From 2d55df8d24d89773bad91b7f3da5e0839d73fb4b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:20:33 -0700 Subject: [PATCH] fix(agent-status): preserve latest Codex parent lifecycle --- .../server-codex-subagent-transcript.test.ts | 5 ++- src/main/agent-hooks/server.ts | 6 ++- src/relay/agent-hook-server-types.ts | 11 ------ src/relay/agent-hook-server.test.ts | 14 +++++-- src/relay/agent-hook-status-cache.test.ts | 38 ++++++++++++++++++- src/relay/agent-hook-status-cache.ts | 6 ++- ...ent-hook-listener-relay-dependency.test.ts | 1 + src/shared/codex-subagent-transcript.ts | 33 +++++++--------- 8 files changed, 77 insertions(+), 37 deletions(-) delete mode 100644 src/relay/agent-hook-server-types.ts 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 c1bafb24f00..728d8b0ad77 100644 --- a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -210,7 +210,10 @@ describe('AgentHookServer Codex subagent transcript polling', () => { dirs.push(dir) const parentPath = join(dir, 'rollout-parent.jsonl') const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) - writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/restart_repro')) + writeFileSync( + parentPath, + `${line({ type: 'event_msg', payload: { type: 'task_started' } })}${line({ type: 'event_msg', payload: { type: 'task_complete' } })}${line({ type: 'event_msg', payload: { type: 'task_started' } })}${spawnLine(CHILD_ID, '/root/restart_repro')}` + ) writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) const post = async (server: AgentHookServer): Promise => { const env = server.buildPtyEnv() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 8a993b6d153..d0dbd9ebf9b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1726,7 +1726,11 @@ export class AgentHookServer { const terminal = transcript.parentTerminalObserved === true const nextPayload = { ...current.payload, - ...(terminal ? { state: 'done' as const } : {}), + ...(terminal + ? { state: 'done' as const } + : transcript.parentTerminalObserved === false + ? { state: 'working' as const } + : {}), ...(subagents ? { subagents } : { subagents: undefined }) } let latest = current diff --git a/src/relay/agent-hook-server-types.ts b/src/relay/agent-hook-server-types.ts deleted file mode 100644 index 636ed0555b3..00000000000 --- a/src/relay/agent-hook-server-types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' - -export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void -export type RelayHookServerOptions = { - endpointDir?: string - env?: string - token?: string - preferredPort?: number - forward: RelayHookForward -} -export type RelayHookServerStartOptions = { publishEndpoint?: boolean } diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 0a2be085a68..9a3c7564ebb 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -11,6 +11,11 @@ import * as agentHookListener from '../shared/agent-hook-listener/grok-result-di const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +const MALFORMED_LEAF_IDS = [ + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444' +] as const type RelayServerInternals = { state: { lastStatusByPaneKey: Map } @@ -372,17 +377,20 @@ describe('RelayAgentHookServer', () => { JSON.stringify({ version: 1, entries: [ - { event: { paneKey: PANE_KEY }, meta: { source: 'claude' } }, + { + event: { paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[0]) }, + meta: { source: 'claude' } + }, { event: { - paneKey: PANE_KEY, + paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[1]), payload: { state: 'working', prompt: 'missing agent type' } }, meta: { source: 'claude' } }, { event: { - paneKey: PANE_KEY, + paneKey: makePaneKey('tab-1', MALFORMED_LEAF_IDS[2]), payload: null }, meta: { source: 'claude' } diff --git a/src/relay/agent-hook-status-cache.test.ts b/src/relay/agent-hook-status-cache.test.ts index b97813ac36f..10768ba66c8 100644 --- a/src/relay/agent-hook-status-cache.test.ts +++ b/src/relay/agent-hook-status-cache.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { createHookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' -import { applyRelayHookEvent } from './agent-hook-status-cache' +import { applyRelayHookEvent, reconcileRelayCodexEvent } from './agent-hook-status-cache' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' @@ -22,6 +25,39 @@ function event( } describe('relay agent-hook status cache', () => { + it('restores working when a newer parent turn follows a historical completion', () => { + const dir = mkdtempSync(join(tmpdir(), 'relay-hook-status-cache-')) + try { + const transcriptPath = join(dir, 'rollout-parent.jsonl') + writeFileSync( + transcriptPath, + `${JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete' } })}\n${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: 'done', + prompt: 'prompt', + agentType: 'codex', + subagents: [ + { + id: '019fa65f-3144-7151-9c02-cff7a28f316f', + state: 'working', + startedAt: 1234 + } + ] + } + }) + + expect(reconciled.payload.state).toBe('working') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('clears an inherited Codex reconciliation diagnostic at SessionStart', () => { const state = createHookListenerState() const metadata = new Map() diff --git a/src/relay/agent-hook-status-cache.ts b/src/relay/agent-hook-status-cache.ts index 9d8d682c305..de1ba74ab35 100644 --- a/src/relay/agent-hook-status-cache.ts +++ b/src/relay/agent-hook-status-cache.ts @@ -149,7 +149,11 @@ export function reconcileRelayCodexEvent( const payload = { ...event.payload, ...(subagents ? { subagents } : { subagents: undefined }), - ...(transcript.parentTerminalObserved ? { state: 'done' as const } : {}) + ...(transcript.parentTerminalObserved === true + ? { state: 'done' as const } + : transcript.parentTerminalObserved === false + ? { state: 'working' as const } + : {}) } return [...transcript.subagents.values()].some((child) => child.unresolvedSince) ? { ...event, payload } diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index e05ef801f7f..46a5d184336 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -78,6 +78,7 @@ describe('agent hook listener relay dependency boundary', () => { const listenerPathPrefix = resolve(sharedRoot, 'agent-hook-listener') const relayConsumers = [ resolve(sharedRoot, '../relay/agent-hook-server.ts'), + resolve(sharedRoot, '../relay/agent-hook-request.ts'), resolve(sharedRoot, '../relay/agent-hook-result-retry-scheduler.ts') ] const pending = relayConsumers.flatMap((consumer) => diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts index 040d3764439..80b81904f15 100644 --- a/src/shared/codex-subagent-transcript.ts +++ b/src/shared/codex-subagent-transcript.ts @@ -23,9 +23,7 @@ type JsonlCursor = { type TrackedTranscriptSubagent = JsonlCursor & { description?: string - /** Latest model seen in the child's own rollout. Retained across polls - * because the cursor is incremental: `turn_context` is emitted once per - * turn, so a later read usually carries no model at all. */ + /** Retained because incremental reads usually omit the earlier `turn_context`. */ model?: string startedAt: number unresolvedSince?: number @@ -203,9 +201,7 @@ function readActivity(recordValue: JsonRecord): } } -/** Latest model from the child's own `turn_context` records. A child can be - * launched on a different model than its parent, so this is read from the - * child rollout rather than inherited. */ +/** Read from the child's rollout because its model can differ from the parent's. */ function readChildModel(records: JsonRecord[]): string | undefined { let model: string | undefined for (const recordValue of records) { @@ -222,19 +218,15 @@ function readChildModel(records: JsonRecord[]): string | undefined { } function childIsComplete(records: JsonRecord[]): boolean { - let complete = false + let lifecycle: unknown for (const recordValue of records) { - if (recordValue.type !== 'event_msg') { - continue - } - const payload = record(recordValue.payload) - if (payload?.type === 'task_started') { - complete = false - } else if (payload?.type === 'task_complete') { - complete = true + const eventType = + recordValue.type === 'event_msg' ? record(recordValue.payload)?.type : undefined + if (eventType === 'task_started' || eventType === 'task_complete') { + lifecycle = eventType } } - return complete + return lifecycle === 'task_complete' } export function createCodexSubagentTranscriptState(): CodexSubagentTranscriptState { @@ -265,11 +257,14 @@ export function reconcileCodexSubagentTranscript( } state.parent = { filePath: normalizedPath, offset: 0, carry: '' } state.subagents.clear() - state.parentTerminalObserved = false + state.parentTerminalObserved = undefined } for (const recordValue of readJsonlCursor(state.parent) ?? []) { - if (recordValue.type === 'event_msg' && record(recordValue.payload)?.type === 'task_complete') { - state.parentTerminalObserved = true + if (recordValue.type === 'event_msg') { + const eventType = record(recordValue.payload)?.type + if (eventType === 'task_started' || eventType === 'task_complete') { + state.parentTerminalObserved = eventType === 'task_complete' + } } const activity = readActivity(recordValue) if (!activity) {