Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions docs/reference/agent-status-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions src/shared/agent-hook-listener-codex-main-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type HookListenerState
} from './agent-hook-listener/listener-state'
import {
markCodexLeadTurnInterrupted,
reconcileRemoteCodexState,
seedCodexStateFromSnapshot
} from './agent-hook-listener/providers/codex-state'
Expand Down Expand Up @@ -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' } })
})
})
3 changes: 2 additions & 1 deletion src/shared/agent-hook-listener/main-agent-turn-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions src/shared/agent-hook-listener/providers/claude-events.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,7 +18,6 @@ import {
normalizeClaudeSubagentLifecycleEvent
} from './claude-lifecycle-events'
import {
claudeMainAgentTurnInterrupted,
getOrCreateClaudeSubagentRoster,
resolveClaudePaneStatus,
setClaudeMainAgentTurnState,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
})
Expand Down
15 changes: 6 additions & 9 deletions src/shared/agent-hook-listener/providers/claude-roster-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
} from '../../agent-status-types'
import {
continueMainAgentStatus,
mainAgentTurnInterrupted,
foldAgentLeadStatus,
type AgentLeadStatusResolution
} from '../../agent-lead-status-fold'
Expand Down Expand Up @@ -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<ClaudeLeadTurnState, 'outcome'> | 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(
Expand Down Expand Up @@ -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)
),
Expand Down Expand Up @@ -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 }
: {}),
Expand Down
25 changes: 9 additions & 16 deletions src/shared/agent-hook-listener/providers/codex-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +27,7 @@ import {
getOrCreateCodexSubagentRoster,
getOrCreateCodexSubagentTranscriptState,
hasCodexTranscriptSubagents,
resolveCodexPaneStatus,
setCodexMainAgentTurnState
} from './codex-state'

Expand All @@ -36,7 +37,7 @@ export function buildCodexStatusPayload(
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>,
options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean }
options: AgentLeadStatusResolution & { updateLead: boolean }
): ParsedAgentStatusPayload | null {
const snapshot = options.updateLead
? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), {
Expand All @@ -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)
}),
Expand All @@ -68,13 +70,10 @@ export function buildCodexChildDrivenStatusPayload(
paneKey: string,
hookPayload: Record<string, unknown>
): 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
})
}
Expand Down Expand Up @@ -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
})
}
31 changes: 25 additions & 6 deletions src/shared/agent-hook-listener/providers/codex-state.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<CodexLeadTurnState, 'state' | 'outcome'>
): 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 {
Expand Down Expand Up @@ -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 =
Expand All @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions src/shared/agent-lead-status-fold.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
continueMainAgentStatus,
mainAgentTurnInterrupted,
foldAgentLeadStatus,
isAgentStatusHeldOpenByChildWork
} from './agent-lead-status-fold'
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading