fix(agent-status): preserve latest Codex parent lifecycle

This commit is contained in:
Brennan Benson
2026-08-27 08:20:33 -07:00
parent acc7d1cbe0
commit 2d55df8d24
8 changed files with 77 additions and 37 deletions
@@ -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<void> => {
const env = server.buildPtyEnv()
+5 -1
View File
@@ -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
-11
View File
@@ -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 }
+11 -3
View File
@@ -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<string, unknown> }
@@ -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' }
+37 -1
View File
@@ -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<string, { source: 'codex' }>()
+5 -1
View File
@@ -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 }
@@ -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) =>
+14 -19
View File
@@ -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) {