fix(codex): preserve nested agent state on restart

This commit is contained in:
Brennan Benson
2026-07-20 19:28:31 -07:00
parent 096b0d9da4
commit d4a45c3ddc
5 changed files with 117 additions and 39 deletions
+20
View File
@@ -6434,6 +6434,26 @@ describe('Last-status persistence', () => {
})
])
await postHookEvent(
server,
buildBody({
hook_event_name: 'PreToolUse',
agent_id: '11111111-2222-4333-8444-555555555555',
agent_type: 'reviewer',
model: 'gpt-5.4-mini',
tool_name: 'exec_command',
tool_input: { cmd: 'pnpm test' }
}),
'/hook/codex'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
model: 'gpt-5.4',
subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })]
})
])
await postHookEvent(
server,
buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }),
+9 -15
View File
@@ -26,7 +26,7 @@ import {
resolveHookSource,
preparePendingGrokResultDiscovery,
seedClaudeSubagentRosterFromSnapshots,
seedCodexSubagentRosterFromSnapshots,
seedCodexStateFromSnapshot,
warnOnHookEnvOrVersionMismatch,
writeEndpointFile,
type AgentHookEventPayload,
@@ -1830,20 +1830,14 @@ export class AgentHookServer {
)
}
// Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds.
if (entry.payload.subagents) {
if (entry.payload.agentType === 'codex') {
seedCodexSubagentRosterFromSnapshots(
this.state,
resolvedPaneKey,
entry.payload.subagents
)
} else if (entry.payload.agentType === 'claude') {
seedClaudeSubagentRosterFromSnapshots(
this.state,
resolvedPaneKey,
entry.payload.subagents
)
}
if (entry.payload.agentType === 'codex') {
seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload)
} else if (entry.payload.agentType === 'claude' && entry.payload.subagents) {
seedClaudeSubagentRosterFromSnapshots(
this.state,
resolvedPaneKey,
entry.payload.subagents
)
}
hydrated += 1
} else {
+22 -6
View File
@@ -19,12 +19,14 @@ import {
import { isAbsolute, join } from 'node:path'
import {
AGENT_MODEL_MAX_LENGTH,
normalizeAgentStatusPayload,
parseAgentStatusPayload,
type AgentStatusState,
type AgentSubagentSnapshot,
type ParsedAgentStatusPayload
} from './agent-status-types'
import { normalizeOptionalField } from './agent-status-field-normalization'
import { isAskUserQuestionTool } from './agent-question-answered-intent'
import {
claudeRosterHasWorkingSubagent,
@@ -3037,15 +3039,29 @@ function getOrCreateCodexSubagentRoster(
return roster
}
export function seedCodexSubagentRosterFromSnapshots(
export function seedCodexStateFromSnapshot(
state: HookListenerState,
paneKey: string,
snapshots: readonly AgentSubagentSnapshot[]
payload: Pick<ParsedAgentStatusPayload, 'model' | 'state' | 'subagents'>
): void {
if (snapshots.length === 0 || state.codexSubagentRosterByPaneKey.has(paneKey)) {
return
const snapshots = payload.subagents ?? []
if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) {
seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(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.
state.codexLeadStateByPaneKey.set(paneKey, {
// Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting.
state:
payload.state === 'done'
? 'done'
: payload.state === 'waiting' &&
!snapshots.some((snapshot) => snapshot.state === 'waiting')
? 'waiting'
: 'working',
model: payload.model
})
}
seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots)
}
function buildCodexStatusPayload(
@@ -3175,7 +3191,7 @@ function normalizeCodexEvent(
state.codexLeadStateByPaneKey.set(paneKey, {
state: stateName,
model:
readString(hookPayload, 'model') ??
normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ??
(eventName === 'SessionStart' ? undefined : previousLead?.model)
})
const effectiveState = codexRosterEffectiveState(
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { AGENT_MODEL_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types'
import {
codexRosterToSnapshots,
finishCodexSubagent,
upsertCodexSubagent,
type CodexSubagentRoster
} from './codex-subagent-roster'
describe('Codex subagent roster', () => {
it('normalizes retained identity fields before storing them', () => {
const roster: CodexSubagentRoster = new Map()
upsertCodexSubagent(
roster,
' child-1 ',
{
agentType: `reviewer\n${'x'.repeat(AGENT_TYPE_MAX_LENGTH * 2)}`,
model: `gpt-model-${'x'.repeat(AGENT_MODEL_MAX_LENGTH * 2)}`,
state: 'working'
},
10
)
const snapshot = codexRosterToSnapshots(roster)?.[0]
expect([...roster.keys()]).toEqual(['child-1'])
expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH)
expect(snapshot?.agentType).not.toContain('\n')
expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH)
finishCodexSubagent(roster, ' child-1 ')
expect(roster.size).toBe(0)
})
it('rejects an id that would normalize to an invisible child', () => {
const roster: CodexSubagentRoster = new Map()
upsertCodexSubagent(roster, ' ', { state: 'waiting' }, 10)
expect(roster.size).toBe(0)
})
})
+24 -18
View File
@@ -1,4 +1,10 @@
import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types'
import {
AGENT_MODEL_MAX_LENGTH,
AGENT_STATUS_MAX_SUBAGENTS,
AGENT_TYPE_MAX_LENGTH,
type AgentSubagentSnapshot
} from './agent-status-types'
import { normalizeOptionalField } from './agent-status-field-normalization'
const CODEX_SUBAGENT_ID_MAX_LENGTH = 64
@@ -21,29 +27,32 @@ export function upsertCodexSubagent(
},
now: number
): void {
if (id.length === 0 || id.length > CODEX_SUBAGENT_ID_MAX_LENGTH) {
const normalizedId = id.trim()
if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) {
return
}
const existing = roster.get(id)
const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH)
const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH)
const existing = roster.get(normalizedId)
if (existing) {
existing.agentType = fields.agentType ?? existing.agentType
existing.model = fields.model ?? existing.model
existing.agentType = agentType ?? existing.agentType
existing.model = model ?? existing.model
existing.state = fields.state
return
}
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) {
return
}
roster.set(id, {
agentType: fields.agentType,
model: fields.model,
roster.set(normalizedId, {
agentType,
model,
state: fields.state,
startedAt: now
})
}
export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void {
roster.delete(id)
roster.delete(id.trim())
}
export function seedCodexSubagentRoster(
@@ -54,15 +63,12 @@ export function seedCodexSubagentRoster(
if (snapshot.state !== 'working' && snapshot.state !== 'waiting') {
continue
}
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) {
return
}
roster.set(snapshot.id, {
agentType: snapshot.agentType,
model: snapshot.model,
state: snapshot.state,
startedAt: snapshot.startedAt
})
upsertCodexSubagent(
roster,
snapshot.id,
{ agentType: snapshot.agentType, model: snapshot.model, state: snapshot.state },
snapshot.startedAt
)
}
}