Fix phantom agent status rows (#4498)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
4a6cc16777
commit
b7b39d2c42
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue