mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(agent-hooks): preserve uncertain Codex child activity
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user