mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* fix(native-chat): offer restart resume for chats whose only work was background The quit started the chat teardown and then stopped the hook server, which emptied the status store holding each chat's subagents and background commands. The teardown reads that store to decide which chats were still working, so a chat whose turn had finished but whose background command or subagent was still running was never offered to resume after a restart or update. The quit now stops hook ingress without clearing the store. * test: preserve background-only chat resume across shutdown
268 lines
9.7 KiB
TypeScript
268 lines
9.7 KiB
TypeScript
// The production runtime hands a Codex session's child work to the status sink, under the address
|
|
// the session's own row landed under, and ends it with the provider.
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
CodexAppServerConnection,
|
|
CodexAppServerConnectionHandlers,
|
|
openCodexAppServerConnection
|
|
} from '../codex/codex-app-server-connection'
|
|
import {
|
|
HOST_TEST_SESSION as SESSION,
|
|
hostTestAttachParams
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
|
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
|
import {
|
|
ensureStructuredAgentSessionHost,
|
|
stopStructuredAgentSessionRuntime
|
|
} from './structured-agent-session-runtime'
|
|
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import { AgentHookServer } from '../agent-hooks/server'
|
|
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
|
|
import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule'
|
|
import {
|
|
openStructuredCodexRpcHarness,
|
|
THREAD as RESTART_THREAD,
|
|
type StructuredCodexRpcHarness
|
|
} from './structured-codex-session-rpc-test-harness'
|
|
|
|
const THREAD = 'thread-runtime-child-work'
|
|
const CHILD = 'thread-runtime-reviewer'
|
|
const ROUTES: Record<string, unknown> = {
|
|
'thread/start': { thread: { id: THREAD } },
|
|
'model/list': {
|
|
data: [
|
|
{
|
|
model: 'gpt-test',
|
|
displayName: 'GPT Test',
|
|
hidden: false,
|
|
supportedReasoningEfforts: [],
|
|
defaultReasoningEffort: null,
|
|
isDefault: true
|
|
}
|
|
],
|
|
nextCursor: null
|
|
}
|
|
}
|
|
|
|
describe('structured Codex child work through the production runtime', () => {
|
|
let root: string | null = null
|
|
|
|
afterEach(async () => {
|
|
await stopStructuredAgentSessionRuntime()
|
|
if (root) {
|
|
await rm(root, { recursive: true, force: true })
|
|
root = null
|
|
}
|
|
})
|
|
|
|
it("hands its subagents to the status sink under the session's own address", async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'orca-runtime-codex-child-work-'))
|
|
const connections: CodexAppServerConnectionHandlers[] = []
|
|
const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
|
|
connections.push(handlers)
|
|
const connection: CodexAppServerConnection = {
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method) => (method in ROUTES ? ROUTES[method] : {}),
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => true
|
|
}
|
|
return connection
|
|
}
|
|
const childWork: Parameters<
|
|
NonNullable<StructuredAgentSessionStatusSink['publishChildWork']>
|
|
>[] = []
|
|
const host = await ensureStructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
stateDirectory: root,
|
|
hostId: 'local',
|
|
claimKeyId: 'key-1',
|
|
resolveWorkspacePath: async () => root!,
|
|
resolveLaunchArgs: () => [],
|
|
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
|
|
resolveCodexCommand: () => 'codex',
|
|
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
|
openCodexConnection: openConnection,
|
|
readProcessStartTime: async () => 1_700_000_000_000,
|
|
statusSink: {
|
|
publish: () => {},
|
|
forget: () => {},
|
|
publishChildWork: (...args) => childWork.push(args)
|
|
}
|
|
})
|
|
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
|
attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}`
|
|
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
|
|
expect(attached).toMatchObject({ ok: true })
|
|
// Creating the session starts its child; nothing else has to keep it running.
|
|
expect(connections).toHaveLength(1)
|
|
const notify = (method: string, params: Record<string, unknown>) =>
|
|
connections[0]?.onNotification?.(method, params)
|
|
notify('turn/started', { threadId: THREAD, turn: { id: 'turn-1' } })
|
|
notify('turn/started', { threadId: CHILD, turn: { id: 'child-turn-1' } })
|
|
notify('item/started', {
|
|
threadId: THREAD,
|
|
turnId: 'turn-1',
|
|
item: {
|
|
type: 'subAgentActivity',
|
|
id: 'spawn-1',
|
|
kind: 'started',
|
|
agentThreadId: CHILD,
|
|
agentPath: '/root/review'
|
|
}
|
|
})
|
|
const subject = expect.objectContaining({ kind: 'structured-session', sessionId: SESSION })
|
|
expect(childWork).toEqual([
|
|
[
|
|
subject,
|
|
[
|
|
expect.objectContaining({
|
|
type: 'live',
|
|
child: expect.objectContaining({
|
|
handle: { idKind: 'thread_id', id: CHILD, runId: 'child-turn-1' },
|
|
description: 'review'
|
|
})
|
|
})
|
|
],
|
|
'codex'
|
|
]
|
|
])
|
|
// The provider dies: its session's end is reported under the same address.
|
|
connections[0]?.onExit?.(new Error('scripted provider exit'))
|
|
expect(childWork.at(-1)).toEqual([
|
|
subject,
|
|
[expect.objectContaining({ type: 'session-ended' })],
|
|
'codex'
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('Codex background-only resume through hook shutdown', () => {
|
|
let harness: StructuredCodexRpcHarness | null = null
|
|
let server: AgentHookServer | null = null
|
|
|
|
afterEach(async () => {
|
|
await harness?.dispose()
|
|
harness = null
|
|
server?.stop()
|
|
server = null
|
|
})
|
|
|
|
it.each([
|
|
{ trigger: 'quit', kind: 'agent', settled: false },
|
|
{ trigger: 'update', kind: 'agent', settled: false },
|
|
{ trigger: 'quit', kind: 'command', settled: false },
|
|
{ trigger: 'update', kind: 'command', settled: false },
|
|
{ trigger: 'update', kind: 'command', settled: true }
|
|
] as const)(
|
|
'$trigger with a background $kind (settled: $settled)',
|
|
async ({ trigger, kind, settled }) => {
|
|
const runtime = await openStructuredCodexRpcHarness()
|
|
harness = runtime
|
|
const store = new AgentHookServer()
|
|
server = store
|
|
const config = {
|
|
...runtime.hostConfig(),
|
|
resolveWorkspacePath: async () => runtime.root,
|
|
statusSink: {
|
|
publish: (summary, subject) => store.ingestStructuredStatus(summary, subject),
|
|
forget: (subject) => store.dropStructuredStatus(subject),
|
|
publishChildWork: (subject, evidence, provider) =>
|
|
store.ingestStructuredChildWork(subject, evidence, provider),
|
|
readChildWork: (subject) => store.getStructuredChildWorkViews(subject)
|
|
} satisfies StructuredAgentSessionStatusSink
|
|
}
|
|
const host = await ensureStructuredAgentSessionHost(config)
|
|
const params = hostTestAttachParams(null, {
|
|
providerHandle: undefined,
|
|
location: {
|
|
executionHostId: 'local',
|
|
wslDistro: null,
|
|
workspaceId: 'workspace-1',
|
|
workspaceKind: 'folder'
|
|
}
|
|
})
|
|
params.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}`
|
|
expect(await host.attach({ callerKey: 'restart-test' }, params)).toMatchObject({ ok: true })
|
|
const notify = runtime.codex.notify
|
|
notify('turn/started', { threadId: RESTART_THREAD, turn: { id: 'lead-turn' } })
|
|
if (kind === 'agent') {
|
|
notify('turn/started', { threadId: CHILD, turn: { id: 'child-turn' } })
|
|
notify('item/started', {
|
|
threadId: RESTART_THREAD,
|
|
turnId: 'lead-turn',
|
|
item: {
|
|
type: 'subAgentActivity',
|
|
id: 'spawn-1',
|
|
kind: 'started',
|
|
agentThreadId: CHILD,
|
|
agentPath: '/root/review'
|
|
}
|
|
})
|
|
} else {
|
|
const command = {
|
|
type: 'commandExecution',
|
|
id: 'command-1',
|
|
command: 'npm run build',
|
|
source: 'unifiedExecStartup'
|
|
}
|
|
notify('item/started', {
|
|
threadId: RESTART_THREAD,
|
|
turnId: 'lead-turn',
|
|
item: { ...command, status: 'inProgress' }
|
|
})
|
|
if (settled) {
|
|
notify('item/completed', {
|
|
threadId: RESTART_THREAD,
|
|
turnId: 'lead-turn',
|
|
item: { ...command, status: 'completed', exitCode: 0 }
|
|
})
|
|
}
|
|
}
|
|
notify('turn/completed', {
|
|
threadId: RESTART_THREAD,
|
|
turn: { id: 'lead-turn', status: 'completed' }
|
|
})
|
|
await host.flushStreamedEvents(SESSION)
|
|
await vi.waitFor(() =>
|
|
expect(store.getStatusSnapshot().find((entry) => entry.structuredHost)).toMatchObject({
|
|
state: settled ? 'done' : 'working',
|
|
mainAgent: { state: 'done' }
|
|
})
|
|
)
|
|
// The real quit starts async chat teardown, then synchronously stops hook ingress.
|
|
const stopping = stopStructuredAgentSessionRuntime({ trigger })
|
|
store.stop({ keepStatus: true })
|
|
await stopping
|
|
expect(
|
|
store
|
|
.getStructuredChildWorkViews(makeStructuredAgentStatusSubject(params.location, SESSION))
|
|
.filter((child) => child.state !== 'done')
|
|
).toEqual([])
|
|
const activity = {
|
|
state: 'done',
|
|
prompts: [],
|
|
tasks: [{ kind, label: kind === 'agent' ? 'review' : 'npm run build' }]
|
|
}
|
|
const markers = await new AgentSessionRecoveryCapsule(runtime.root).list(Date.now())
|
|
const restarted = await ensureStructuredAgentSessionHost(config)
|
|
const candidates = await restarted.restartResume.list()
|
|
if (settled) {
|
|
expect(markers).toEqual([])
|
|
expect(candidates).toEqual([])
|
|
} else {
|
|
expect(markers).toMatchObject([{ sessionId: SESSION, trigger, activity }])
|
|
expect(candidates).toMatchObject([
|
|
{ sessionId: SESSION, agent: 'codex', workspaceKind: 'folder', trigger, activity }
|
|
])
|
|
}
|
|
}
|
|
)
|
|
})
|