Fix phantom agent status rows (#4498)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-02 17:44:44 -04:00 committed by GitHub
parent 4a6cc16777
commit b7b39d2c42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 306 additions and 18 deletions

View File

@ -5,14 +5,16 @@ import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111')
const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222')
const LEAF_ID_1 = '22222222-2222-4222-8222-222222222222'
const LEAF_ID_1_SECOND = '77777777-7777-4777-8777-777777777777'
const PANE_KEY_1 = makePaneKey('tab-1', LEAF_ID_1)
const PANE_KEY_2 = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333')
const PANE_KEY_3 = makePaneKey('tab-3', '55555555-5555-4555-8555-555555555555')
const PANE_KEY_4 = makePaneKey('tab-4', '66666666-6666-4666-8666-666666666666')
@ -59,6 +61,27 @@ function makeRetained(paneKey: string, worktreeId: string, startedAt: number): R
}
}
function makeSinglePaneLayout(leafId: string): TerminalLayoutSnapshot {
return {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null
}
}
function makeSplitPaneLayout(firstLeafId: string, secondLeafId: string): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: firstLeafId },
second: { type: 'leaf', leafId: secondLeafId }
},
activeLeafId: firstLeafId,
expandedLeafId: null
}
}
describe('buildWorktreeAgentRows', () => {
it('includes retained rows even when their original tab is no longer current', () => {
const rows = buildWorktreeAgentRows({
@ -89,6 +112,44 @@ describe('buildWorktreeAgentRows', () => {
expect(rows[0].startedAt).toBe(2000)
})
it('dedupes retained legacy numeric rows for a single current stable pane', () => {
const liveEntry = makeEntry(PANE_KEY_1, 2000, {
state: 'working',
agentType: 'copilot',
prompt: 'current turn'
})
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [liveEntry],
retained: [makeRetained('tab-1:1', 'wt-1', 1000), makeRetained('tab-1:2', 'wt-1', 1500)],
terminalLayoutsByTabId: {
'tab-1': makeSinglePaneLayout(LEAF_ID_1)
},
now: 3000
})
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1])
})
it('keeps a retained legacy numeric row for a different split pane', () => {
const liveEntry = makeEntry(PANE_KEY_1, 2000, {
state: 'working',
agentType: 'copilot',
prompt: 'current turn'
})
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [liveEntry],
retained: [makeRetained('tab-1:1', 'wt-1', 1000), makeRetained('tab-1:2', 'wt-1', 1500)],
terminalLayoutsByTabId: {
'tab-1': makeSplitPaneLayout(LEAF_ID_1, LEAF_ID_1_SECOND)
},
now: 3000
})
expect(rows.map((row) => row.paneKey)).toEqual(['tab-1:2', PANE_KEY_1])
})
it('decays a stale working entry to idle but leaves a stale done entry alone', () => {
// Why: the freshness scheduler ticks agentStatusEpoch when an entry crosses
// the stale boundary; the row state machine must collapse working/blocked/

View File

@ -6,8 +6,17 @@ import {
type AgentStatusEntry,
type AgentStatusOrchestrationContext
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import {
makePaneKey,
parseLegacyNumericPaneKey,
parsePaneKey
} from '../../../../shared/stable-pane-id'
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TerminalTab
} from '../../../../shared/types'
import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id'
import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows'
function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null {
@ -70,6 +79,53 @@ function entryWithRuntimeOrchestration(
return { ...entry, orchestration }
}
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
if (!node) {
return 0
}
if (node.type === 'leaf') {
return 1
}
return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second)
}
function seenStablePaneKeysForTab(seenPaneKeys: Set<string>, tabId: string): string[] {
const keys: string[] = []
for (const paneKey of seenPaneKeys) {
const parsed = parsePaneKey(paneKey)
if (parsed?.tabId === tabId) {
keys.push(paneKey)
}
}
return keys
}
function isRetainedLegacyAliasOfSeenStablePane(args: {
paneKey: string
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
seenPaneKeys: Set<string>
}): boolean {
const legacy = parseLegacyNumericPaneKey(args.paneKey)
if (!legacy) {
return false
}
const stablePaneKeys = seenStablePaneKeysForTab(args.seenPaneKeys, legacy.tabId)
if (stablePaneKeys.length === 0) {
return false
}
const layout = args.terminalLayoutsByTabId?.[legacy.tabId]
const leafId = resolveRuntimePaneTitleLeafId(layout, legacy.numericPaneId)
if (leafId) {
return args.seenPaneKeys.has(makePaneKey(legacy.tabId, leafId))
}
// Why: old PaneManager ids can advance across remounts/updates even for a
// single physical pane. Once the tab has exactly one current stable pane,
// retained numeric rows under that tab are stale aliases of it.
return countTerminalLayoutLeaves(layout?.root) === 1 && stablePaneKeys.length === 1
}
export function buildWorktreeAgentRows(args: {
tabs: TerminalTab[]
entries: AgentStatusEntry[]
@ -152,6 +208,15 @@ export function buildWorktreeAgentRows(args: {
if (seenPaneKeys.has(ra.entry.paneKey)) {
continue
}
if (
isRetainedLegacyAliasOfSeenStablePane({
paneKey: ra.entry.paneKey,
terminalLayoutsByTabId: args.terminalLayoutsByTabId,
seenPaneKeys
})
) {
continue
}
const rowEntry = entryWithRuntimeOrchestration(
ra.entry,
args.runtimeAgentOrchestrationByPaneKey

View File

@ -1078,7 +1078,12 @@ describe('useIpcEvents updater integration', () => {
leafId?: string
splitFromLeafId?: string
splitDirection?: 'horizontal' | 'vertical'
splitTelemetrySource?: 'contextual_tour' | 'keyboard' | 'context_menu' | 'command' | 'unknown'
splitTelemetrySource?:
| 'contextual_tour'
| 'keyboard'
| 'context_menu'
| 'command'
| 'unknown'
}) => void)
| null
} = { current: null }
@ -1190,7 +1195,12 @@ describe('useIpcEvents updater integration', () => {
leafId?: string
splitFromLeafId?: string
splitDirection?: 'horizontal' | 'vertical'
splitTelemetrySource?: 'contextual_tour' | 'keyboard' | 'context_menu' | 'command' | 'unknown'
splitTelemetrySource?:
| 'contextual_tour'
| 'keyboard'
| 'context_menu'
| 'command'
| 'unknown'
}) => void
) => {
createTerminalListenerRef.current = listener
@ -2501,6 +2511,15 @@ describe('useIpcEvents agent status snapshot integration', () => {
toolInput?: string
lastAssistantMessage?: string
interrupted?: boolean
terminalHandle?: string
orchestration?: {
taskId?: string
dispatchId?: string
parentTerminalHandle?: string
parentPaneKey?: string
coordinatorHandle?: string
orchestrationRunId?: string
}
connectionId?: string | null
receivedAt: number
stateStartedAt: number
@ -3993,6 +4012,140 @@ describe('useIpcEvents agent status snapshot integration', () => {
expect(setAgentStatus).not.toHaveBeenCalled()
})
it('silently discards stale worktree-attributed snapshots for unknown panes', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>
Promise.resolve([
{
paneKey: ORPHAN_PANE_KEY,
state: 'done' as const,
prompt: 'old copilot turn',
agentType: 'copilot',
worktreeId: 'wt-1',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
}
])
)
const storeState: StoreLike = buildStoreState({
setAgentStatus,
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Copilot' }]
},
terminalLayoutsByTabId: {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
},
workspaceSessionReady: true
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
getSnapshot,
onSet: () => () => {}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
await Promise.resolve()
expect(setAgentStatus).not.toHaveBeenCalled()
})
it('applies worktree-attributed child snapshots when runtime identity is present', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>
Promise.resolve([
{
paneKey: ORPHAN_PANE_KEY,
state: 'working' as const,
prompt: 'child task',
agentType: 'codex',
worktreeId: 'wt-1',
terminalHandle: 'term-child',
orchestration: {
taskId: 'task-child',
dispatchId: 'dispatch-child',
parentTerminalHandle: 'term-parent'
},
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
}
])
)
const storeState: StoreLike = buildStoreState({
setAgentStatus,
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] },
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }]
},
terminalLayoutsByTabId: {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
},
workspaceSessionReady: true
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
getSnapshot,
onSet: () => () => {}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
await Promise.resolve()
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(setAgentStatus).toHaveBeenCalledWith(
ORPHAN_PANE_KEY,
expect.objectContaining({
state: 'working',
prompt: 'child task',
agentType: 'codex',
orchestration: expect.objectContaining({ taskId: 'task-child' })
}),
undefined,
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 },
expect.objectContaining({ worktreeId: 'wt-1', terminalHandle: 'term-child' })
)
})
it('silently discards valid paneKeys whose leaf is not in the current layout', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>

View File

@ -1219,16 +1219,18 @@ export function useIpcEvents(): void {
)
unsubs.push(
window.api.ui.onSplitTerminal(({ tabId, paneRuntimeId, direction, command, telemetrySource }) => {
const detail: SplitTerminalPaneDetail = {
tabId,
paneRuntimeId,
direction,
command,
telemetrySource
window.api.ui.onSplitTerminal(
({ tabId, paneRuntimeId, direction, command, telemetrySource }) => {
const detail: SplitTerminalPaneDetail = {
tabId,
paneRuntimeId,
direction,
command,
telemetrySource
}
window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail }))
}
window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail }))
})
)
)
unsubs.push(
@ -2255,11 +2257,11 @@ export function useIpcEvents(): void {
repoConnectionResolved,
owningWorktreeId
} = resolvePaneKey(store, data.paneKey)
if (!exists && data.worktreeId) {
if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
// Why: orchestration worker hooks can carry main-side worktree
// attribution before this renderer has a terminal tab for the pane.
// Accept those only when the worktree is known, then keep the normal
// repo connection check below for SSH/local ownership.
// Require runtime identity too; durable snapshots with only worktreeId
// can be stale cached rows from closed/remounted panes.
const fallbackOwnership = resolveWorktreeConnection(store, data.worktreeId)
if (fallbackOwnership.worktreeExists) {
owningWorktreeId = data.worktreeId
@ -2610,6 +2612,13 @@ export function useIpcEvents(): void {
}, [])
}
function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean {
return (
(typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) ||
data.orchestration !== undefined
)
}
function applyResolvedAgentTerminalTitleToTab(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string,