diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index 3bec9b9e9358..59027b381250 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -234,17 +234,29 @@ repaint, an inferred answer) keeps it only while `mainAgent` is unchanged. A chi sticky permission prompt still records the main agent's own progress and background evidence in the held row, and pushes the held row to subscribers when `mainAgent` changes. -Two combining rules remain outside the shared fold and are named so a reader -does not mistake them for drift: +Every lane, Codex included, combines through the fold. A child waiting on a +human is a fold input (`childWorkLiveness: 'waiting'`, derived from the child's +own `waiting` state; a child's `blocked` means it failed and stays live work) +and makes the row wait whatever the main agent is doing, unless the main agent +is itself asking. Only the Codex hook lane feeds that input today. Known +divergences, pinned by name in the parity table +(`src/shared/main-agent-status-parity.test.ts`) where they are reachable, so a +reader does not mistake them for drift: -- Codex keeps `codexRosterEffectiveState` for its combined `state` (a waiting - child wins, a settled root with any live child reads `working`, never - monitoring) and publishes `mainAgent` from its root record; moving that combine - onto the fold needs a waiting-child input the fold does not have yet. - A cancelled turn with a still-running shell reads `done` in the hook lane - and `monitoring` in the structured lane. The parity table in - `src/shared/main-agent-status-parity.test.ts` pins this as a known - divergence; the cancel policy that removes it flips that row. + and `monitoring` in the structured lane; the cancel policy that removes it + flips that row. +- The Claude hook lane holds a child's permission wait in one slot on the + displaced main agent record (`waitingAgentId`, `stateBeforeWait`), not on + the child. It publishes the displaced state as `mainAgent`, but the next + main agent event overwrites the slot, so the row stops reading `waiting` + while the child is still asking, and a second asking child replaces the + first. +- The structured lane has no per-child wait: a child's pending prompt makes + the session `attention`, which reads as the main agent's own `blocked`. +- The Codex hook lane drops its roster on a root `Stop` when it tracks no + child transcripts, so a still-running or still-asking child stops holding + the row. ## PR 1b: the runtime's retained row store is deleted diff --git a/src/shared/agent-hook-listener-codex-main-agent.test.ts b/src/shared/agent-hook-listener-codex-main-agent.test.ts index a83e992388c5..e7448c0f866b 100644 --- a/src/shared/agent-hook-listener-codex-main-agent.test.ts +++ b/src/shared/agent-hook-listener-codex-main-agent.test.ts @@ -4,6 +4,7 @@ import { type HookListenerState } from './agent-hook-listener/listener-state' import { + markCodexLeadTurnInterrupted, reconcileRemoteCodexState, seedCodexStateFromSnapshot } from './agent-hook-listener/providers/codex-state' @@ -50,4 +51,35 @@ describe('the Codex root record seeded from a durable row', () => { ) expect(reconciled.mainAgent).toEqual({ state: 'done', stateStartedAt: expect.any(Number) }) }) + + it('carries the cancellation Orca inferred into a late relayed Stop', () => { + markCodexLeadTurnInterrupted(state, PANE_KEY) + const reconciled = reconcileRemoteCodexState( + state, + PANE_KEY, + 'Stop', + undefined, + { state: 'done', prompt: 'ship', agentType: 'codex' }, + undefined + ) + expect(reconciled.mainAgent).toMatchObject({ state: 'done', outcome: 'cancellation' }) + }) + + it('folds a relayed waiting child through the shared rule, keeping the root fact', () => { + // The relay's aggregate says `working`; main re-derives the row from the roster instead. + const reconciled = reconcileRemoteCodexState( + state, + PANE_KEY, + 'PermissionRequest', + 'child', + { + state: 'working', + prompt: 'ship', + agentType: 'codex', + subagents: [{ id: 'child', state: 'waiting', startedAt: 1 }] + }, + undefined + ) + expect(reconciled).toMatchObject({ state: 'waiting', mainAgent: { state: 'working' } }) + }) }) diff --git a/src/shared/agent-hook-listener/main-agent-turn-state.ts b/src/shared/agent-hook-listener/main-agent-turn-state.ts index 2a9e261d31e1..c077860f8467 100644 --- a/src/shared/agent-hook-listener/main-agent-turn-state.ts +++ b/src/shared/agent-hook-listener/main-agent-turn-state.ts @@ -23,7 +23,8 @@ export type ClaudeLeadTurnState = { > } -/** The Codex root's own record; its combined `state` still comes from `codexRosterEffectiveState`. */ +/** The Codex root's own record, folded with its roster into the combined `state`. A child's wait + * lives on the roster entry, never here, so this record is always the root's own truth. */ export type CodexLeadTurnState = { state: 'working' | 'waiting' | 'done' /** The turn verdict the server inferred; Codex's own Stop hook carries none. */ diff --git a/src/shared/agent-hook-listener/providers/claude-events.ts b/src/shared/agent-hook-listener/providers/claude-events.ts index 5d39b50aa63e..335bf8c49fbb 100644 --- a/src/shared/agent-hook-listener/providers/claude-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-events.ts @@ -1,4 +1,5 @@ import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import { mainAgentTurnInterrupted } from '../../agent-lead-status-fold' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' import { readClaudeBackgroundAgentTasks } from '../../claude-background-task-inventory' import { @@ -17,7 +18,6 @@ import { normalizeClaudeSubagentLifecycleEvent } from './claude-lifecycle-events' import { - claudeMainAgentTurnInterrupted, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus, setClaudeMainAgentTurnState, @@ -78,7 +78,7 @@ export function normalizeClaudeEvent( const interrupted = isTurnBoundary && ((eventAgentId === undefined && hookPayload['is_interrupt'] === true) || - claudeMainAgentTurnInterrupted(previousLead)) + mainAgentTurnInterrupted(previousLead)) ? true : undefined // Why: absent means unknown — a plain Stop never becomes `success`, so a cancel can never read as a @@ -206,7 +206,7 @@ export function normalizeClaudeEvent( return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { ...resolveClaudePaneStatus(state, paneKey, restored), updateToolSnapshot: true, - interrupted: claudeMainAgentTurnInterrupted(restored), + interrupted: mainAgentTurnInterrupted(restored), turnCompletedAt: restored.turnCompletedAt }) } diff --git a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts index 4d3f70a2b997..ada041a07f6e 100644 --- a/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts +++ b/src/shared/agent-hook-listener/providers/claude-lifecycle-events.ts @@ -9,8 +9,8 @@ import { } from '../../claude-subagent-roster' import type { HookListenerState } from '../listener-state' import { readString } from '../tool-input-preview' +import { mainAgentTurnInterrupted } from '../../agent-lead-status-fold' import { - claudeMainAgentTurnInterrupted, clearClaudePendingWaitForAgent, getOrCreateClaudeSubagentRoster, resolveClaudePaneStatus @@ -85,7 +85,7 @@ export function normalizeClaudeSubagentLifecycleEvent( const hasUnconfirmedChild = claudeRosterHasRestoredSnapshotSubagent(roster) const hasConfirmedDoneGate = cachedLead?.state === 'done' && - !claudeMainAgentTurnInterrupted(cachedLead) && + !mainAgentTurnInterrupted(cachedLead) && (state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || state.claudeActiveSessionCronPaneKeys.has(paneKey)) const restoredOnlyDoneGate = @@ -138,7 +138,7 @@ export function buildClaudeCachedLeadStatusPayload( outcome: lead?.outcome }), updateToolSnapshot: false, - interrupted: claudeMainAgentTurnInterrupted(lead), + interrupted: mainAgentTurnInterrupted(lead), // Why: draining the last background child is this turn's all-clear; the stamp lets a consumer pair it with the announcement already sent. turnCompletedAt: lead?.turnCompletedAt }) diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index 80c68037390b..c35fa2c03c6c 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -5,6 +5,7 @@ import type { } from '../../agent-status-types' import { continueMainAgentStatus, + mainAgentTurnInterrupted, foldAgentLeadStatus, type AgentLeadStatusResolution } from '../../agent-lead-status-fold' @@ -126,13 +127,6 @@ export function updateClaudeRunningNonAgentTask( export type ClaudePaneStatusResolution = AgentLeadStatusResolution -/** A cancelled turn is the one verdict the display fold still reads. */ -export function claudeMainAgentTurnInterrupted( - record: Pick | undefined -): boolean { - return record?.outcome === 'cancellation' -} - /** The only writer of the main agent record. The main agent's clock keeps continuity across * same-state writes; a caller restoring a stash passes the stashed instant and wins. */ export function setClaudeMainAgentTurnState( @@ -175,8 +169,11 @@ export function resolveClaudePaneStatus( ): ClaudePaneStatusResolution { return foldAgentLeadStatus({ leadState: lead.state, - interrupted: claudeMainAgentTurnInterrupted(lead), + interrupted: mainAgentTurnInterrupted(lead), childWorkLiveness: agentChildWorkLivenessFromEvidence({ + // A child's permission wait displaces the main agent record itself (`waitingAgentId`, + // `stateBeforeWait`) instead of living on the roster, so the roster never carries one. + hasWaitingChildWork: false, hasLiveAgentWork: claudeRosterHasWorkingSubagent( state.claudeSubagentRosterByPaneKey.get(paneKey) ), @@ -328,7 +325,7 @@ export function clearClaudeAnsweredQuestionWait( return { state: resolved.stateName, ...(resolved.workingMode ? { workingMode: resolved.workingMode } : {}), - ...(claudeMainAgentTurnInterrupted(restored) ? { interrupted: true as const } : {}), + ...(mainAgentTurnInterrupted(restored) ? { interrupted: true as const } : {}), ...(restored.turnCompletedAt !== undefined ? { turnCompletedAt: restored.turnCompletedAt } : {}), diff --git a/src/shared/agent-hook-listener/providers/codex-events.ts b/src/shared/agent-hook-listener/providers/codex-events.ts index d056b2de4dbc..c7f615213704 100644 --- a/src/shared/agent-hook-listener/providers/codex-events.ts +++ b/src/shared/agent-hook-listener/providers/codex-events.ts @@ -5,8 +5,8 @@ import { } from '../../agent-status-types' import { normalizeOptionalField } from '../../agent-status-field-normalization' import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import type { AgentLeadStatusResolution } from '../../agent-lead-status-fold' import { - codexRosterEffectiveState, codexRosterToSnapshots, finishCodexSubagent, upsertCodexSubagent @@ -27,6 +27,7 @@ import { getOrCreateCodexSubagentRoster, getOrCreateCodexSubagentTranscriptState, hasCodexTranscriptSubagents, + resolveCodexPaneStatus, setCodexMainAgentTurnState } from './codex-state' @@ -36,7 +37,7 @@ export function buildCodexStatusPayload( promptText: string, paneKey: string, hookPayload: Record, - options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } + options: AgentLeadStatusResolution & { updateLead: boolean } ): ParsedAgentStatusPayload | null { const snapshot = options.updateLead ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { @@ -47,6 +48,7 @@ export function buildCodexStatusPayload( return normalizeAgentStatusPayload({ state: options.stateName, + workingMode: options.workingMode, prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn: options.updateLead && isNewTurnEvent('codex', eventName) }), @@ -68,13 +70,10 @@ export function buildCodexChildDrivenStatusPayload( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { - const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' - const stateName = codexRosterEffectiveState( - state.codexSubagentRosterByPaneKey.get(paneKey), - leadState - ) + // Why: a child event before any root event means the root is mid-turn; nothing else spawns. + const lead = state.codexLeadStateByPaneKey.get(paneKey) ?? { state: 'working' as const } return buildCodexStatusPayload(state, eventName, '', paneKey, hookPayload, { - stateName, + ...resolveCodexPaneStatus(state, paneKey, lead), updateLead: false }) } @@ -228,21 +227,15 @@ export function normalizeCodexEvent( stateName ) const previousLead = state.codexLeadStateByPaneKey.get(paneKey) - setCodexMainAgentTurnState(state, paneKey, { + const record = setCodexMainAgentTurnState(state, paneKey, { state: ownedState, ...codexOutcomeRestatedByStop(previousLead, ownedState), model: normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? (eventName === 'SessionStart' ? undefined : previousLead?.model) }) - // The combined state keeps Codex's own rule (a waiting child wins, a done root with any live - // child reads working); folding it onto `foldAgentLeadStatus` is a separate slice. - const effectiveState = codexRosterEffectiveState( - state.codexSubagentRosterByPaneKey.get(paneKey), - ownedState - ) return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { - stateName: effectiveState, + ...resolveCodexPaneStatus(state, paneKey, record), updateLead: true }) } diff --git a/src/shared/agent-hook-listener/providers/codex-state.ts b/src/shared/agent-hook-listener/providers/codex-state.ts index bd86f77f8d85..e2f7e2f5ae3e 100644 --- a/src/shared/agent-hook-listener/providers/codex-state.ts +++ b/src/shared/agent-hook-listener/providers/codex-state.ts @@ -1,7 +1,12 @@ import type { AgentMainAgentStatus, ParsedAgentStatusPayload } from '../../agent-status-types' -import { continueMainAgentStatus } from '../../agent-lead-status-fold' import { - codexRosterEffectiveState, + continueMainAgentStatus, + foldAgentLeadStatus, + mainAgentTurnInterrupted, + type AgentLeadStatusResolution +} from '../../agent-lead-status-fold' +import { + codexRosterChildWorkLiveness, codexRosterToSnapshots, finishCodexSubagent, seedCodexSubagentRoster, @@ -72,9 +77,21 @@ export function codexOutcomeRestatedByStop( : {} } -/** The `mainAgent` fact a Codex row publishes. Its combined `state` still comes from - * `codexRosterEffectiveState`, whose waiting-child rule the shared fold cannot express yet; - * moving that combine onto the fold is a separate slice with its own story table. */ +/** The combined row state for a Codex pane: the root record and its roster through the same + * fold every other lane uses. */ +export function resolveCodexPaneStatus( + state: HookListenerState, + paneKey: string, + record: Pick +): AgentLeadStatusResolution { + return foldAgentLeadStatus({ + leadState: record.state, + interrupted: mainAgentTurnInterrupted(record), + childWorkLiveness: codexRosterChildWorkLiveness(state.codexSubagentRosterByPaneKey.get(paneKey)) + }) +} + +/** The `mainAgent` fact a Codex row publishes, straight from the root record. */ export function codexMainAgentStatusForPayload( record: CodexLeadTurnState | undefined ): AgentMainAgentStatus | undefined { @@ -203,6 +220,7 @@ export function reconcileRemoteCodexState( if (!lead) { return payload } + const resolution = resolveCodexPaneStatus(state, paneKey, lead) // Child lifecycle hooks commonly omit the root prompt. Preserve the last known // turn label while merging their roster/state so relay restarts do not blank it. const prompt = @@ -212,7 +230,8 @@ export function reconcileRemoteCodexState( return { ...payload, prompt, - state: codexRosterEffectiveState(roster, lead.state), + state: resolution.stateName, + workingMode: resolution.workingMode, model: lead.model ?? payload.model, subagents: codexRosterToSnapshots(roster), // Why: main's cache outlives a relay restart, so it is the main agent fact for a relayed row too. diff --git a/src/shared/agent-lead-status-fold.test.ts b/src/shared/agent-lead-status-fold.test.ts index 75ab3da89dd1..252205888f88 100644 --- a/src/shared/agent-lead-status-fold.test.ts +++ b/src/shared/agent-lead-status-fold.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { continueMainAgentStatus, + mainAgentTurnInterrupted, foldAgentLeadStatus, isAgentStatusHeldOpenByChildWork } from './agent-lead-status-fold' @@ -46,6 +47,35 @@ describe('foldAgentLeadStatus', () => { foldAgentLeadStatus({ leadState: 'done', interrupted: false, childWorkLiveness: null }) ).toEqual({ stateName: 'done' }) }) + + describe('a child waiting on a human', () => { + it('makes a working or settled main agent wait, even after an interrupt', () => { + for (const leadState of ['working', 'done'] as const) { + for (const interrupted of [false, true]) { + expect( + foldAgentLeadStatus({ leadState, interrupted, childWorkLiveness: 'waiting' }) + ).toEqual({ stateName: 'waiting' }) + } + } + }) + + it("yields to the main agent's own request for a human, in the main agent's own vocabulary", () => { + for (const leadState of ['waiting', 'blocked'] as const) { + expect( + foldAgentLeadStatus({ leadState, interrupted: false, childWorkLiveness: 'waiting' }) + ).toEqual({ stateName: leadState }) + } + }) + }) +}) + +describe('mainAgentTurnInterrupted', () => { + it('reads only a cancellation verdict as an interrupt', () => { + expect(mainAgentTurnInterrupted({ outcome: 'cancellation' })).toBe(true) + expect(mainAgentTurnInterrupted({ outcome: 'failure' })).toBe(false) + expect(mainAgentTurnInterrupted({})).toBe(false) + expect(mainAgentTurnInterrupted(undefined)).toBe(false) + }) }) describe('isAgentStatusHeldOpenByChildWork', () => { diff --git a/src/shared/agent-lead-status-fold.ts b/src/shared/agent-lead-status-fold.ts index c74ae7df1f67..416fb23a37a1 100644 --- a/src/shared/agent-lead-status-fold.ts +++ b/src/shared/agent-lead-status-fold.ts @@ -2,7 +2,8 @@ import type { AgentChildWorkLiveness } from './agent-status-child-work-liveness' import type { AgentMainAgentStatus, AgentStatusState, AgentWorkingMode } from './agent-status-types' export type AgentLeadStatusFoldInput = { - /** The lead's own turn state. Anything but `done` wins outright. */ + /** The main agent's own turn state. Anything but `done` wins over child work, except that a + * child waiting on a human outranks a working main agent. */ leadState: AgentStatusState /** A lead turn that ended by interrupt keeps a watch loop from reading as monitoring; * live agent work still counts, because it outlives the interrupt. */ @@ -15,13 +16,29 @@ export type AgentLeadStatusResolution = { workingMode?: AgentWorkingMode } +/** A cancelled turn is the one verdict the display fold reads off the main agent record. */ +export function mainAgentTurnInterrupted( + record: Pick | undefined +): boolean { + return record?.outcome === 'cancellation' +} + /** - * One fold for every lane that publishes a lead agent's status: a settled lead - * with live agent work is still working, and a settled lead with only watch - * loops is monitoring. The hook lane and the structured session lane derive - * the liveness from different evidence, but the policy must not differ. + * One fold for every lane that publishes a main agent's status: a child waiting + * on a human makes the row wait whatever the main agent is doing, a settled main + * agent with live agent work is still working, and one with only watch loops is + * monitoring. Every lane derives the liveness from its own evidence, but the + * policy must not differ. */ export function foldAgentLeadStatus(input: AgentLeadStatusFoldInput): AgentLeadStatusResolution { + // The main agent's own request for a human keeps its own vocabulary (`blocked` in the + // structured lane); a child's request surfaces only when the main agent is not already asking. + if (input.leadState === 'waiting' || input.leadState === 'blocked') { + return { stateName: input.leadState } + } + if (input.childWorkLiveness === 'waiting') { + return { stateName: 'waiting' } + } if (input.leadState !== 'done') { return { stateName: input.leadState } } diff --git a/src/shared/agent-status-child-work-liveness.test.ts b/src/shared/agent-status-child-work-liveness.test.ts index b376a098ce09..519d9b8119e9 100644 --- a/src/shared/agent-status-child-work-liveness.test.ts +++ b/src/shared/agent-status-child-work-liveness.test.ts @@ -12,15 +12,34 @@ function child( } describe('agentChildWorkLivenessFromEvidence', () => { - it('lets live agent work outrank live watch loops', () => { + it('ranks a waiting child above live agent work, and that above live watch loops', () => { expect( - agentChildWorkLivenessFromEvidence({ hasLiveAgentWork: true, hasLiveNonAgentWork: true }) + agentChildWorkLivenessFromEvidence({ + hasWaitingChildWork: true, + hasLiveAgentWork: true, + hasLiveNonAgentWork: true + }) + ).toBe('waiting') + expect( + agentChildWorkLivenessFromEvidence({ + hasWaitingChildWork: false, + hasLiveAgentWork: true, + hasLiveNonAgentWork: true + }) ).toBe('working') expect( - agentChildWorkLivenessFromEvidence({ hasLiveAgentWork: false, hasLiveNonAgentWork: true }) + agentChildWorkLivenessFromEvidence({ + hasWaitingChildWork: false, + hasLiveAgentWork: false, + hasLiveNonAgentWork: true + }) ).toBe('monitoring') expect( - agentChildWorkLivenessFromEvidence({ hasLiveAgentWork: false, hasLiveNonAgentWork: false }) + agentChildWorkLivenessFromEvidence({ + hasWaitingChildWork: false, + hasLiveAgentWork: false, + hasLiveNonAgentWork: false + }) ).toBeNull() }) }) @@ -42,13 +61,26 @@ describe('agentChildWorkLiveness', () => { ) }) - it('keeps a waiting, blocked or unverifiable agent live, the way a shell in those states is', () => { - for (const state of ['waiting', 'blocked', 'unverifiable'] as const) { + // A child's `blocked` is a failure, not a request for a human; neither it nor lost contact waits. + it('keeps a failed or unverifiable agent live without asking for a human', () => { + for (const state of ['blocked', 'unverifiable'] as const) { expect(agentChildWorkLiveness([child({ state })])).toBe('working') expect(agentChildWorkLiveness([child({ kind: 'command', state })])).toBe('monitoring') } }) + it('reads a child of any kind waiting on a human as waiting, above every other live child', () => { + expect(agentChildWorkLiveness([child({ state: 'waiting' })])).toBe('waiting') + expect(agentChildWorkLiveness([child({ kind: 'command', state: 'waiting' })])).toBe('waiting') + expect( + agentChildWorkLiveness([child(), child({ kind: 'command' }), child({ state: 'waiting' })]) + ).toBe('waiting') + }) + + it('does not read a settled child as waiting whatever it was doing before', () => { + expect(agentChildWorkLiveness([child({ state: 'done' })])).toBeNull() + }) + it('retires an agent only on an explicitly settled state', () => { for (const state of ['done', 'idle'] as const) { expect(agentChildWorkLiveness([child({ state })])).toBeNull() diff --git a/src/shared/agent-status-child-work-liveness.ts b/src/shared/agent-status-child-work-liveness.ts index 0df7d8dfc7e7..657df9592f90 100644 --- a/src/shared/agent-status-child-work-liveness.ts +++ b/src/shared/agent-status-child-work-liveness.ts @@ -1,8 +1,9 @@ import type { AgentChildWorkKind, AgentChildWorkState } from './agent-status-child-work' -/** Two-state vocabulary by design: any live agent work reads as `working`; `monitoring` - * only when shells and monitors are the sole live work; null when nothing runs. */ -export type AgentChildWorkLiveness = 'working' | 'monitoring' | null +/** Three live arms by design, ranked: a child waiting on a human is `waiting`; otherwise any live + * agent work reads as `working`; `monitoring` only when shells and monitors are the sole live + * work; null when nothing runs. */ +export type AgentChildWorkLiveness = 'waiting' | 'working' | 'monitoring' | null export type AgentChildWorkLivenessCandidate = { kind: AgentChildWorkKind @@ -10,6 +11,8 @@ export type AgentChildWorkLivenessCandidate = { } export type AgentChildWorkLivenessEvidence = { + /** A live child of any kind is waiting on a human before it can go on. */ + hasWaitingChildWork: boolean hasLiveAgentWork: boolean hasLiveNonAgentWork: boolean } @@ -26,14 +29,24 @@ export function isAgentChildWorkKind(kind: AgentChildWorkKind): boolean { * retire — and a blocked subagent cannot count for less than the shell beside it. * The escape hatch is the roster's own lifetime, not a state: it is per-session host memory that * dies when the session closes (Claude also clears it on provider `ended`), so a producer that - * ever reported a failure IN PLACE rather than settling it would pin `working` until then. */ + * ever reported a failure IN PLACE (as `blocked`) rather than settling it would pin `working` + * until then. */ function isLiveChildWork(child: AgentChildWorkLivenessCandidate): boolean { return child.state !== 'done' && child.state !== 'idle' } +/** Only `waiting` asks for a human. A child's `blocked` means it failed (its sole producer maps a + * failed task to it), unlike the row's `blocked`; lost contact (`unverifiable`) asks for no one. */ +function isWaitingChildWork(child: AgentChildWorkLivenessCandidate): boolean { + return child.state === 'waiting' +} + export function agentChildWorkLivenessFromEvidence( evidence: AgentChildWorkLivenessEvidence ): AgentChildWorkLiveness { + if (evidence.hasWaitingChildWork) { + return 'waiting' + } if (evidence.hasLiveAgentWork) { return 'working' } @@ -43,14 +56,20 @@ export function agentChildWorkLivenessFromEvidence( export function agentChildWorkLiveness( children: readonly AgentChildWorkLivenessCandidate[] | undefined ): AgentChildWorkLiveness { + let hasWaitingChildWork = false let hasLiveAgentWork = false let hasLiveNonAgentWork = false for (const child of children ?? []) { if (!isLiveChildWork(child)) { continue } + hasWaitingChildWork ||= isWaitingChildWork(child) hasLiveAgentWork ||= isAgentChildWorkKind(child.kind) hasLiveNonAgentWork ||= !isAgentChildWorkKind(child.kind) } - return agentChildWorkLivenessFromEvidence({ hasLiveAgentWork, hasLiveNonAgentWork }) + return agentChildWorkLivenessFromEvidence({ + hasWaitingChildWork, + hasLiveAgentWork, + hasLiveNonAgentWork + }) } diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts index 0cbb44830c93..9d7598504765 100644 --- a/src/shared/codex-subagent-roster.test.ts +++ b/src/shared/codex-subagent-roster.test.ts @@ -5,6 +5,7 @@ import { AGENT_TYPE_MAX_LENGTH } from './agent-status-types' import { + codexRosterChildWorkLiveness, codexRosterToSnapshots, finishCodexSubagent, setCodexSubagentModel, @@ -63,6 +64,20 @@ describe('Codex subagent roster', () => { expect(roster.has('replacement')).toBe(true) }) + describe('codexRosterChildWorkLiveness', () => { + it('reads every child as agent work, a waiting one above the rest, and nothing as a watch loop', () => { + const roster: CodexSubagentRoster = new Map() + expect(codexRosterChildWorkLiveness(undefined)).toBeNull() + expect(codexRosterChildWorkLiveness(roster)).toBeNull() + upsertCodexSubagent(roster, 'a', { state: 'working' }, 1) + expect(codexRosterChildWorkLiveness(roster)).toBe('working') + upsertCodexSubagent(roster, 'b', { state: 'waiting' }, 2) + expect(codexRosterChildWorkLiveness(roster)).toBe('waiting') + finishCodexSubagent(roster, 'b') + expect(codexRosterChildWorkLiveness(roster)).toBe('working') + }) + }) + describe('setCodexSubagentModel', () => { it('records the model without disturbing the child lifecycle or label', () => { const roster: CodexSubagentRoster = new Map() diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts index 5592ed8711f5..805d459e059e 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/codex-subagent-roster.ts @@ -6,6 +6,10 @@ import { type AgentSubagentSnapshot } from './agent-status-types' import { normalizeOptionalField } from './agent-status-field-normalization' +import { + agentChildWorkLiveness, + type AgentChildWorkLiveness +} from './agent-status-child-work-liveness' const CODEX_SUBAGENT_ID_MAX_LENGTH = 64 @@ -123,17 +127,15 @@ export function codexRosterToSnapshots( return snapshots } -export function codexRosterEffectiveState( - roster: CodexSubagentRoster | undefined, - leadState: 'working' | 'waiting' | 'done' -): 'working' | 'waiting' | 'done' { - if (!roster || roster.size === 0) { - return leadState - } - for (const tracked of roster.values()) { - if (tracked.state === 'waiting') { - return 'waiting' - } - } - return leadState === 'done' ? 'working' : leadState +/** The roster as child-work evidence for the shared fold. Every tracked child is a spawned + * agent thread, classified by the one kind test the other lanes use, so a waiting child + * reads `waiting` and a live one `working`; nothing here is a watch loop. */ +export function codexRosterChildWorkLiveness( + roster: CodexSubagentRoster | undefined +): AgentChildWorkLiveness { + return agentChildWorkLiveness( + roster + ? Array.from(roster.values(), (tracked) => ({ kind: 'agent' as const, state: tracked.state })) + : undefined + ) } diff --git a/src/shared/main-agent-status-parity.test.ts b/src/shared/main-agent-status-parity.test.ts index 25112754034c..c08b5e9f6f7f 100644 --- a/src/shared/main-agent-status-parity.test.ts +++ b/src/shared/main-agent-status-parity.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { normalizeHookPayload } from './agent-hook-listener' import { markClaudeLeadTurnInterrupted } from './agent-hook-listener/providers/claude-roster-state' +import { markCodexLeadTurnInterrupted } from './agent-hook-listener/providers/codex-state' import { createHookListenerState, type HookListenerState @@ -23,7 +24,7 @@ import type { AgentWorkingMode, ParsedAgentStatusPayload } from './agent-status-types' -import { codexRosterEffectiveState, seedCodexSubagentRoster } from './codex-subagent-roster' +import { codexRosterChildWorkLiveness, seedCodexSubagentRoster } from './codex-subagent-roster' import { structuredAgentSessionAgentStatus } from './structured-agent-session-agent-status' import type { AgentJournalTurnOutcome } from './agent-turn-outcome' @@ -192,6 +193,65 @@ const STORIES: Story[] = [ expect: { state: 'waiting', mainAgent: { state: 'waiting' } } } }, + { + // Claude records a child's wait by displacing the main agent record; Codex keeps it on the + // child. Both publish the main agent's own state beside the waiting row. + name: 'a child waiting on the user', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { + hook_event_name: 'PermissionRequest', + agent_id: 'agent-1', + tool_name: 'Bash', + tool_input: { command: 'rm' } + } + ], + expect: { state: 'waiting', mainAgent: { state: 'working' } } + }, + codex: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'PermissionRequest', agent_id: 'agent-1', tool_name: 'shell' } + ], + expect: { state: 'waiting', mainAgent: { state: 'working' } } + } + }, + { + name: 'settled main agent whose child is waiting on the user', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop', background_tasks: [RUNNING_AGENT] }, + { + hook_event_name: 'PermissionRequest', + agent_id: 'agent-1', + tool_name: 'Bash', + tool_input: { command: 'rm' } + } + ], + expect: { state: 'waiting', mainAgent: { state: 'done' } } + }, + // KNOWN DIVERGENCE: no structured producer reports a waiting task; a child's pending prompt + // is a session-level `attention`, so this lane blames the main agent for the child's request. + structured: { + status: 'attention', + backgroundTasks: [AGENT_TASK], + expect: { state: 'blocked', mainAgent: { state: 'blocked' } } + }, + codex: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }, + { hook_event_name: 'Stop' }, + { hook_event_name: 'PermissionRequest', agent_id: 'agent-1', tool_name: 'shell' } + ], + expect: { state: 'waiting', mainAgent: { state: 'done' } } + } + }, { name: 'failed turn', claude: { @@ -247,6 +307,27 @@ const STORIES: Story[] = [ expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } } }, + { + // Neither CLI reports a cancel on its own Stop, so the late turn boundary must keep the + // verdict Orca inferred rather than downgrade it to "unknown". + name: 'interrupted, then the late turn boundary', + claude: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + ORCA_INFERRED_INTERRUPT, + { hook_event_name: 'Stop' } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + }, + codex: { + events: [ + { hook_event_name: 'UserPromptSubmit', prompt: 'go' }, + ORCA_INFERRED_INTERRUPT, + { hook_event_name: 'Stop' } + ], + expect: { state: 'done', mainAgent: { state: 'done', outcome: 'cancellation' } } + } + }, { // Secondary source: a build that does send `is_interrupt` on its Stop. Same known divergence. name: 'interrupted by a Stop that carries is_interrupt, with a watch loop (older builds)', @@ -260,14 +341,6 @@ const STORIES: Story[] = [ } ] -/** Codex never reports a blocked root; the combine's input type says so. */ -function codexMainAgentState(state: AgentStatusState): 'working' | 'waiting' | 'done' { - if (state === 'blocked') { - throw new Error('Codex published a blocked main agent') - } - return state -} - /** The stories a lane takes part in, as `it.each` rows. */ function storiesFor( lane: K @@ -296,7 +369,11 @@ describe('mainAgent status parity across lanes', () => { let last: ParsedAgentStatusPayload | null = null for (const payload of events) { if (payload === ORCA_INFERRED_INTERRUPT) { - markClaudeLeadTurnInterrupted(state, PANE_KEY) + if (source === 'codex') { + markCodexLeadTurnInterrupted(state, PANE_KEY) + } else { + markClaudeLeadTurnInterrupted(state, PANE_KEY) + } continue } const event = normalizeHookPayload( @@ -316,6 +393,9 @@ describe('mainAgent status parity across lanes', () => { /** The hook lane's child evidence: the roster on the row, the shell and cron sets in memory. */ function claudeChildWorkLiveness(payload: ParsedAgentStatusPayload): AgentChildWorkLiveness { return agentChildWorkLivenessFromEvidence({ + // This lane holds a child's wait on the displaced main agent record, not the roster. + hasWaitingChildWork: + state.claudeLeadStateByPaneKey.get(PANE_KEY)?.waitingAgentId !== undefined, hasLiveAgentWork: payload.subagents?.some((child) => child.state === 'working') === true, hasLiveNonAgentWork: state.claudeRunningNonAgentTaskPaneKeys.has(PANE_KEY) || @@ -366,18 +446,15 @@ describe('mainAgent status parity across lanes', () => { }) }) - describe('Codex hook lane (own combine, not the shared fold)', () => { + describe('Codex hook lane', () => { it.each(storiesFor('codex'))('%s', (_name, lane) => { const payload = drive('codex', lane.events) const row = published(payload) expect(row).toEqual(lane.expect) - // Codex keeps `codexRosterEffectiveState` until its combine moves onto the fold: a - // waiting child wins, a settled root with any live child reads working, no monitoring. + // Codex's child evidence is the roster on the row: every child a spawned agent thread. const roster = new Map() seedCodexSubagentRoster(roster, payload.subagents ?? []) - expect(row.state).toBe( - codexRosterEffectiveState(roster, codexMainAgentState(row.mainAgent.state)) - ) + expect(row).toEqual(refold(row.mainAgent, codexRosterChildWorkLiveness(roster))) }) }) }) diff --git a/src/shared/structured-agent-session-agent-status.test.ts b/src/shared/structured-agent-session-agent-status.test.ts index 15dc6995748b..097da311c522 100644 --- a/src/shared/structured-agent-session-agent-status.test.ts +++ b/src/shared/structured-agent-session-agent-status.test.ts @@ -39,14 +39,23 @@ describe('structuredAgentSessionAgentStatus', () => { ).toEqual({ state: 'working', workingMode: 'monitoring', mainAgent: { state: 'done' } }) }) - it('keeps an idle lead working while a subagent is blocked or out of contact', () => { - for (const state of ['waiting', 'blocked', 'unverifiable'] as const) { + it('keeps an idle main agent working while a subagent failed in place or is out of contact', () => { + for (const state of ['blocked', 'unverifiable'] as const) { expect( structuredAgentSessionAgentStatus({ status: 'idle', backgroundTasks: [task({ state })] }) ).toEqual({ state: 'working', mainAgent: { state: 'done' } }) } }) + it('reads an idle main agent as waiting while a subagent waits on a human', () => { + expect( + structuredAgentSessionAgentStatus({ + status: 'idle', + backgroundTasks: [task({ state: 'waiting' })] + }) + ).toEqual({ state: 'waiting', mainAgent: { state: 'done' } }) + }) + // The spinner and the expandable child list are built from the same summary, so a workflow must // not claim a running agent that `projectAgentChildWorkLegacySubagents` then refuses to render. it('reads a lead whose only live task is a workflow as monitoring, with no children to show', () => {