fix(agent-hooks): preserve uncertain Codex child activity

This commit is contained in:
Brennan Benson
2026-08-27 16:21:59 -07:00
parent 685374fb39
commit ef49c94f01
5 changed files with 182 additions and 20 deletions
@@ -265,6 +265,63 @@ describe('AgentHookServer Codex subagent transcript polling', () => {
}
})
it('keeps a restored aggregate active until a child outlives the parent', async () => {
const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-restart-active-child-'))
dirs.push(dir)
const parentPath = join(dir, 'rollout-parent.jsonl')
const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`)
writeFileSync(
parentPath,
`${line({ type: 'event_msg', payload: { type: 'task_started' } })}${spawnLine(CHILD_ID, '/root/active_child')}`
)
writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } }))
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath: dir })
try {
const env = first.buildPtyEnv()
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
hook_event_name: 'PostToolUse',
session_id: 'root-session',
transcript_path: parentPath,
tool_name: 'collaborationspawn_agent'
}
})
})
expect(first.getStatusSnapshot()[0]?.subagents).toHaveLength(1)
} finally {
first.stop()
}
appendFileSync(parentPath, line({ type: 'event_msg', payload: { type: 'task_complete' } }))
const second = new AgentHookServer()
await second.start({ env: 'production', userDataPath: dir })
try {
await new Promise((resolve) => setTimeout(resolve, 250))
expect(second.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
restoredUnconfirmed: true,
subagents: [expect.objectContaining({ id: CHILD_ID, state: 'working' })]
})
appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } }))
await vi.waitFor(() => expect(second.getStatusSnapshot()[0]?.state).toBe('done'), {
timeout: 5_500,
interval: 50
})
} finally {
second.stop()
}
})
it('reconciles a persisted child whose spawn record fell outside the transcript tail', async () => {
const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-restart-tail-'))
dirs.push(dir)
@@ -311,6 +311,41 @@ describe('AgentHookServer ingestRemote', () => {
)
expect(server.getStatusSnapshot()[0]?.state).toBe('done')
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
})
it('marks a nonterminal relay cache replay as unconfirmed until live evidence arrives', () => {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PostToolUse',
isReplay: true,
codexSubagentsAuthoritative: true,
codexAuthoritativeParentState: 'working',
payload: { state: 'working', prompt: '', agentType: 'codex' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
restoredUnconfirmed: true
})
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hookEventName: 'PostToolUse',
payload: { state: 'working', prompt: '', agentType: 'codex' }
},
'conn-1'
)
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
})
it('keeps transcript-proved children discovered from a cached SessionStart', () => {
+25 -11
View File
@@ -76,7 +76,10 @@ import {
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../shared/claude-subagent-roster'
import { codexRosterToSnapshots } from '../../shared/codex-subagent-roster'
import {
codexRosterEffectiveState,
codexRosterToSnapshots
} from '../../shared/codex-subagent-roster'
import { reconcileCodexSubagentTranscript } from '../../shared/codex-subagent-transcript'
import { seedCodexSubagentTranscriptFromSnapshot } from '../../shared/codex-subagent-transcript-seeding'
import {
@@ -1495,7 +1498,7 @@ export class AgentHookServer {
this.emitEnrichedStatus(enriched)
return enriched
}
const stateReconciledPayload =
let stateReconciledPayload: AgentHookEventPayload =
diagnosticAwarePayload.connectionId &&
diagnosticAwarePayload.payload.agentType === 'codex' &&
diagnosticAwarePayload.hookEventName
@@ -1516,6 +1519,13 @@ export class AgentHookServer {
)
}
: diagnosticAwarePayload
if (
stateReconciledPayload.connectionId &&
stateReconciledPayload.isReplay === true &&
stateReconciledPayload.payload.state !== 'done'
) {
stateReconciledPayload = { ...stateReconciledPayload, restoredUnconfirmed: true }
}
const previousCodexRoot =
stateReconciledPayload.payload.agentType === 'codex' &&
stateReconciledPayload.toolAgentId &&
@@ -1756,20 +1766,24 @@ export class AgentHookServer {
reconcileCodexSubagentTranscript(transcript, roster, transcriptPath)
this.codexRestartReconcileNextRunAt = Date.now() + CODEX_RESTART_RECONCILE_YIELD_MS
const subagents = codexRosterToSnapshots(roster)
const terminal = transcript.parentTerminalObserved === true
const reconciledParentState =
transcript.parentTerminalObserved === true
? ('done' as const)
: transcript.parentTerminalObserved === false
? current.payload.state === 'waiting'
? ('waiting' as const)
: ('working' as const)
: undefined
const reconciledState = reconciledParentState
? codexRosterEffectiveState(roster, reconciledParentState)
: current.payload.state
const terminal = reconciledState === 'done'
const transcriptUnreadable =
transcript.parentReadable === false ||
[...transcript.subagents.values()].some((child) => child.unresolvedSince)
const nextPayload = {
...current.payload,
...(terminal
? { state: 'done' as const }
: transcript.parentTerminalObserved === false
? {
state:
current.payload.state === 'waiting' ? ('waiting' as const) : ('working' as const)
}
: {}),
...(reconciledParentState ? { state: reconciledState } : {}),
...(subagents ? { subagents } : { subagents: undefined })
}
let latest = current
+11 -9
View File
@@ -5,7 +5,7 @@ import {
getOrCreateCodexSubagentTranscriptState,
seedCodexStateFromSnapshot
} from '../shared/agent-hook-listener/providers/codex-state'
import { codexRosterToSnapshots } from '../shared/codex-subagent-roster'
import { codexRosterEffectiveState, codexRosterToSnapshots } from '../shared/codex-subagent-roster'
import { reconcileCodexSubagentTranscript } from '../shared/codex-subagent-transcript'
import { seedCodexSubagentTranscriptFromSnapshot } from '../shared/codex-subagent-transcript-seeding'
import { createRelayCodexReconciler, type RelayHookStatusMeta } from './agent-hook-status-cache'
@@ -56,17 +56,19 @@ export function reconcileRelayCodexEvent(
const roster = getOrCreateCodexSubagentRoster(state, event.paneKey)
reconcileCodexSubagentTranscript(transcript, roster, transcriptPath)
const subagents = codexRosterToSnapshots(roster)
const reconciledParentState =
transcript.parentTerminalObserved === true
? ('done' as const)
: transcript.parentTerminalObserved === false
? event.payload.state === 'waiting'
? ('waiting' as const)
: ('working' as const)
: undefined
const payload = {
...event.payload,
...(subagents ? { subagents } : { subagents: undefined }),
...(options.reconcileParentState
? transcript.parentTerminalObserved === true
? { state: 'done' as const }
: transcript.parentTerminalObserved === false
? {
state: event.payload.state === 'waiting' ? ('waiting' as const) : ('working' as const)
}
: {}
...(options.reconcileParentState && reconciledParentState
? { state: codexRosterEffectiveState(roster, reconciledParentState) }
: {})
}
const codexAuthoritativeParentState =
+54
View File
@@ -134,6 +134,60 @@ describe('relay agent-hook status cache', () => {
}
})
it.each(['working', 'waiting'] as const)(
'keeps the aggregate %s when the parent completed before a child',
(childState) => {
const dir = mkdtempSync(join(tmpdir(), 'relay-hook-status-cache-active-child-'))
try {
const transcriptPath = join(dir, 'rollout-parent.jsonl')
const childId = '019fa65f-3144-7151-9c02-cff7a28f316f'
writeFileSync(
transcriptPath,
`${JSON.stringify({ type: 'event_msg', payload: { type: 'task_started' } })}\n${JSON.stringify(
{
type: 'event_msg',
payload: {
type: 'sub_agent_activity',
occurred_at_ms: 1234,
agent_thread_id: childId,
agent_path: '/root/active_child',
kind: 'started'
}
}
)}\n${JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete' } })}\n`
)
writeFileSync(
join(dir, `rollout-child-${childId}.jsonl`),
`${JSON.stringify({ type: 'event_msg', payload: { type: 'task_started' } })}\n`
)
const state = createHookListenerState()
const reconciled = reconcileRelayCodexEvent(
state,
{
...event('PostToolUse'),
providerSession: { key: 'session_id', id: 'session-1', transcriptPath },
payload: {
state: childState,
prompt: 'prompt',
agentType: 'codex',
subagents: [{ id: childId, state: childState, startedAt: 1234 }]
}
},
{ reconcileParentState: true }
)
expect(reconciled.codexAuthoritativeParentState).toBe('done')
expect(reconciled.payload.state).toBe(childState)
expect(reconciled.payload.subagents).toEqual([
expect.objectContaining({ id: childId, state: childState })
])
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
)
it('does not claim an authoritative roster when an initial read skips old parent history', () => {
const dir = mkdtempSync(join(tmpdir(), 'relay-hook-status-cache-long-parent-'))
try {