mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(codex): preserve nested agent state on restart
This commit is contained in:
@@ -6434,6 +6434,26 @@ describe('Last-status persistence', () => {
|
||||
})
|
||||
])
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({
|
||||
hook_event_name: 'PreToolUse',
|
||||
agent_id: '11111111-2222-4333-8444-555555555555',
|
||||
agent_type: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
tool_name: 'exec_command',
|
||||
tool_input: { cmd: 'pnpm test' }
|
||||
}),
|
||||
'/hook/codex'
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
model: 'gpt-5.4',
|
||||
subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })]
|
||||
})
|
||||
])
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }),
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
resolveHookSource,
|
||||
preparePendingGrokResultDiscovery,
|
||||
seedClaudeSubagentRosterFromSnapshots,
|
||||
seedCodexSubagentRosterFromSnapshots,
|
||||
seedCodexStateFromSnapshot,
|
||||
warnOnHookEnvOrVersionMismatch,
|
||||
writeEndpointFile,
|
||||
type AgentHookEventPayload,
|
||||
@@ -1830,20 +1830,14 @@ export class AgentHookServer {
|
||||
)
|
||||
}
|
||||
// Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds.
|
||||
if (entry.payload.subagents) {
|
||||
if (entry.payload.agentType === 'codex') {
|
||||
seedCodexSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
resolvedPaneKey,
|
||||
entry.payload.subagents
|
||||
)
|
||||
} else if (entry.payload.agentType === 'claude') {
|
||||
seedClaudeSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
resolvedPaneKey,
|
||||
entry.payload.subagents
|
||||
)
|
||||
}
|
||||
if (entry.payload.agentType === 'codex') {
|
||||
seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload)
|
||||
} else if (entry.payload.agentType === 'claude' && entry.payload.subagents) {
|
||||
seedClaudeSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
resolvedPaneKey,
|
||||
entry.payload.subagents
|
||||
)
|
||||
}
|
||||
hydrated += 1
|
||||
} else {
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
|
||||
import {
|
||||
AGENT_MODEL_MAX_LENGTH,
|
||||
normalizeAgentStatusPayload,
|
||||
parseAgentStatusPayload,
|
||||
type AgentStatusState,
|
||||
type AgentSubagentSnapshot,
|
||||
type ParsedAgentStatusPayload
|
||||
} from './agent-status-types'
|
||||
import { normalizeOptionalField } from './agent-status-field-normalization'
|
||||
import { isAskUserQuestionTool } from './agent-question-answered-intent'
|
||||
import {
|
||||
claudeRosterHasWorkingSubagent,
|
||||
@@ -3037,15 +3039,29 @@ function getOrCreateCodexSubagentRoster(
|
||||
return roster
|
||||
}
|
||||
|
||||
export function seedCodexSubagentRosterFromSnapshots(
|
||||
export function seedCodexStateFromSnapshot(
|
||||
state: HookListenerState,
|
||||
paneKey: string,
|
||||
snapshots: readonly AgentSubagentSnapshot[]
|
||||
payload: Pick<ParsedAgentStatusPayload, 'model' | 'state' | 'subagents'>
|
||||
): void {
|
||||
if (snapshots.length === 0 || state.codexSubagentRosterByPaneKey.has(paneKey)) {
|
||||
return
|
||||
const snapshots = payload.subagents ?? []
|
||||
if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) {
|
||||
seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots)
|
||||
}
|
||||
if (!state.codexLeadStateByPaneKey.has(paneKey)) {
|
||||
// Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache.
|
||||
state.codexLeadStateByPaneKey.set(paneKey, {
|
||||
// Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting.
|
||||
state:
|
||||
payload.state === 'done'
|
||||
? 'done'
|
||||
: payload.state === 'waiting' &&
|
||||
!snapshots.some((snapshot) => snapshot.state === 'waiting')
|
||||
? 'waiting'
|
||||
: 'working',
|
||||
model: payload.model
|
||||
})
|
||||
}
|
||||
seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots)
|
||||
}
|
||||
|
||||
function buildCodexStatusPayload(
|
||||
@@ -3175,7 +3191,7 @@ function normalizeCodexEvent(
|
||||
state.codexLeadStateByPaneKey.set(paneKey, {
|
||||
state: stateName,
|
||||
model:
|
||||
readString(hookPayload, 'model') ??
|
||||
normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ??
|
||||
(eventName === 'SessionStart' ? undefined : previousLead?.model)
|
||||
})
|
||||
const effectiveState = codexRosterEffectiveState(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AGENT_MODEL_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH } from './agent-status-types'
|
||||
import {
|
||||
codexRosterToSnapshots,
|
||||
finishCodexSubagent,
|
||||
upsertCodexSubagent,
|
||||
type CodexSubagentRoster
|
||||
} from './codex-subagent-roster'
|
||||
|
||||
describe('Codex subagent roster', () => {
|
||||
it('normalizes retained identity fields before storing them', () => {
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
|
||||
upsertCodexSubagent(
|
||||
roster,
|
||||
' child-1 ',
|
||||
{
|
||||
agentType: `reviewer\n${'x'.repeat(AGENT_TYPE_MAX_LENGTH * 2)}`,
|
||||
model: `gpt-model-${'x'.repeat(AGENT_MODEL_MAX_LENGTH * 2)}`,
|
||||
state: 'working'
|
||||
},
|
||||
10
|
||||
)
|
||||
|
||||
const snapshot = codexRosterToSnapshots(roster)?.[0]
|
||||
expect([...roster.keys()]).toEqual(['child-1'])
|
||||
expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH)
|
||||
expect(snapshot?.agentType).not.toContain('\n')
|
||||
expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH)
|
||||
|
||||
finishCodexSubagent(roster, ' child-1 ')
|
||||
expect(roster.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an id that would normalize to an invisible child', () => {
|
||||
const roster: CodexSubagentRoster = new Map()
|
||||
|
||||
upsertCodexSubagent(roster, ' ', { state: 'waiting' }, 10)
|
||||
|
||||
expect(roster.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,10 @@
|
||||
import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent-status-types'
|
||||
import {
|
||||
AGENT_MODEL_MAX_LENGTH,
|
||||
AGENT_STATUS_MAX_SUBAGENTS,
|
||||
AGENT_TYPE_MAX_LENGTH,
|
||||
type AgentSubagentSnapshot
|
||||
} from './agent-status-types'
|
||||
import { normalizeOptionalField } from './agent-status-field-normalization'
|
||||
|
||||
const CODEX_SUBAGENT_ID_MAX_LENGTH = 64
|
||||
|
||||
@@ -21,29 +27,32 @@ export function upsertCodexSubagent(
|
||||
},
|
||||
now: number
|
||||
): void {
|
||||
if (id.length === 0 || id.length > CODEX_SUBAGENT_ID_MAX_LENGTH) {
|
||||
const normalizedId = id.trim()
|
||||
if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) {
|
||||
return
|
||||
}
|
||||
const existing = roster.get(id)
|
||||
const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH)
|
||||
const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH)
|
||||
const existing = roster.get(normalizedId)
|
||||
if (existing) {
|
||||
existing.agentType = fields.agentType ?? existing.agentType
|
||||
existing.model = fields.model ?? existing.model
|
||||
existing.agentType = agentType ?? existing.agentType
|
||||
existing.model = model ?? existing.model
|
||||
existing.state = fields.state
|
||||
return
|
||||
}
|
||||
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) {
|
||||
return
|
||||
}
|
||||
roster.set(id, {
|
||||
agentType: fields.agentType,
|
||||
model: fields.model,
|
||||
roster.set(normalizedId, {
|
||||
agentType,
|
||||
model,
|
||||
state: fields.state,
|
||||
startedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void {
|
||||
roster.delete(id)
|
||||
roster.delete(id.trim())
|
||||
}
|
||||
|
||||
export function seedCodexSubagentRoster(
|
||||
@@ -54,15 +63,12 @@ export function seedCodexSubagentRoster(
|
||||
if (snapshot.state !== 'working' && snapshot.state !== 'waiting') {
|
||||
continue
|
||||
}
|
||||
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) {
|
||||
return
|
||||
}
|
||||
roster.set(snapshot.id, {
|
||||
agentType: snapshot.agentType,
|
||||
model: snapshot.model,
|
||||
state: snapshot.state,
|
||||
startedAt: snapshot.startedAt
|
||||
})
|
||||
upsertCodexSubagent(
|
||||
roster,
|
||||
snapshot.id,
|
||||
{ agentType: snapshot.agentType, model: snapshot.model, state: snapshot.state },
|
||||
snapshot.startedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user