Preserve agent lineage across status replay and worktree cleanup

- Enrich agent status snapshots with runtime terminal and orchestration metadata
- Share lineage tree building between dashboard and sidebar, including coordinator fallback
- Avoid applying stale runtime dispatch metadata to current hook statuses
- Use worktree attribution for PR refresh and live status removal before tabs mount
This commit is contained in:
Jinjing
2026-06-01 02:11:10 -07:00
parent 2c998c2397
commit d29396441d
16 changed files with 537 additions and 163 deletions
+63
View File
@@ -15,6 +15,7 @@ const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => u
const removeHandler = vi.fn()
const removeAllListeners = vi.fn()
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const CHILD_PANE_KEY = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222')
vi.mock('electron', () => ({
ipcMain: {
@@ -114,6 +115,68 @@ describe('agentStatus:getSnapshot IPC', () => {
expect(handler).toBeDefined()
expect(handler!({})).toEqual(snapshot)
})
it('enriches the hook cache snapshot with runtime lineage metadata', async () => {
const snapshot = [
{
paneKey: PANE_KEY,
state: 'done',
prompt: 'parent',
agentType: 'codex',
connectionId: null,
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
},
{
paneKey: CHILD_PANE_KEY,
state: 'done',
prompt: 'child',
agentType: 'codex',
connectionId: null,
receivedAt: 1_700_000_001_000,
stateStartedAt: 1_700_000_000_500
}
]
getStatusSnapshot.mockReturnValue(snapshot)
const runtime = {
getAgentStatusTerminalHandleForPaneKey: vi.fn((paneKey: string) =>
paneKey === PANE_KEY ? 'term-parent' : paneKey === CHILD_PANE_KEY ? 'term-child' : undefined
),
getAgentStatusOrchestrationContextForPaneKey: vi.fn((paneKey: string) =>
paneKey === CHILD_PANE_KEY
? {
taskId: 'task-child',
dispatchId: 'dispatch-child',
parentTerminalHandle: 'term-parent',
parentPaneKey: PANE_KEY,
coordinatorHandle: 'term-parent'
}
: undefined
)
}
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers(runtime)
const handler = handleHandlers.get('agentStatus:getSnapshot')
expect(handler).toBeDefined()
expect(handler!({})).toEqual([
{
...snapshot[0],
terminalHandle: 'term-parent'
},
{
...snapshot[1],
terminalHandle: 'term-child',
orchestration: {
taskId: 'task-child',
dispatchId: 'dispatch-child',
parentTerminalHandle: 'term-parent',
parentPaneKey: PANE_KEY,
coordinatorHandle: 'term-parent'
}
}
])
})
})
describe('agentHooks:antigravityStatus IPC', () => {
+28 -3
View File
@@ -5,6 +5,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import { ampHookService } from '../amp/hook-service'
import {
@@ -23,11 +24,32 @@ import { copilotHookService } from '../copilot/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
type AgentStatusRuntimeEnrichment = Pick<
OrcaRuntimeService,
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
>
function enrichAgentStatusIpcPayload(
data: AgentStatusIpcPayload,
runtime: AgentStatusRuntimeEnrichment | undefined
): AgentStatusIpcPayload {
if (!runtime) {
return data
}
const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey)
const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey)
return {
...data,
...(terminalHandle ? { terminalHandle } : {}),
...(orchestration ? { orchestration } : {})
}
}
// Why: install/remove are intentionally not exposed to the renderer. Orca
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
// renderer-triggered remove would be silently reverted on the next launch
// and mislead the user.
export function registerAgentHookHandlers(): void {
export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment): void {
// Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration
// never throws "Attempted to register a second handler..." if this function is
// ever invoked more than once (e.g. the macOS app re-activation path that
@@ -71,8 +93,11 @@ export function registerAgentHookHandlers(): void {
})
ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => {
// Why: the renderer pulls this after workspace hydration, so startup cannot
// lose replayed statuses while its local store is still empty.
return agentHookServer.getStatusSnapshot()
// lose replayed statuses while its local store is still empty. Match the
// live push enrichment in main/index.ts so parent/child rows survive replay.
return agentHookServer
.getStatusSnapshot()
.map((entry) => enrichAgentStatusIpcPayload(entry, runtime))
})
ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => {
if (typeof request !== 'object' || request === null) {
+1 -1
View File
@@ -357,7 +357,7 @@ describe('registerCoreHandlers', () => {
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch })
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
expect(registerAgentHookHandlersMock).toHaveBeenCalled()
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerPetHandlersMock).toHaveBeenCalled()
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
+1 -1
View File
@@ -103,7 +103,7 @@ export function registerCoreHandlers(
registerCodexUsageHandlers(codexUsage)
registerOpenCodeUsageHandlers(openCodeUsage)
registerCodexAccountHandlers(codexAccounts)
registerAgentHookHandlers()
registerAgentHookHandlers(runtime)
registerAgentTrustHandlers()
registerClaudeAccountHandlers(claudeAccounts)
registerRateLimitHandlers(rateLimits)
+56
View File
@@ -7869,6 +7869,62 @@ describe('OrcaRuntimeService', () => {
})
})
it('does not attach an unrelated active coordinator run to a completed dispatch', () => {
const runtime = new OrcaRuntimeService(store)
const workerLeafId = '55555555-5555-4555-8555-555555555555'
const workerPaneKey = makePaneKey('tab-worker', workerLeafId)
const workerHandle = runtime.preAllocateHandleForPty('pty-worker')
runtime.setOrchestrationDb({
getActiveDispatchForTerminal: vi.fn(() => undefined),
getLatestDispatchForTerminal: vi.fn((handle: string) =>
handle === workerHandle
? {
id: 'ctx-done',
task_id: 'task-done',
assignee_handle: workerHandle,
status: 'completed',
completed_at: new Date(Date.now()).toISOString()
}
: undefined
),
getTask: vi.fn(() => ({
id: 'task-done'
})),
getActiveCoordinatorRun: vi.fn(() => ({
id: 'run-unrelated',
coordinator_handle: 'term_unrelated'
}))
} as never)
runtime.attachWindow(1)
const result = runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-worker',
worktreeId: TEST_WORKTREE_ID,
title: 'Claude Code',
activeLeafId: workerLeafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-worker',
worktreeId: TEST_WORKTREE_ID,
leafId: workerLeafId,
paneRuntimeId: 1,
ptyId: 'pty-worker',
paneTitle: null
}
]
})
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({
taskId: 'task-done',
dispatchId: 'ctx-done'
})
})
it('does not return stale completed orchestration context for renderer-synced terminal leaves', () => {
const runtime = new OrcaRuntimeService(store)
const workerLeafId = '77777777-7777-4777-8777-777777777777'
+1 -1
View File
@@ -11561,7 +11561,7 @@ export class OrcaRuntimeService {
return undefined
}
const task = db?.getTask?.(dispatch.task_id)
const activeRun = db?.getActiveCoordinatorRun?.()
const activeRun = dispatch.status === 'completed' ? undefined : db?.getActiveCoordinatorRun?.()
const parentTerminalHandle =
task?.created_by_terminal_handle ??
(activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import type { DashboardAgentRow } from './useDashboardData'
import { buildAgentRowLineageTree } from './agent-row-lineage-model'
function makeTab(id: string): TerminalTab {
return {
id,
worktreeId: 'wt-1',
ptyId: null,
title: 'Agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 0
}
}
function makeRow(
paneKey: string,
options: {
terminalHandle?: string
parentPaneKey?: string
parentTerminalHandle?: string
coordinatorHandle?: string
} = {}
): DashboardAgentRow {
const orchestration =
options.parentPaneKey || options.parentTerminalHandle || options.coordinatorHandle
? {
taskId: `${paneKey}-task`,
dispatchId: `${paneKey}-dispatch`,
...(options.parentPaneKey ? { parentPaneKey: options.parentPaneKey } : {}),
...(options.parentTerminalHandle
? { parentTerminalHandle: options.parentTerminalHandle }
: {}),
...(options.coordinatorHandle ? { coordinatorHandle: options.coordinatorHandle } : {})
}
: undefined
const entry: AgentStatusEntry = {
paneKey,
state: 'done',
prompt: paneKey,
updatedAt: 1000,
stateStartedAt: 1000,
stateHistory: [],
agentType: 'codex',
...(options.terminalHandle ? { terminalHandle: options.terminalHandle } : {}),
...(orchestration ? { orchestration } : {})
}
return {
paneKey,
entry,
tab: makeTab(paneKey.split(':')[0] ?? paneKey),
agentType: 'codex',
state: 'done',
startedAt: 1000
}
}
describe('buildAgentRowLineageTree', () => {
it('groups children by explicit parent pane key', () => {
const parent = makeRow('parent:1')
const child = makeRow('child:1', { parentPaneKey: 'parent:1' })
const tree = buildAgentRowLineageTree([parent, child])
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1'])
expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([
'child:1'
])
})
it('falls back to the parent terminal handle when the parent pane key is absent', () => {
const parent = makeRow('parent:1', { terminalHandle: 'term-parent' })
const child = makeRow('child:1', { parentTerminalHandle: 'term-parent' })
const tree = buildAgentRowLineageTree([parent, child])
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1'])
expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([
'child:1'
])
})
it('uses the coordinator handle as the last visible parent fallback', () => {
const parent = makeRow('parent:1', { terminalHandle: 'term-coordinator' })
const child = makeRow('child:1', { coordinatorHandle: 'term-coordinator' })
const tree = buildAgentRowLineageTree([parent, child])
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1'])
expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([
'child:1'
])
})
it('keeps cyclic lineage rows visible as flat roots', () => {
const root = makeRow('root:1')
const firstCycleRow = makeRow('cycle-a:1', { parentPaneKey: 'cycle-b:1' })
const secondCycleRow = makeRow('cycle-b:1', { parentPaneKey: 'cycle-a:1' })
const tree = buildAgentRowLineageTree([root, firstCycleRow, secondCycleRow])
expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['root:1', 'cycle-a:1', 'cycle-b:1'])
expect(tree.childrenByParentPaneKey.size).toBe(0)
expect(tree.childPaneKeys.size).toBe(0)
})
})
@@ -0,0 +1,129 @@
import type { DashboardAgentRow } from './useDashboardData'
export type AgentRowLineageTree<T extends DashboardAgentRow> = {
rootRows: T[]
childrenByParentPaneKey: Map<string, T[]>
childPaneKeys: Set<string>
}
function buildPaneKeyByTerminalHandle<T extends DashboardAgentRow>(
rows: readonly T[]
): Map<string, string> {
const paneKeyByTerminalHandle = new Map<string, string>()
for (const row of rows) {
if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) {
paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey)
}
}
return paneKeyByTerminalHandle
}
export function resolveAgentRowParentPaneKey<T extends DashboardAgentRow>(
row: T,
rowsByPaneKey: ReadonlyMap<string, T>,
paneKeyByTerminalHandle: ReadonlyMap<string, string>
): string | undefined {
const explicitParentPaneKey = row.entry.orchestration?.parentPaneKey
if (
explicitParentPaneKey &&
explicitParentPaneKey !== row.paneKey &&
rowsByPaneKey.has(explicitParentPaneKey)
) {
return explicitParentPaneKey
}
const parentTerminalHandles = [
row.entry.orchestration?.parentTerminalHandle,
row.entry.orchestration?.coordinatorHandle
]
for (const parentTerminalHandle of parentTerminalHandles) {
const parentPaneKey = parentTerminalHandle
? paneKeyByTerminalHandle.get(parentTerminalHandle)
: undefined
if (parentPaneKey && parentPaneKey !== row.paneKey && rowsByPaneKey.has(parentPaneKey)) {
return parentPaneKey
}
}
return undefined
}
export function buildAgentRowLineageTree<T extends DashboardAgentRow>(
rows: readonly T[]
): AgentRowLineageTree<T> {
const rowsByPaneKey = new Map<string, T>()
for (const row of rows) {
if (!rowsByPaneKey.has(row.paneKey)) {
rowsByPaneKey.set(row.paneKey, row)
}
}
const paneKeyByTerminalHandle = buildPaneKeyByTerminalHandle(rows)
const childrenByParentPaneKey = new Map<string, T[]>()
const childPaneKeys = new Set<string>()
for (const row of rows) {
const parentPaneKey = resolveAgentRowParentPaneKey(row, rowsByPaneKey, paneKeyByTerminalHandle)
if (!parentPaneKey) {
continue
}
childPaneKeys.add(row.paneKey)
const siblings = childrenByParentPaneKey.get(parentPaneKey)
if (siblings) {
siblings.push(row)
} else {
childrenByParentPaneKey.set(parentPaneKey, [row])
}
}
const rootRows = rows.filter((row) => !childPaneKeys.has(row.paneKey))
if (rootRows.length === 0 && rows.length > 0) {
// Why: malformed orchestration metadata can form a closed cycle. Keep every
// row visible as a flat root instead of hiding all participants.
return { rootRows: [...rows], childrenByParentPaneKey: new Map(), childPaneKeys: new Set() }
}
const reachablePaneKeys = new Set<string>()
const markReachable = (row: T, ancestorPaneKeys: ReadonlySet<string> = new Set()): void => {
if (reachablePaneKeys.has(row.paneKey) || ancestorPaneKeys.has(row.paneKey)) {
return
}
reachablePaneKeys.add(row.paneKey)
const descendantAncestorPaneKeys = new Set(ancestorPaneKeys)
descendantAncestorPaneKeys.add(row.paneKey)
for (const childRow of childrenByParentPaneKey.get(row.paneKey) ?? []) {
markReachable(childRow, descendantAncestorPaneKeys)
}
}
for (const rootRow of rootRows) {
markReachable(rootRow)
}
const unreachableRows = rows.filter((row) => !reachablePaneKeys.has(row.paneKey))
if (unreachableRows.length === 0) {
return { rootRows, childrenByParentPaneKey, childPaneKeys }
}
const normalizedChildrenByParentPaneKey = new Map(childrenByParentPaneKey)
const normalizedChildPaneKeys = new Set(childPaneKeys)
for (const row of unreachableRows) {
if (!rootRows.some((rootRow) => rootRow.paneKey === row.paneKey)) {
rootRows.push(row)
}
normalizedChildPaneKeys.delete(row.paneKey)
normalizedChildrenByParentPaneKey.delete(row.paneKey)
for (const [parentPaneKey, siblings] of normalizedChildrenByParentPaneKey) {
const visibleSiblings = siblings.filter((sibling) => sibling.paneKey !== row.paneKey)
if (visibleSiblings.length === 0) {
normalizedChildrenByParentPaneKey.delete(parentPaneKey)
} else if (visibleSiblings.length !== siblings.length) {
normalizedChildrenByParentPaneKey.set(parentPaneKey, visibleSiblings)
}
}
}
return {
rootRows,
childrenByParentPaneKey: normalizedChildrenByParentPaneKey,
childPaneKeys: normalizedChildPaneKeys
}
}
@@ -1,4 +1,5 @@
import type { DashboardAgentRow } from './useDashboardData'
import { buildAgentRowLineageTree } from './agent-row-lineage-model'
export type AgentRowLineagePresentation = {
depth: 0 | 1
@@ -23,35 +24,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR
return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
}
const rowsByPaneKey = new Map(rows.map((row) => [row.paneKey, row]))
const paneKeyByTerminalHandle = new Map<string, string>()
for (const row of rows) {
if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) {
paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey)
}
}
const childrenByParentPaneKey = new Map<string, DashboardAgentRow[]>()
const childPaneKeys = new Set<string>()
for (const row of rows) {
const explicitParentPaneKey = row.entry.orchestration?.parentPaneKey
const parentPaneKey =
explicitParentPaneKey && rowsByPaneKey.has(explicitParentPaneKey)
? explicitParentPaneKey
: row.entry.orchestration?.parentTerminalHandle
? paneKeyByTerminalHandle.get(row.entry.orchestration.parentTerminalHandle)
: undefined
if (!parentPaneKey || parentPaneKey === row.paneKey || !rowsByPaneKey.has(parentPaneKey)) {
continue
}
childPaneKeys.add(row.paneKey)
const siblings = childrenByParentPaneKey.get(parentPaneKey)
if (siblings) {
siblings.push(row)
} else {
childrenByParentPaneKey.set(parentPaneKey, [row])
}
}
const { rootRows, childrenByParentPaneKey, childPaneKeys } = buildAgentRowLineageTree(rows)
if (childPaneKeys.size === 0) {
return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE }))
@@ -86,10 +59,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR
})
}
for (const row of rows) {
if (childPaneKeys.has(row.paneKey)) {
continue
}
for (const row of rootRows) {
emitSubtree(row, ROOT_LINEAGE)
}
@@ -15,7 +15,11 @@ type MockAgentOptions = {
lastAssistantMessage?: string
stateStartedAt?: number
terminalHandle?: string
orchestration?: { parentPaneKey?: string; parentTerminalHandle?: string }
orchestration?: {
parentPaneKey?: string
parentTerminalHandle?: string
coordinatorHandle?: string
}
lineage?: {
depth: number
isFirstSibling: boolean
@@ -246,6 +250,30 @@ describe('WorktreeCardAgents', () => {
expect(markup).toContain('aria-label="Hide 1 child agent"')
})
it('shows orchestration children under a visible coordinator when parent handle is absent', async () => {
mockAgentActivityDisplayMode = 'full'
mockAgents = [
mockAgent({
paneKey: 'tab-parent:1',
terminalHandle: 'term-coordinator'
}),
mockAgent({
paneKey: 'tab-child:1',
state: 'done',
stateStartedAt: 1500,
orchestration: { coordinatorHandle: 'term-coordinator' }
})
]
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
const markup = renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
expect(markup).toContain('role="tree"')
expect(markup).toContain('data-pane-key="tab-parent:1"')
expect(markup).toContain('data-pane-key="tab-child:1"')
expect(markup).toContain('aria-label="Hide 1 child agent"')
})
it('keeps partially cyclic orchestration rows visible as flat roots', async () => {
mockAgentActivityDisplayMode = 'full'
mockAgents = [
@@ -19,6 +19,7 @@ import {
CompactAgentRow,
CompactAgentSummaryButton
} from './worktree-card-compact-agents'
import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model'
import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants'
import { revealElementInScrollContainer } from './worktree-sidebar-reveal'
@@ -74,82 +75,6 @@ type BodyProps = {
className?: string
}
type AgentLineageModel = {
rootAgents: DashboardAgentRowData[]
childrenByParentPaneKey: Map<string, DashboardAgentRowData[]>
}
function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageModel {
const agentPaneKeys = new Set(agents.map((agent) => agent.paneKey))
const paneKeyByTerminalHandle = new Map<string, string>()
for (const agent of agents) {
if (agent.entry.terminalHandle && !paneKeyByTerminalHandle.has(agent.entry.terminalHandle)) {
paneKeyByTerminalHandle.set(agent.entry.terminalHandle, agent.paneKey)
}
}
const childrenByParentPaneKey = new Map<string, DashboardAgentRowData[]>()
const childPaneKeys = new Set<string>()
for (const agent of agents) {
const explicitParentPaneKey = agent.entry.orchestration?.parentPaneKey
const parentPaneKey =
explicitParentPaneKey && agentPaneKeys.has(explicitParentPaneKey)
? explicitParentPaneKey
: agent.entry.orchestration?.parentTerminalHandle
? paneKeyByTerminalHandle.get(agent.entry.orchestration.parentTerminalHandle)
: undefined
if (!parentPaneKey || parentPaneKey === agent.paneKey || !agentPaneKeys.has(parentPaneKey)) {
continue
}
childPaneKeys.add(agent.paneKey)
const siblings = childrenByParentPaneKey.get(parentPaneKey)
if (siblings) {
siblings.push(agent)
} else {
childrenByParentPaneKey.set(parentPaneKey, [agent])
}
}
const rootAgents = agents.filter((agent) => !childPaneKeys.has(agent.paneKey))
if (rootAgents.length === 0 && agents.length > 0) {
// Why: malformed orchestration metadata can theoretically form a cycle.
// Keep every row visible instead of recursing forever or hiding the list.
return { rootAgents: agents, childrenByParentPaneKey: new Map() }
}
const reachablePaneKeys = new Set<string>()
const markReachable = (
agent: DashboardAgentRowData,
ancestorPaneKeys: ReadonlySet<string> = new Set()
): void => {
if (reachablePaneKeys.has(agent.paneKey) || ancestorPaneKeys.has(agent.paneKey)) {
return
}
reachablePaneKeys.add(agent.paneKey)
const descendantAncestorPaneKeys = new Set(ancestorPaneKeys)
descendantAncestorPaneKeys.add(agent.paneKey)
for (const childAgent of childrenByParentPaneKey.get(agent.paneKey) ?? []) {
markReachable(childAgent, descendantAncestorPaneKeys)
}
}
for (const rootAgent of rootAgents) {
markReachable(rootAgent)
}
for (const agent of agents) {
if (reachablePaneKeys.has(agent.paneKey)) {
continue
}
// Why: a partial cycle alongside a valid root has no true root, so it
// would otherwise disappear. Render malformed participants as flat rows
// and drop their child edges, matching the dashboard lineage fallback.
rootAgents.push(agent)
childrenByParentPaneKey.delete(agent.paneKey)
}
return { rootAgents, childrenByParentPaneKey }
}
const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
worktreeId,
agents,
@@ -287,8 +212,8 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
// never mount this component (see WorktreeCardAgents), so idle worktrees
// don't pay any timer cost.
const now = useNow(30_000)
const { rootAgents, childrenByParentPaneKey } = useMemo(
() => buildAgentLineageModel(agents),
const { rootRows: rootAgents, childrenByParentPaneKey } = useMemo(
() => buildAgentRowLineageTree(agents),
[agents]
)
const hasLineage = childrenByParentPaneKey.size > 0
@@ -169,49 +169,49 @@ describe('buildWorktreeAgentRows', () => {
expect(rows[1].entry.orchestration).toMatchObject({ parentPaneKey: PANE_KEY_1 })
})
it('uses fresh runtime orchestration metadata over stale hook-reported dispatch identity', () => {
const staleParent = makeEntry(PANE_KEY_1, 1000, {
prompt: 'stale parent',
it('does not override current hook dispatch identity with mismatched runtime metadata', () => {
const currentParent = makeEntry(PANE_KEY_1, 1000, {
prompt: 'current parent',
state: 'done'
})
const currentParent = makeEntry(PANE_KEY_3, 1100, {
prompt: 'current parent',
const staleParent = makeEntry(PANE_KEY_3, 1100, {
prompt: 'stale parent',
state: 'working'
})
const child = makeEntry(PANE_KEY_2, 1200, {
prompt: 'child',
state: 'working',
orchestration: {
taskId: 'task-1',
dispatchId: 'ctx-1',
taskId: 'task-2',
dispatchId: 'ctx-2',
parentPaneKey: PANE_KEY_1,
parentTerminalHandle: 'term-stale'
parentTerminalHandle: 'term-current'
}
})
const rows = applyAgentRowLineage(
buildWorktreeAgentRows({
tabs: [makeTab('tab-1'), makeTab('tab-2'), makeTab('tab-3')],
entries: [staleParent, currentParent, child],
entries: [currentParent, staleParent, child],
retained: [],
runtimeAgentOrchestrationByPaneKey: {
[PANE_KEY_2]: {
taskId: 'task-2',
dispatchId: 'ctx-2',
taskId: 'task-1',
dispatchId: 'ctx-1',
parentPaneKey: PANE_KEY_3,
parentTerminalHandle: 'term-current'
parentTerminalHandle: 'term-stale'
}
},
now: 2000
})
)
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1, PANE_KEY_3, PANE_KEY_2])
expect(rows[1].lineage).toMatchObject({ depth: 0, childCount: 1 })
expect(rows[2].lineage).toMatchObject({ depth: 1, childCount: 0 })
expect(rows[2].entry.orchestration).toEqual({
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1, PANE_KEY_2, PANE_KEY_3])
expect(rows[0].lineage).toMatchObject({ depth: 0, childCount: 1 })
expect(rows[1].lineage).toMatchObject({ depth: 1, childCount: 0 })
expect(rows[1].entry.orchestration).toEqual({
taskId: 'task-2',
dispatchId: 'ctx-2',
parentPaneKey: PANE_KEY_3,
parentPaneKey: PANE_KEY_1,
parentTerminalHandle: 'term-current'
})
})
@@ -51,6 +51,9 @@ function entryWithRuntimeOrchestration(
runtimeOrchestration &&
entry.orchestration.taskId === runtimeOrchestration.taskId &&
entry.orchestration.dispatchId === runtimeOrchestration.dispatchId
if (entry.orchestration && runtimeOrchestration && !sameDispatch) {
return entry
}
const orchestration =
sameDispatch && entry.orchestration && runtimeOrchestration
? { ...entry.orchestration, ...runtimeOrchestration }
@@ -69,11 +69,6 @@ export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): Summar
})
}
export function formatSummaryGroupLabel(group: SummaryAgentGroup): string {
const label = formatSummaryStateLabel(group.state)
return group.agents.length === 1 ? label : `${group.agents.length} ${label}`
}
export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string {
const counts = new Map<AgentDotState, number>()
for (const agent of agents) {
@@ -4,7 +4,6 @@ import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import type { AppState } from '../types'
import type { RetainedAgentEntry } from './agent-status'
import { createTestStore, makeTab, makeWorktree } from './store-test-helpers'
@@ -363,7 +362,7 @@ describe('agent status runtime orchestration metadata', () => {
const retained: RetainedAgentEntry = {
entry,
worktreeId: 'wt-1',
tab: { id: 'tab-child', title: 'codex' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-child', worktreeId: 'wt-1', title: 'codex' }),
agentType: 'codex',
startedAt: now
}
@@ -696,6 +695,47 @@ describe('agent status PR refresh handoff', () => {
})
})
it('uses hook worktree attribution for PR refresh when the agent tab is not mounted', async () => {
vi.useFakeTimers()
const enqueuePRRefresh = stubGitHubPRRefreshApi()
const store = createTestStore()
seedAgentPRRefreshFixture(store, ['pr'])
store.setState({ tabsByWorktree: { 'wt-1': [] } } as Partial<AppState>)
const paneKey = 'tab-worker:11111111-1111-4111-8111-111111111111'
store
.getState()
.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'create a PR', agentType: 'codex' },
undefined,
undefined,
{ tabId: 'tab-worker', worktreeId: 'wt-1', terminalHandle: 'term-worker' }
)
store
.getState()
.setAgentStatus(
paneKey,
{ state: 'done', prompt: 'create a PR', agentType: 'codex' },
undefined,
undefined,
{ tabId: 'tab-worker', worktreeId: 'wt-1', terminalHandle: 'term-worker' }
)
await flushMicrotasks()
expect(enqueuePRRefresh).toHaveBeenCalledWith({
candidate: expect.objectContaining({
repoPath: '/repo',
branch: 'feature/pr-from-agent',
worktreeId: 'wt-1',
linkedPRNumber: null
}),
reason: 'active',
priority: 80
})
})
it('does not spend a PR refresh when no PR surface is visible', async () => {
vi.useFakeTimers()
const enqueuePRRefresh = stubGitHubPRRefreshApi()
@@ -881,14 +921,14 @@ describe('agent status retention + prefix sweep', () => {
const retainedA: RetainedAgentEntry = {
entry: entryA,
worktreeId: 'wt-a',
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
agentType: 'claude',
startedAt: now
}
const retainedB: RetainedAgentEntry = {
entry: entryB,
worktreeId: 'wt-b',
tab: { id: 'tab-b', title: 'claude' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-b', worktreeId: 'wt-b', title: 'claude' }),
agentType: 'claude',
startedAt: now
}
@@ -931,14 +971,14 @@ describe('agent status retention + prefix sweep', () => {
const retainedA: RetainedAgentEntry = {
entry: entryA,
worktreeId: 'wt-a',
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
agentType: 'claude',
startedAt: now
}
const retainedB: RetainedAgentEntry = {
entry: entryB,
worktreeId: 'wt-a',
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
agentType: 'claude',
startedAt: now
}
@@ -958,6 +998,35 @@ describe('agent status retention + prefix sweep', () => {
expect(suppressed['tab-a:1']).toBeUndefined()
})
it('dropAgentStatusByWorktree removes live entries attributed before their tab exists', () => {
vi.useFakeTimers()
const store = createTestStore()
const paneKey = 'tab-worker:11111111-1111-4111-8111-111111111111'
store
.getState()
.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'worker', agentType: 'codex' },
undefined,
undefined,
{
tabId: 'tab-worker',
worktreeId: 'wt-a',
terminalHandle: 'term-worker'
}
)
store.setState({
acknowledgedAgentsByPaneKey: { [paneKey]: Date.now() }
} as Partial<AppState>)
store.getState().dropAgentStatusByWorktree('wt-a')
expect(store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined()
expect(store.getState().acknowledgedAgentsByPaneKey[paneKey]).toBeUndefined()
expect(store.getState().retentionSuppressedPaneKeys[paneKey]).toBe(true)
})
it('pruneRetainedAgents keeps only entries whose worktreeId is in the valid set', () => {
const store = createTestStore()
const now = Date.now()
@@ -980,14 +1049,14 @@ describe('agent status retention + prefix sweep', () => {
const retainedA: RetainedAgentEntry = {
entry: entryA,
worktreeId: 'wt-a',
tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }),
agentType: 'claude',
startedAt: now
}
const retainedB: RetainedAgentEntry = {
entry: entryB,
worktreeId: 'wt-b',
tab: { id: 'tab-b', title: 'claude' } as unknown as TerminalTab,
tab: makeTab({ id: 'tab-b', worktreeId: 'wt-b', title: 'claude' }),
agentType: 'claude',
startedAt: now
}
+15 -15
View File
@@ -96,15 +96,9 @@ export type AgentStatusSlice = {
* Used on worktree sleep/remove — the whole worktree surface is folding, so
* retained rows must drop even if their original tab is no longer present.
*
* Note on orphan-live asymmetry: liveKeys are matched against tab prefixes
* derived from `tabsByWorktree[worktreeId]`. Live entries belonging to the
* same worktree but whose tab has already been pruned from `tabsByWorktree`
* (an orphan-live race) are not swept here. The retained side has a
* fallback (`retained.worktreeId === worktreeId`); the live side does not,
* because live entries do not carry a worktreeId. In practice the gap is
* bounded — `removeAgentStatus` is called on PTY exit — and dropping an
* orphan-live entry on shutdown is best-effort, so accepting the asymmetry
* is the simpler tradeoff. */
* Live entries are swept by tab prefix and by main-stamped worktree
* attribution so worker rows that arrive before their tab exists do not
* survive sleep/remove. */
dropAgentStatusByWorktree: (worktreeId: string) => void
/** Retain agent snapshots (called by the top-level retention sync effect).
@@ -439,7 +433,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
existing !== undefined &&
!isAgentCompletionState(existing.state)
) {
completionRefreshWorktreeId = findAgentPaneWorktreeId(s, paneKey)
completionRefreshWorktreeId = entry.worktreeId ?? findAgentPaneWorktreeId(s, paneKey)
}
// Why: broad freshness-aware subscribers only need a global tick when
// an entry appears, changes state, crosses stale->fresh, or receives
@@ -852,9 +846,13 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
let hadLive = false
set((s) => {
const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const liveKeys = Object.keys(s.agentStatusByPaneKey).filter((k) =>
paneKeyMatchesAnyTabPrefix(k, tabPrefixes)
)
const liveKeys = Object.entries(s.agentStatusByPaneKey)
.filter(
([paneKey, entry]) =>
entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)
)
.map(([paneKey]) => paneKey)
const liveKeySet = new Set(liveKeys)
const retainedKeys = Object.entries(s.retainedAgentsByPaneKey)
.filter(
([paneKey, retained]) =>
@@ -869,10 +867,12 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
(entry.paneKey ? paneKeyMatchesAnyTabPrefix(entry.paneKey, tabPrefixes) : false)
)
// See removeAgentStatus for rationale on ack cleanup. Current tabs are
// swept by prefix; orphan retained rows are swept by their retained key.
// swept by prefix; attributed live rows and orphan retained rows are
// swept by their retained/lifecycle key.
let nextAck = s.acknowledgedAgentsByPaneKey
const ackKeys = Object.keys(nextAck).filter(
(k) => paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || retainedKeySet.has(k)
(k) =>
paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || liveKeySet.has(k) || retainedKeySet.has(k)
)
if (ackKeys.length > 0) {
nextAck = { ...nextAck }