fix(agents): keep a pane working while its agent's descendants are live

A pane is idle only when its lead session is idle AND no descendant is still
running. That rule existed twice — Claude's roster and Codex's — and nowhere
else, so a Grok nested subagent's SessionEnd/StopFailure and a Pi async
subagent run were read as the pane's own completion.

Generalize the Codex roster into a provider-agnostic descendant roster and
apply the rule once, at the provider-dispatch boundary every provider funnels
through. A descendant's event now updates the child list and republishes the
LEAD's state; it never settles the pane, never relabels the row with the
child's tool or prompt, and never counts as a user submit. Claude keeps its
own roster: its children carry teammate parking, background-task folding and
restored-snapshot provenance that no other provider has.

Install Grok's SubagentStart/SubagentStop so a child can be named rather than
only inferred, and forward pi's async subagent run lifecycle from the managed
status extension.
This commit is contained in:
Merge Sim
2026-09-09 03:19:03 -07:00
parent 65631e449a
commit ccab7c81e7
15 changed files with 724 additions and 118 deletions
+7 -1
View File
@@ -28,7 +28,13 @@ export const GROK_EVENTS = [
eventName: 'PostToolUseFailure',
definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] }
},
{ eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } }
{ eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } },
// Why: nested subagents inherit the parent pane's ORCA_PANE_KEY, so without these the pane only
// ever hears a child through events that look like the session's own. These name the child, which
// is what lets Orca show its row and keep the pane working while a background child outlives the
// parent turn. Older grok builds ignore unregistered event names (the StopFailure precedent).
{ eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }
] as const
export function buildInstalledGrokConfig(
@@ -0,0 +1,52 @@
import type { PiAgentKind } from '../../shared/pi-agent-kind'
/** Async child runs delegated through pi's subagent extension outlive the parent turn: the parent
* settles and goes interactive while the children keep working. Their lifecycle rides pi's process
* event bus rather than the extension API, so it is forwarded here as its own hook event and folded
* into the pane's descendant roster receiver-side — the parent's own `agent_end` stays the parent's. */
export function getPiAgentStatusAsyncSubagentSourceLines(kind: PiAgentKind): string[] {
if (kind !== 'pi') {
return []
}
return [
' // Why: a bus this pi build does not emit on simply never fires; registering costs nothing',
' // and keeps the parent-only path unchanged for installs without the subagent extension.',
' try {',
' const bus = process as unknown as { on?: (event: string, listener: (payload: unknown) => void) => void }',
' const readRunId = (payload: unknown): string => {',
" if (!payload || typeof payload !== 'object') return ''",
' const record = payload as Record<string, unknown>',
" for (const key of ['runId', 'run_id', 'subagentId', 'subagent_id', 'id']) {",
' const value = record[key]',
" if (typeof value === 'string' && value) return value",
' }',
" return ''",
' }',
' const readText = (payload: unknown, keys: string[]): string | undefined => {',
" if (!payload || typeof payload !== 'object') return undefined",
' const record = payload as Record<string, unknown>',
' for (const key of keys) {',
' const value = record[key]',
" if (typeof value === 'string' && value) return value",
' }',
' return undefined',
' }',
' const postAsyncSubagent = (hookEventName: string, payload: unknown): void => {',
' if (isOmpRuntime()) return',
' const runId = readRunId(payload)',
' if (!runId) return',
' post(hookEventName, {',
' subagent_id: runId,',
" agent_type: readText(payload, ['agentType', 'agent_type', 'subagentType']),",
" description: readText(payload, ['description', 'task', 'prompt']),",
' })',
' }',
" bus.on?.('subagent:async-started', (payload) => postAsyncSubagent('subagent_async_started', payload))",
" bus.on?.('subagent:async-complete', (payload) => postAsyncSubagent('subagent_async_complete', payload))",
' } catch {',
' // Why: status reporting must never fail the pi run; an unavailable bus just means no children.',
' }',
''
]
}
@@ -1,4 +1,5 @@
import type { PiAgentKind } from '../../shared/pi-agent-kind'
import { getPiAgentStatusAsyncSubagentSourceLines } from './agent-status-async-subagent-source'
import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source'
// Why: keep the generated handler registrations separate from hook transport;
@@ -136,6 +137,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' })',
'',
...approvalHandlers,
...getPiAgentStatusAsyncSubagentSourceLines(kind),
...getPiAgentStatusUiPromptHandlerSourceLines(kind),
" // Why: capture the assistant's final text on each completed message",
' // so the dashboard preview reflects the most recent reply even before',
@@ -5,18 +5,18 @@ import {
AGENT_TYPE_MAX_LENGTH
} from './agent-status-types'
import {
codexRosterToSnapshots,
finishCodexSubagent,
setCodexSubagentModel,
upsertCodexSubagent,
type CodexSubagentRoster
} from './codex-subagent-roster'
agentDescendantRosterToSnapshots,
finishAgentDescendant,
setAgentDescendantModel,
upsertAgentDescendant,
type AgentDescendantRoster
} from './agent-descendant-roster'
describe('Codex subagent roster', () => {
it('normalizes retained identity fields before storing them', () => {
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
upsertCodexSubagent(
upsertAgentDescendant(
roster,
' child-1 ',
{
@@ -28,49 +28,49 @@ describe('Codex subagent roster', () => {
10
)
const snapshot = codexRosterToSnapshots(roster)?.[0]
const snapshot = agentDescendantRosterToSnapshots(roster)?.[0]
expect([...roster.keys()]).toEqual(['child-1'])
expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH)
expect(snapshot?.agentType).not.toContain('\n')
expect(snapshot?.description).toBe('Review the sidebar lifecycle')
expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH)
finishCodexSubagent(roster, ' child-1 ')
finishAgentDescendant(roster, ' child-1 ')
expect(roster.size).toBe(0)
})
it('rejects an id that would normalize to an invisible child', () => {
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
upsertCodexSubagent(roster, ' ', { state: 'waiting' }, 10)
upsertAgentDescendant(roster, ' ', { state: 'waiting' }, 10)
expect(roster.size).toBe(0)
})
it('bounds live storage while admitting a replacement after one child stops', () => {
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
for (let index = 0; index <= AGENT_STATUS_MAX_SUBAGENTS; index += 1) {
upsertCodexSubagent(roster, `child-${index}`, { state: 'working' }, index)
upsertAgentDescendant(roster, `child-${index}`, { state: 'working' }, index)
}
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
expect(roster.has(`child-${AGENT_STATUS_MAX_SUBAGENTS}`)).toBe(false)
finishCodexSubagent(roster, 'child-0')
upsertCodexSubagent(roster, 'replacement', { state: 'working' }, 100)
finishAgentDescendant(roster, 'child-0')
upsertAgentDescendant(roster, 'replacement', { state: 'working' }, 100)
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
expect(roster.has('replacement')).toBe(true)
})
describe('setCodexSubagentModel', () => {
describe('setAgentDescendantModel', () => {
it('records the model without disturbing the child lifecycle or label', () => {
const roster: CodexSubagentRoster = new Map()
upsertCodexSubagent(roster, 'child-1', { description: '/root/audit', state: 'waiting' }, 10)
const roster: AgentDescendantRoster = new Map()
upsertAgentDescendant(roster, 'child-1', { description: '/root/audit', state: 'waiting' }, 10)
setCodexSubagentModel(roster, 'child-1', ' gpt-5.6-terra ')
setAgentDescendantModel(roster, 'child-1', ' gpt-5.6-terra ')
expect(codexRosterToSnapshots(roster)).toEqual([
expect(agentDescendantRosterToSnapshots(roster)).toEqual([
{
id: 'child-1',
agentType: undefined,
@@ -83,31 +83,31 @@ describe('Codex subagent roster', () => {
})
it('never creates a row for a child that is no longer tracked', () => {
const roster: CodexSubagentRoster = new Map()
upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10)
finishCodexSubagent(roster, 'child-1')
const roster: AgentDescendantRoster = new Map()
upsertAgentDescendant(roster, 'child-1', { state: 'working' }, 10)
finishAgentDescendant(roster, 'child-1')
// A model read racing a completed child must not resurrect its row.
setCodexSubagentModel(roster, 'child-1', 'gpt-5.6-terra')
setAgentDescendantModel(roster, 'child-1', 'gpt-5.6-terra')
expect(roster.size).toBe(0)
})
it('keeps a known model when the new value is empty', () => {
const roster: CodexSubagentRoster = new Map()
upsertCodexSubagent(roster, 'child-1', { model: 'gpt-5.6-sol', state: 'working' }, 10)
const roster: AgentDescendantRoster = new Map()
upsertAgentDescendant(roster, 'child-1', { model: 'gpt-5.6-sol', state: 'working' }, 10)
setCodexSubagentModel(roster, 'child-1', ' ')
setCodexSubagentModel(roster, 'child-1', undefined)
setAgentDescendantModel(roster, 'child-1', ' ')
setAgentDescendantModel(roster, 'child-1', undefined)
expect(roster.get('child-1')?.model).toBe('gpt-5.6-sol')
})
it('bounds an oversized model to the shared cap', () => {
const roster: CodexSubagentRoster = new Map()
upsertCodexSubagent(roster, 'child-1', { state: 'working' }, 10)
const roster: AgentDescendantRoster = new Map()
upsertAgentDescendant(roster, 'child-1', { state: 'working' }, 10)
setCodexSubagentModel(roster, 'child-1', 'x'.repeat(AGENT_MODEL_MAX_LENGTH + 50))
setAgentDescendantModel(roster, 'child-1', 'x'.repeat(AGENT_MODEL_MAX_LENGTH + 50))
expect(roster.get('child-1')?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH)
})
@@ -3,15 +3,28 @@ import {
AGENT_STATUS_MAX_SUBAGENTS,
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_TYPE_MAX_LENGTH,
type AgentStatusState,
type AgentSubagentSnapshot
} from './agent-status-types'
import { normalizeOptionalField } from './agent-status-field-normalization'
const CODEX_SUBAGENT_ID_MAX_LENGTH = 64
/** Live descendants (subagents, spawned threads, async child runs) of one pane's
* lead agent session, keyed by the provider-assigned child id.
*
* Provider-agnostic on purpose: the pane rule "idle only when the lead is idle
* AND no descendant is live" is one concept, so it has one implementation. A
* provider contributes only how to READ a child out of its hook events
* (`agent-hook-listener/descendant-events.ts`). Claude keeps its own roster
* because its children carry provider-specific reconciliation (teammate
* parking, `background_tasks` folding, restored-snapshot provenance) that no
* other provider has; it implements the same pane rule in
* `providers/claude-roster-state.ts`. */
export type CodexSubagentRoster = Map<string, TrackedCodexSubagent>
const AGENT_DESCENDANT_ID_MAX_LENGTH = 64
type TrackedCodexSubagent = {
export type AgentDescendantRoster = Map<string, TrackedAgentDescendant>
type TrackedAgentDescendant = {
agentType?: string
description?: string
model?: string
@@ -19,8 +32,8 @@ type TrackedCodexSubagent = {
startedAt: number
}
export function upsertCodexSubagent(
roster: CodexSubagentRoster,
export function upsertAgentDescendant(
roster: AgentDescendantRoster,
id: string,
fields: {
agentType?: string
@@ -31,7 +44,7 @@ export function upsertCodexSubagent(
now: number
): void {
const normalizedId = id.trim()
if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) {
if (normalizedId.length === 0 || normalizedId.length > AGENT_DESCENDANT_ID_MAX_LENGTH) {
return
}
const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH)
@@ -57,18 +70,18 @@ export function upsertCodexSubagent(
})
}
export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void {
export function finishAgentDescendant(roster: AgentDescendantRoster, id: string): void {
roster.delete(id.trim())
}
/**
* Record the model a already-tracked child is running. Deliberately narrower
* than `upsertCodexSubagent`: it never creates a roster entry and never touches
* than `upsertAgentDescendant`: it never creates a roster entry and never touches
* `state`, so late model discovery from a child rollout cannot resurrect a
* finished child nor move any child's lifecycle.
*/
export function setCodexSubagentModel(
roster: CodexSubagentRoster,
export function setAgentDescendantModel(
roster: AgentDescendantRoster,
id: string,
model: string | undefined
): void {
@@ -83,15 +96,15 @@ export function setCodexSubagentModel(
existing.model = normalizedModel
}
export function seedCodexSubagentRoster(
roster: CodexSubagentRoster,
export function seedAgentDescendantRoster(
roster: AgentDescendantRoster,
snapshots: readonly AgentSubagentSnapshot[]
): void {
for (const snapshot of snapshots) {
if (snapshot.state !== 'working' && snapshot.state !== 'waiting') {
continue
}
upsertCodexSubagent(
upsertAgentDescendant(
roster,
snapshot.id,
{
@@ -105,8 +118,8 @@ export function seedCodexSubagentRoster(
}
}
export function codexRosterToSnapshots(
roster: CodexSubagentRoster | undefined
export function agentDescendantRosterToSnapshots(
roster: AgentDescendantRoster | undefined
): AgentSubagentSnapshot[] | undefined {
if (!roster || roster.size === 0) {
return undefined
@@ -123,10 +136,14 @@ export function codexRosterToSnapshots(
return snapshots
}
export function codexRosterEffectiveState(
roster: CodexSubagentRoster | undefined,
leadState: 'working' | 'waiting' | 'done'
): 'working' | 'waiting' | 'done' {
/** The pane's state once its live descendants are taken into account: a lead
* `done` is only the pane's `done` when nothing is still running under it. A
* descendant blocked on a human answer outranks the lead's own working state,
* since that wait is the actionable one. */
export function agentDescendantEffectiveState(
roster: AgentDescendantRoster | undefined,
leadState: AgentStatusState
): AgentStatusState {
if (!roster || roster.size === 0) {
return leadState
}
@@ -0,0 +1,230 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
createHookListenerState,
type HookListenerState
} from './agent-hook-listener/listener-state'
import { normalizeAndAccept, PANE_KEY } from './agent-hook-listener-test-harness'
import type { AgentHookSource } from './agent-hook-relay'
/** Every case drives `normalizeHookPayload`, the entry both the main process and the relay
* call, so a fix that never reaches production wiring cannot pass here. */
describe('descendant lifecycle never settles the pane', () => {
let state: HookListenerState
beforeEach(() => {
state = createHookListenerState()
})
const publish = (
source: AgentHookSource,
payload: Record<string, unknown>
): ReturnType<typeof normalizeAndAccept> => normalizeAndAccept(state, source, payload)
const publishedState = (
source: AgentHookSource,
payload: Record<string, unknown>
): string | undefined => publish(source, payload)?.payload.state
describe('grok nested subagents (STA-6982)', () => {
const startTurn = (): void => {
expect(publishedState('grok', { hookEventName: 'UserPromptSubmit', prompt: 'ship it' })).toBe(
'working'
)
}
it('keeps the pane working when a nested child session ends', () => {
startTurn()
// Why: grok remaps a child's turn gate to SubagentStop, so the child's own SessionEnd is what
// reaches the parent's pane. It carries subagentType; the session's own SessionEnd never does.
expect(
publishedState('grok', {
hookEventName: 'SessionEnd',
reason: 'clear',
subagentType: 'explore'
})
).toBe('working')
})
it('keeps the pane working when a nested child turn fails', () => {
startTurn()
expect(
publishedState('grok', {
hookEventName: 'StopFailure',
error: 'rate_limit',
subagentType: 'explore'
})
).toBe('working')
})
it('still settles the pane on the lead session own stop', () => {
startTurn()
expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('done')
})
it('holds the pane working while a tracked child outlives the lead turn', () => {
startTurn()
const spawned = publish('grok', {
hookEventName: 'SubagentStart',
subagentId: 'sub-1',
subagentType: 'explore',
description: 'review the diff'
})
expect(spawned?.payload.state).toBe('working')
expect(spawned?.payload.subagents).toEqual([
expect.objectContaining({ id: 'sub-1', state: 'working', agentType: 'explore' })
])
// The lead's own Stop is real, but a live child means the pane is not idle yet.
expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('working')
const drained = publish('grok', {
hookEventName: 'SubagentStop',
subagentId: 'sub-1',
subagentType: 'explore'
})
expect(drained?.payload.state).toBe('done')
expect(drained?.payload.subagents).toBeUndefined()
})
it('publishes exactly one done across a child-then-lead completion', () => {
startTurn()
publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' })
const states = [
publishedState('grok', { hookEventName: 'SessionEnd', reason: 'clear', subagentType: 'x' }),
publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' }),
publishedState('grok', {
hookEventName: 'SubagentStop',
subagentId: 'sub-1',
subagentType: 'x'
})
]
expect(states).toEqual(['working', 'working', 'done'])
expect(states.filter((value) => value === 'done')).toHaveLength(1)
})
it('does not relabel the pane with a child tool call', () => {
startTurn()
const parentTool = publish('grok', {
hookEventName: 'PreToolUse',
toolName: 'edit_file',
toolInput: { path: 'src/app.ts' }
})
expect(parentTool?.payload.toolName).toBe('edit_file')
const childTool = publish('grok', {
hookEventName: 'PreToolUse',
subagentType: 'explore',
subagentId: 'sub-1',
toolName: 'run_terminal_cmd',
toolInput: { command: 'rg TODO' }
})
expect(childTool?.payload.state).toBe('working')
expect(childTool?.payload.toolName).toBe('edit_file')
expect(childTool?.payload.prompt).toBe('ship it')
expect(childTool?.hasExplicitPrompt).toBeUndefined()
})
it('drops a stale child roster when the pane starts a new agent process', () => {
startTurn()
publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' })
expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('working')
// Why: a child whose stop hook was lost must not pin the pane forever; a replaced agent
// process cannot still have the old process's children.
publish('grok', { hookEventName: 'SessionStart', source: 'startup' })
expect(publishedState('grok', { hookEventName: 'UserPromptSubmit', prompt: 'again' })).toBe(
'working'
)
expect(publishedState('grok', { hookEventName: 'Stop', reason: 'end_turn' })).toBe('done')
})
})
describe('pi async subagent runs (STA-6378)', () => {
const startTurn = (): void => {
expect(
publishedState('pi', { hook_event_name: 'before_agent_start', prompt: 'delegate' })
).toBe('working')
expect(publishedState('pi', { hook_event_name: 'agent_start' })).toBe('working')
}
it('settles the pane when the parent ends with no async children', () => {
startTurn()
expect(publishedState('pi', { hook_event_name: 'agent_end' })).toBe('done')
})
it('stays working when the parent settles while an async child run continues', () => {
startTurn()
const started = publish('pi', {
hook_event_name: 'subagent_async_started',
subagent_id: 'run-1',
agent_type: 'researcher'
})
expect(started?.payload.state).toBe('working')
expect(started?.payload.subagents).toEqual([
expect.objectContaining({ id: 'run-1', state: 'working', agentType: 'researcher' })
])
expect(publishedState('pi', { hook_event_name: 'agent_end' })).toBe('working')
const finished = publish('pi', {
hook_event_name: 'subagent_async_complete',
subagent_id: 'run-1'
})
expect(finished?.payload.state).toBe('done')
expect(finished?.payload.subagents).toBeUndefined()
})
it('completes once when the final child wakes the parent for another turn', () => {
startTurn()
publish('pi', { hook_event_name: 'subagent_async_started', subagent_id: 'run-1' })
publish('pi', { hook_event_name: 'subagent_async_started', subagent_id: 'run-2' })
const states = [
publishedState('pi', { hook_event_name: 'agent_end' }),
publishedState('pi', { hook_event_name: 'subagent_async_complete', subagent_id: 'run-1' }),
// The last child wakes the parent, which runs another turn before the pane is idle.
publishedState('pi', { hook_event_name: 'subagent_async_complete', subagent_id: 'run-2' }),
publishedState('pi', { hook_event_name: 'agent_start' }),
publishedState('pi', { hook_event_name: 'agent_end' })
]
expect(states).toEqual(['working', 'working', 'done', 'working', 'done'])
})
it('keeps the parent prompt while an async child reports', () => {
startTurn()
const started = publish('pi', {
hook_event_name: 'subagent_async_started',
subagent_id: 'run-1',
prompt: 'child task text'
})
expect(started?.payload.prompt).toBe('delegate')
expect(started?.hasExplicitPrompt).toBeUndefined()
})
})
describe('pane-scoped state bookkeeping', () => {
it('reports a descendant-only pane as holding a state claim', async () => {
const { paneHasStateClaims, clearPaneCacheState } =
await import('./agent-hook-listener/listener-state')
publish('grok', { hookEventName: 'UserPromptSubmit', prompt: 'go' })
publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' })
expect(paneHasStateClaims(state, PANE_KEY)).toBe(true)
clearPaneCacheState(state, PANE_KEY)
expect(state.descendantRosterByPaneKey.has(PANE_KEY)).toBe(false)
expect(state.descendantLeadStateByPaneKey.has(PANE_KEY)).toBe(false)
})
it('moves descendant state with the pane when its key is remapped', async () => {
const { movePaneCacheState } = await import('./agent-hook-listener/listener-state')
publish('grok', { hookEventName: 'UserPromptSubmit', prompt: 'go' })
publish('grok', { hookEventName: 'SubagentStart', subagentId: 'sub-1', subagentType: 'x' })
movePaneCacheState(state, PANE_KEY, 'tab-2:22222222-2222-4222-8222-222222222222')
expect(state.descendantRosterByPaneKey.has(PANE_KEY)).toBe(false)
expect(
state.descendantRosterByPaneKey.get('tab-2:22222222-2222-4222-8222-222222222222')?.size
).toBe(1)
})
})
})
+6 -2
View File
@@ -136,8 +136,12 @@ export function normalizeHookPayload(
// Normalization is transport-agnostic; only ingestRemote knows the mux identity to stamp.
connectionId: null,
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
hasExplicitPrompt:
source === 'amp'
// Why: nothing on a descendant's event describes the pane's own turn — counting a child's
// prompt as a user submit would move turn-scoped state (telemetry, permission stickiness)
// on the parent.
hasExplicitPrompt: dispatched.descendantScoped
? undefined
: source === 'amp'
? hasExplicitAmpPrompt(eventName, promptText, hookPayloadRecord)
? true
: undefined
@@ -0,0 +1,137 @@
import type { AgentHookSource } from '../agent-hook-relay'
import { readFirstString } from './interactive-tool'
import { isGrokEvent, normalizeHookEventName } from './provider-event-names'
/** What one hook event says about a DESCENDANT of the pane's lead session.
*
* A descendant's lifecycle is not the pane's: it may add or remove a child row,
* and it may never settle the pane or fire completion. `id` is optional because
* some providers only mark an event as "this fired inside a child" without
* naming which one — that is still enough to refuse the settle. */
export type DescendantEventFacts = {
id?: string
agentType?: string
description?: string
model?: string
/** The descendant's own turn or session ended. */
ended: boolean
}
/** Providers whose normalizer already tracks its own descendants and derives the
* pane state from them. Claude's roster carries teammate parking, background-task
* folding and restored-snapshot provenance; Codex's carries per-child models and
* rollout reconciliation. Both already implement the pane rule, so the generic
* path must not run a second, blinder copy over the same events. */
export function providerOwnsDescendantLifecycle(source: AgentHookSource): boolean {
return source === 'claude' || source === 'codex'
}
/** Provider-assigned child id under the names hook payloads actually use. */
function readDescendantId(hookPayload: Record<string, unknown>): string | undefined {
return readFirstString(hookPayload, [
'subagentId',
'subagent_id',
'agentId',
'agent_id',
'runId',
'run_id'
])
}
function readGrokDescendantFacts(
eventName: unknown,
hookPayload: Record<string, unknown>
): DescendantEventFacts | null {
const isLifecycleEvent = isGrokEvent(eventName, 'subagent_start', 'subagent_stop', 'subagent_end')
const subagentType = readFirstString(hookPayload, ['subagentType', 'subagent_type'])
// Why: grok stamps `subagentType` on every event that can fire inside a child and omits it in
// the main session, so its presence — not the event name — is what tells a child apart. A
// background child outlives the parent turn and inherits the pane key, so without this its
// SessionEnd/StopFailure lands on the parent as a completion.
if (!isLifecycleEvent && subagentType === undefined) {
return null
}
return {
id: readFirstString(hookPayload, ['subagentId', 'subagent_id']),
agentType: subagentType,
description: readFirstString(hookPayload, ['description']),
ended:
isGrokEvent(eventName, 'subagent_stop', 'subagent_end') ||
(subagentType !== undefined &&
isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure', 'stop_cancelled'))
}
}
function readPiDescendantFacts(
eventName: unknown,
hookPayload: Record<string, unknown>
): DescendantEventFacts | null {
const normalized = normalizeHookEventName(eventName)
if (normalized !== 'subagent_async_started' && normalized !== 'subagent_async_complete') {
return null
}
return {
id: readDescendantId(hookPayload),
agentType: readFirstString(hookPayload, ['agentType', 'agent_type', 'subagentType']),
description: readFirstString(hookPayload, ['description', 'task', 'prompt']),
model: readFirstString(hookPayload, ['model']),
ended: normalized === 'subagent_async_complete'
}
}
/** Read a descendant out of one provider's hook event, or null when the event
* belongs to the pane's lead session.
*
* Exhaustive on purpose: a new provider has to answer "does this CLI report
* child sessions on the parent's pane?" here rather than inherit a guess. The
* answer is one line, and `readDescendantId` covers the field names in use. */
export function readDescendantEventFacts(
source: AgentHookSource,
eventName: unknown,
hookPayload: Record<string, unknown>
): DescendantEventFacts | null {
switch (source) {
// Why: both own their rosters; `providerOwnsDescendantLifecycle` already routes around this,
// and answering here too would let a future caller run the generic path over their events.
case 'claude':
case 'codex':
return null
case 'grok':
return readGrokDescendantFacts(eventName, hookPayload)
case 'pi':
case 'omp':
case 'prime-agent':
return readPiDescendantFacts(eventName, hookPayload)
case 'gemini':
case 'antigravity':
case 'amp':
case 'opencode':
case 'mimo-code':
case 'cursor':
case 'droid':
case 'command-code':
case 'copilot':
case 'hermes':
case 'devin':
case 'kimi':
// No child-session hook surface reaches Orca for these today. When one gains
// one, read it here — the roster and the pane rule already exist.
return null
}
}
/** An event that replaces the pane's agent process, so descendants of the process
* it replaced can no longer be alive. The roster's recovery from a lost child
* stop: without it a dropped stop hook would pin the pane 'working' forever. */
export function isDescendantScopeResetEvent(source: AgentHookSource, eventName: unknown): boolean {
switch (source) {
case 'grok':
return isGrokEvent(eventName, 'session_start')
case 'pi':
case 'omp':
case 'prime-agent':
return eventName === 'session_start'
default:
return false
}
}
@@ -0,0 +1,102 @@
import type { AgentHookSource } from '../agent-hook-relay'
import {
agentDescendantEffectiveState,
agentDescendantRosterToSnapshots,
finishAgentDescendant,
upsertAgentDescendant,
type AgentDescendantRoster
} from '../agent-descendant-roster'
import { normalizeAgentStatusPayload, type ParsedAgentStatusPayload } from '../agent-status-types'
import type { DescendantEventFacts } from './descendant-events'
import type { HookListenerState } from './listener-state'
function getOrCreateDescendantRoster(
state: HookListenerState,
paneKey: string
): AgentDescendantRoster {
let roster = state.descendantRosterByPaneKey.get(paneKey)
if (!roster) {
roster = new Map()
state.descendantRosterByPaneKey.set(paneKey, roster)
}
return roster
}
/** Apply a descendant's lifecycle event: it updates the child list and republishes
* the pane from the LEAD's last known state, so a child finishing can never be the
* pane's completion. The lead's own tool/prompt caches are left untouched — a
* child's tool call is not the pane's, and overwriting them would relabel the row
* with the child's work. */
export function applyDescendantEventToPane(
state: HookListenerState,
source: AgentHookSource,
paneKey: string,
facts: DescendantEventFacts
): ParsedAgentStatusPayload | null {
if (facts.id) {
if (facts.ended) {
const roster = state.descendantRosterByPaneKey.get(paneKey)
if (roster) {
finishAgentDescendant(roster, facts.id)
if (roster.size === 0) {
state.descendantRosterByPaneKey.delete(paneKey)
}
}
} else {
upsertAgentDescendant(
getOrCreateDescendantRoster(state, paneKey),
facts.id,
{
agentType: facts.agentType,
description: facts.description,
model: facts.model,
state: 'working'
},
Date.now()
)
}
}
// Why: a child event before any lead event still proves the pane is working — the lead spawned it.
const leadState = state.descendantLeadStateByPaneKey.get(paneKey) ?? 'working'
const cachedTool = state.lastToolByPaneKey.get(paneKey) ?? {}
return normalizeAgentStatusPayload({
state: agentDescendantEffectiveState(state.descendantRosterByPaneKey.get(paneKey), leadState),
prompt: state.lastPromptByPaneKey.get(paneKey) ?? '',
agentType: source,
toolName: cachedTool.toolName,
toolInput: cachedTool.toolInput,
interactivePrompt: cachedTool.interactivePrompt,
lastAssistantMessage: cachedTool.lastAssistantMessage,
lastAssistantMessageIsToolOutput: cachedTool.lastAssistantMessageIsToolOutput,
subagents: agentDescendantRosterToSnapshots(state.descendantRosterByPaneKey.get(paneKey))
})
}
/** The pane is idle only when its lead session is idle AND no descendant is live.
* Records the lead's own verdict first, so draining the last descendant later
* republishes what the lead actually said instead of the gated value. */
export function gatePaneStateOnDescendants(
state: HookListenerState,
paneKey: string,
payload: ParsedAgentStatusPayload | null
): ParsedAgentStatusPayload | null {
if (!payload) {
return payload
}
state.descendantLeadStateByPaneKey.set(paneKey, payload.state)
const roster = state.descendantRosterByPaneKey.get(paneKey)
if (!roster || roster.size === 0) {
return payload
}
return {
...payload,
state: agentDescendantEffectiveState(roster, payload.state),
subagents: agentDescendantRosterToSnapshots(roster)
}
}
export function clearDescendantScope(state: HookListenerState, paneKey: string): void {
state.descendantRosterByPaneKey.delete(paneKey)
state.descendantLeadStateByPaneKey.delete(paneKey)
}
@@ -1,6 +1,6 @@
import type { AgentStatusState } from '../agent-status-types'
import type { ClaudeSubagentRoster } from '../claude-subagent-roster'
import type { CodexSubagentRoster } from '../codex-subagent-roster'
import type { AgentDescendantRoster } from '../agent-descendant-roster'
import type { CodexSubagentTranscriptState } from '../codex-subagent-transcript'
import type { AgentHookEventPayload, ToolSnapshot } from './listener-event'
@@ -30,11 +30,16 @@ export type HookListenerState = {
* even when no SessionStart arrives — the backstop for the exits that emit no terminating hook. */
claudeSessionOwnerByPaneKey: Map<string, string>
/** Live thread-spawn children per Codex pane. */
codexSubagentRosterByPaneKey: Map<string, CodexSubagentRoster>
codexSubagentRosterByPaneKey: Map<string, AgentDescendantRoster>
/** Incremental parent/child rollout cursors for Codex collaboration v2. */
codexSubagentTranscriptByPaneKey: Map<string, CodexSubagentTranscriptState>
/** Root Codex state/model, kept separate from child hook traffic. */
codexLeadStateByPaneKey: Map<string, CodexLeadTurnState>
/** Live descendants for every provider that does not own a roster of its own. */
descendantRosterByPaneKey: Map<string, AgentDescendantRoster>
/** What the LEAD session last said, before descendants gated it — so draining the
* last child republishes the lead's verdict instead of the gated one. */
descendantLeadStateByPaneKey: Map<string, AgentStatusState>
}
export type ClaudeLeadTurnState = {
@@ -73,7 +78,9 @@ export function createHookListenerState(): HookListenerState {
claudeSessionOwnerByPaneKey: new Map(),
codexSubagentRosterByPaneKey: new Map(),
codexSubagentTranscriptByPaneKey: new Map(),
codexLeadStateByPaneKey: new Map()
codexLeadStateByPaneKey: new Map(),
descendantRosterByPaneKey: new Map(),
descendantLeadStateByPaneKey: new Map()
}
}
@@ -93,6 +100,8 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
state.codexSubagentRosterByPaneKey.delete(paneKey)
state.codexSubagentTranscriptByPaneKey.delete(paneKey)
state.codexLeadStateByPaneKey.delete(paneKey)
state.descendantRosterByPaneKey.delete(paneKey)
state.descendantLeadStateByPaneKey.delete(paneKey)
}
/** Does this pane still hold anything that can ASSERT a state — a stored row, or a Claude latch that
@@ -111,7 +120,9 @@ export function paneHasStateClaims(state: HookListenerState, paneKey: string): b
state.claudeActiveSessionCronPaneKeys.has(paneKey) ||
state.claudeSessionOwnerByPaneKey.has(paneKey) ||
state.codexSubagentRosterByPaneKey.has(paneKey) ||
state.codexLeadStateByPaneKey.has(paneKey)
state.codexLeadStateByPaneKey.has(paneKey) ||
state.descendantRosterByPaneKey.has(paneKey) ||
state.descendantLeadStateByPaneKey.has(paneKey)
)
}
@@ -166,6 +177,8 @@ export function movePaneCacheState(
movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.descendantRosterByPaneKey, fromPaneKey, toPaneKey)
movePaneScopedMapEntries(state.descendantLeadStateByPaneKey, fromPaneKey, toPaneKey)
}
export function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void {
@@ -213,4 +226,6 @@ export function clearAllListenerCaches(state: HookListenerState): void {
state.codexSubagentRosterByPaneKey.clear()
state.codexSubagentTranscriptByPaneKey.clear()
state.codexLeadStateByPaneKey.clear()
state.descendantRosterByPaneKey.clear()
state.descendantLeadStateByPaneKey.clear()
}
@@ -3,6 +3,16 @@ import type { AgentHookSource } from '../agent-hook-relay'
import { readLastCommandCodeUserPromptEntryFromTranscript } from './command-code-transcript'
import { readGrokHomeEnvelope } from './grok-result-discovery'
import { readFirstString } from './interactive-tool'
import {
isDescendantScopeResetEvent,
providerOwnsDescendantLifecycle,
readDescendantEventFacts
} from './descendant-events'
import {
applyDescendantEventToPane,
clearDescendantScope,
gatePaneStateOnDescendants
} from './descendant-pane-state'
import type { HookListenerState } from './listener-state'
import type { ExtractedPromptText } from './prompt-fields'
import { isNewTurnEvent } from './provider-event-routing'
@@ -28,6 +38,8 @@ export type ProviderDispatchResult = {
resolvedPromptText: string
promptInteractionKey?: string
hasTranscriptPromptEvidence: boolean
/** The event fired inside a descendant, so nothing on it describes the pane's own turn. */
descendantScoped?: true
}
/** Exhaustive provider routing with provider-specific transcript locality and attribution. */
@@ -48,6 +60,26 @@ export function normalizeProviderEvent(input: {
let hasTranscriptPromptEvidence = false
let payload: ParsedAgentStatusPayload | null
// Why: the pane rule — idle only when the lead is idle AND no descendant is live — is applied
// here, once, for every provider that does not own a roster. A child's lifecycle event never
// reaches the provider normalizer, so it cannot settle the pane or relabel the row.
const ownsDescendants = providerOwnsDescendantLifecycle(source)
if (!ownsDescendants) {
if (isDescendantScopeResetEvent(source, eventName)) {
clearDescendantScope(state, paneKey)
}
const descendant = readDescendantEventFacts(source, eventName, hookPayload)
if (descendant) {
return {
payload: applyDescendantEventToPane(state, source, paneKey, descendant),
// Why: a child's prompt is not the pane's turn label, and its tool events are not a user submit.
resolvedPromptText: '',
hasTranscriptPromptEvidence: false,
descendantScoped: true
}
}
}
switch (source) {
case 'claude':
payload = normalizeClaudeEvent(state, eventName, promptText, paneKey, hookPayload)
@@ -150,5 +182,10 @@ export function normalizeProviderEvent(input: {
break
}
return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence }
return {
payload: ownsDescendants ? payload : gatePaneStateOnDescendants(state, paneKey, payload),
resolvedPromptText,
promptInteractionKey,
hasTranscriptPromptEvidence
}
}
@@ -1,16 +1,17 @@
import {
AGENT_MODEL_MAX_LENGTH,
normalizeAgentStatusPayload,
type AgentStatusState,
type ParsedAgentStatusPayload
} from '../../agent-status-types'
import { normalizeOptionalField } from '../../agent-status-field-normalization'
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
import {
codexRosterEffectiveState,
codexRosterToSnapshots,
finishCodexSubagent,
upsertCodexSubagent
} from '../../codex-subagent-roster'
agentDescendantEffectiveState,
agentDescendantRosterToSnapshots,
finishAgentDescendant,
upsertAgentDescendant
} from '../../agent-descendant-roster'
import { reconcileCodexSubagentTranscript } from '../../codex-subagent-transcript'
import { readFirstString } from '../interactive-tool'
import type { HookListenerState } from '../listener-state'
@@ -18,7 +19,7 @@ import { resolvePrompt, resolveToolState } from '../prompt-fields'
import { extractToolFields, isNewTurnEvent } from '../provider-event-routing'
import { readString } from '../tool-input-preview'
import {
getOrCreateCodexSubagentRoster,
getOrCreateAgentDescendantRoster,
getOrCreateCodexSubagentTranscriptState,
hasCodexTranscriptSubagents
} from './codex-state'
@@ -29,7 +30,7 @@ export function buildCodexStatusPayload(
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>,
options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean }
options: { stateName: AgentStatusState; updateLead: boolean }
): ParsedAgentStatusPayload | null {
const snapshot = options.updateLead
? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), {
@@ -50,7 +51,7 @@ export function buildCodexStatusPayload(
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey))
subagents: agentDescendantRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey))
})
}
@@ -61,7 +62,7 @@ export function buildCodexChildDrivenStatusPayload(
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working'
const stateName = codexRosterEffectiveState(
const stateName = agentDescendantEffectiveState(
state.codexSubagentRosterByPaneKey.get(paneKey),
leadState
)
@@ -81,9 +82,9 @@ export function normalizeCodexSubagentLifecycleEvent(
if (!agentId) {
return null
}
const roster = getOrCreateCodexSubagentRoster(state, paneKey)
const roster = getOrCreateAgentDescendantRoster(state, paneKey)
if (eventName === 'SubagentStart') {
upsertCodexSubagent(
upsertAgentDescendant(
roster,
agentId,
{
@@ -94,7 +95,7 @@ export function normalizeCodexSubagentLifecycleEvent(
Date.now()
)
} else {
finishCodexSubagent(roster, agentId)
finishAgentDescendant(roster, agentId)
}
return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload)
}
@@ -131,8 +132,8 @@ export function normalizeCodexEvent(
const agentId = readString(hookPayload, 'agent_id')
if (agentId) {
upsertCodexSubagent(
getOrCreateCodexSubagentRoster(state, paneKey),
upsertAgentDescendant(
getOrCreateAgentDescendantRoster(state, paneKey),
agentId,
{
agentType: readString(hookPayload, 'agent_type'),
@@ -153,7 +154,7 @@ export function normalizeCodexEvent(
if (transcriptPath) {
reconcileCodexSubagentTranscript(
getOrCreateCodexSubagentTranscriptState(state, paneKey),
getOrCreateCodexSubagentRoster(state, paneKey),
getOrCreateAgentDescendantRoster(state, paneKey),
transcriptPath
)
}
@@ -168,7 +169,7 @@ export function normalizeCodexEvent(
normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ??
(eventName === 'SessionStart' ? undefined : previousLead?.model)
})
const effectiveState = codexRosterEffectiveState(
const effectiveState = agentDescendantEffectiveState(
state.codexSubagentRosterByPaneKey.get(paneKey),
stateName
)
@@ -1,11 +1,11 @@
import type { ParsedAgentStatusPayload } from '../../agent-status-types'
import {
codexRosterEffectiveState,
codexRosterToSnapshots,
finishCodexSubagent,
seedCodexSubagentRoster,
type CodexSubagentRoster
} from '../../codex-subagent-roster'
agentDescendantEffectiveState,
agentDescendantRosterToSnapshots,
finishAgentDescendant,
seedAgentDescendantRoster,
type AgentDescendantRoster
} from '../../agent-descendant-roster'
import {
createCodexSubagentTranscriptState,
hasTrackedCodexTranscriptSubagents,
@@ -13,10 +13,10 @@ import {
} from '../../codex-subagent-transcript'
import type { CodexLeadTurnState, HookListenerState } from '../listener-state'
export function getOrCreateCodexSubagentRoster(
export function getOrCreateAgentDescendantRoster(
state: HookListenerState,
paneKey: string
): CodexSubagentRoster {
): AgentDescendantRoster {
let roster = state.codexSubagentRosterByPaneKey.get(paneKey)
if (!roster) {
roster = new Map()
@@ -48,7 +48,7 @@ export function seedCodexStateFromSnapshot(
): void {
const snapshots = payload.subagents ?? []
if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) {
seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots)
seedAgentDescendantRoster(getOrCreateAgentDescendantRoster(state, paneKey), snapshots)
}
if (!state.codexLeadStateByPaneKey.has(paneKey)) {
// Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache.
@@ -111,13 +111,13 @@ export function reconcileRemoteCodexState(
if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) {
return payload
}
const roster = getOrCreateCodexSubagentRoster(state, paneKey)
const roster = getOrCreateAgentDescendantRoster(state, paneKey)
if (payload.subagents) {
seedCodexSubagentRoster(roster, payload.subagents)
seedAgentDescendantRoster(roster, payload.subagents)
}
if (agentId) {
if (eventName === 'SubagentStop') {
finishCodexSubagent(roster, agentId)
finishAgentDescendant(roster, agentId)
}
} else {
const leadState = codexLeadStateForHookEvent(eventName)
@@ -146,8 +146,8 @@ export function reconcileRemoteCodexState(
return {
...payload,
prompt,
state: codexRosterEffectiveState(roster, lead.state),
state: agentDescendantEffectiveState(roster, lead.state),
model: lead.model ?? payload.model,
subagents: codexRosterToSnapshots(roster)
subagents: agentDescendantRosterToSnapshots(roster)
}
}
+21 -18
View File
@@ -23,7 +23,10 @@ import {
hasTrackedCodexTranscriptSubagents,
reconcileCodexSubagentTranscript
} from './codex-subagent-transcript'
import { codexRosterToSnapshots, type CodexSubagentRoster } from './codex-subagent-roster'
import {
agentDescendantRosterToSnapshots,
type AgentDescendantRoster
} from './agent-descendant-roster'
const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f'
@@ -69,12 +72,12 @@ describe('Codex subagent transcript reconciliation', () => {
writeFileSync(parentPath, jsonl([activity('started')]))
writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }]))
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
expect(hasTrackedCodexTranscriptSubagents(state)).toBe(true)
expect(codexRosterToSnapshots(roster)).toEqual([
expect(agentDescendantRosterToSnapshots(roster)).toEqual([
{
id: CHILD_ID,
description: '/root/sidebar_repro',
@@ -95,7 +98,7 @@ describe('Codex subagent transcript reconciliation', () => {
reconcileCodexSubagentTranscript(state, roster, parentPath)
expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false)
expect(codexRosterToSnapshots(roster)).toBeUndefined()
expect(agentDescendantRosterToSnapshots(roster)).toBeUndefined()
})
it('resolves a child rollout filed under a later session day than the parent', () => {
@@ -111,7 +114,7 @@ describe('Codex subagent transcript reconciliation', () => {
writeFileSync(parentPath, jsonl([activity('started', childStartedAt)]))
writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }]))
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
writeFileSync(
@@ -136,7 +139,7 @@ describe('Codex subagent transcript reconciliation', () => {
const parentPath = join(dir, 'rollout-parent.jsonl')
writeFileSync(parentPath, jsonl([activity('started')]))
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
expect(roster.size).toBe(1)
@@ -161,7 +164,7 @@ describe('Codex subagent transcript reconciliation', () => {
const parentPath = join(dir, 'rollout-parent.jsonl')
writeFileSync(parentPath, jsonl([activity('started')]))
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
writeFileSync(parentPath, jsonl([activity('started'), activity('interrupted')]))
@@ -197,18 +200,18 @@ describe('Codex subagent transcript reconciliation', () => {
it('reports the model from the child rollout, not the parent model', () => {
const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
// The parent runs sol; only the child's own turn_context may set its model.
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
})
it('keeps the discovered model when a later poll carries no turn_context', () => {
const { parentPath, childPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
// The cursor is incremental: this appended line is all the next read sees.
@@ -222,7 +225,7 @@ describe('Codex subagent transcript reconciliation', () => {
)
reconcileCodexSubagentTranscript(state, roster, parentPath)
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
})
it('tracks the newest model when the child switches mid-session', () => {
@@ -232,22 +235,22 @@ describe('Codex subagent transcript reconciliation', () => {
turnContext('gpt-5.6-sol')
])
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-sol')
expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-sol')
})
it('leaves the child working and keeps its identity while reading the model', () => {
const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
// Model discovery must not move lifecycle or overwrite the child's label.
expect(codexRosterToSnapshots(roster)).toEqual([
expect(agentDescendantRosterToSnapshots(roster)).toEqual([
{
id: CHILD_ID,
description: '/root/sidebar_repro',
@@ -266,7 +269,7 @@ describe('Codex subagent transcript reconciliation', () => {
{ type: 'event_msg', payload: { type: 'task_complete' } }
])
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
reconcileCodexSubagentTranscript(state, roster, parentPath)
@@ -277,7 +280,7 @@ describe('Codex subagent transcript reconciliation', () => {
it('reads the model without opening any file beyond the parent and child rollouts', () => {
const { parentPath } = seedPair([started(), turnContext('gpt-5.6-terra')])
const state = createCodexSubagentTranscriptState()
const roster: CodexSubagentRoster = new Map()
const roster: AgentDescendantRoster = new Map()
openSyncCalls.mockClear()
reconcileCodexSubagentTranscript(state, roster, parentPath)
@@ -285,7 +288,7 @@ describe('Codex subagent transcript reconciliation', () => {
// Why: model extraction reuses the records already read for completion
// detection, so it must add no file I/O of its own.
expect(openSyncCalls).toHaveBeenCalledTimes(2)
expect(codexRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
expect(agentDescendantRosterToSnapshots(roster)?.[0]?.model).toBe('gpt-5.6-terra')
})
})
})
+11 -11
View File
@@ -2,11 +2,11 @@ import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import {
finishCodexSubagent,
setCodexSubagentModel,
upsertCodexSubagent,
type CodexSubagentRoster
} from './codex-subagent-roster'
finishAgentDescendant,
setAgentDescendantModel,
upsertAgentDescendant,
type AgentDescendantRoster
} from './agent-descendant-roster'
const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024
const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024
@@ -253,7 +253,7 @@ export function hasTrackedCodexTranscriptSubagents(
export function reconcileCodexSubagentTranscript(
state: CodexSubagentTranscriptState,
roster: CodexSubagentRoster,
roster: AgentDescendantRoster,
transcriptPath: string | undefined
): void {
const normalizedPath = transcriptPath?.trim()
@@ -262,7 +262,7 @@ export function reconcileCodexSubagentTranscript(
}
if (state.parent.filePath !== normalizedPath) {
for (const id of state.subagents.keys()) {
finishCodexSubagent(roster, id)
finishAgentDescendant(roster, id)
}
state.parent = { filePath: normalizedPath, offset: 0, carry: '' }
state.subagents.clear()
@@ -273,7 +273,7 @@ export function reconcileCodexSubagentTranscript(
continue
}
if (activity.kind === 'interrupted') {
finishCodexSubagent(roster, activity.id)
finishAgentDescendant(roster, activity.id)
state.subagents.delete(activity.id)
continue
}
@@ -284,7 +284,7 @@ export function reconcileCodexSubagentTranscript(
}
tracked.description = activity.description ?? tracked.description
state.subagents.set(activity.id, tracked)
upsertCodexSubagent(
upsertAgentDescendant(
roster,
activity.id,
{ description: tracked.description, state: 'working' },
@@ -316,12 +316,12 @@ export function reconcileCodexSubagentTranscript(
// Why: re-applied every reconcile, not just on discovery — the parent's
// own activity upsert can rebuild this child's roster entry, which would
// otherwise drop a model found on an earlier poll.
setCodexSubagentModel(roster, id, tracked.model)
setAgentDescendantModel(roster, id, tracked.model)
if (!childIsComplete(records)) {
continue
}
}
finishCodexSubagent(roster, id)
finishAgentDescendant(roster, id)
state.subagents.delete(id)
}
}