From ef49c94f0100bbd40a416e3bbd7e9fbdaa8271c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:21:59 -0700 Subject: [PATCH] fix(agent-hooks): preserve uncertain Codex child activity --- .../server-codex-subagent-transcript.test.ts | 57 +++++++++++++++++++ .../agent-hooks/server-ingest-remote.test.ts | 35 ++++++++++++ src/main/agent-hooks/server.ts | 36 ++++++++---- src/relay/agent-hook-codex-reconciliation.ts | 20 ++++--- src/relay/agent-hook-status-cache.test.ts | 54 ++++++++++++++++++ 5 files changed, 182 insertions(+), 20 deletions(-) diff --git a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts index 84bfeb5b8e3..8c7c1cfe4cb 100644 --- a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -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) diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index dba8e719f46..29a7030cf8e 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -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', () => { diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index cf9dcfc1bcf..d8bfc007e80 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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 diff --git a/src/relay/agent-hook-codex-reconciliation.ts b/src/relay/agent-hook-codex-reconciliation.ts index 2b42fe0e826..dd038859612 100644 --- a/src/relay/agent-hook-codex-reconciliation.ts +++ b/src/relay/agent-hook-codex-reconciliation.ts @@ -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 = diff --git a/src/relay/agent-hook-status-cache.test.ts b/src/relay/agent-hook-status-cache.test.ts index 6ae0c014169..304b047ba71 100644 --- a/src/relay/agent-hook-status-cache.test.ts +++ b/src/relay/agent-hook-status-cache.test.ts @@ -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 {