Files
orca/src/main/runtime/structured-agent-session-runtime-exit.test.ts
T
Brennan BensonandMerge Sim 955051ded0 fix(codex): settle a structured send on admission, and stop minting a colliding identity (#20138)
* fix(codex): settle a structured send on admission, and stop minting a colliding identity

Two sends could be written into the journal under one durable identity.

Codex coalesces a mid-turn `turn/start` into the running turn rather than
refusing it -- measured against real `codex app-server` builds 0.147.0,
0.150.1 and 0.153.4, none of which refuse and none of which fire a second
`turn/started`. The dispatch path read the turn id from the turn/start
response and stamped every accepted send `ordinal: 0`. Since a coalesced
send gets the running turn's id back, two submissions persisted the same
`providerItemId`. That string is durable, and it is the key a restore uses
to match a submission against provider history, so the second message's real
history row matched nothing and rendered as an extra bubble on replay.

On 0.147.0 it is worse than a collision: the coalesced response returns a
turn id that never starts and never completes, so the persisted key named a
turn absent from history and NEITHER message could match.

Identity is now minted from the echoed user message at `identityFor` -- the
single point that mints the journal row's own identity -- so the settled key
is by construction the one replay computes, rather than a parallel
calculation that can drift.

Dispatch returns `admitted` when the transport write completes; identity
settles on the echo through a channel that did not previously exist for
Codex. Waiters are keyed by client message id instead of being shifted off
the front of an array by arrival order, and they are cleared on session
close and child exit -- previously a timeout was the only thing that ever
ended one.

`TURN_ID_WAIT_MS` is deleted. It was never reachable on any build measured:
`readCodexTurnId` returns non-null on all three, so the 10s wait never
fired. The comment justifying it claimed older builds acknowledge before the
id exists, which no tested build does.

Three comments asserting Codex answers a mid-turn send with `turn already
running` are corrected. Their only backing was a test fixture inventing that
error string. The correction is factual only -- every changed line in
`src/main/runtime/orchestration/` is a comment, and mid-turn delivery is
still refused for both providers. Whether that policy is right is a separate
question; it was resting on a false premise.

Known gap, stated rather than implied: this prevents new collisions and does
not repair journals already written with a colliding or phantom key. Those
conversations keep duplicating on restore. Repairing them means re-matching
persisted submissions against provider history and rewriting
`providerItemId` -- which is what `journal-submission-reconciler.ts` is
written for, and it still has no production caller.

* test(codex): drop the synchronous-accept contract and the colliding `:0` from the integration fakes

Three tests in the structured-session integration suites encoded the dispatch
contract this branch replaces, and two of them pinned the defect it fixes.

They asserted `agentSession.send` answers `dispatchState: 'accepted'` carrying
`providerItemId: codex:<thread>:<turn>:0` at send time. That ordinal was never
observed; it was stamped on every accepted send, which is exactly the collision
this branch removes -- a send coalesced into a running turn is answered with the
running turn's id, so two submissions persisted one durable key.

The visible failure was a 30s timeout rather than a failed assertion. The fake
client advertised no `agent-session.pending-send-result.v1`, and without it the
host holds the reply until the send settles: a shim for clients too old to
render a pending bubble. The fake provider then echoed the user message with no
`clientId`, so nothing could correlate that echo back to the submission, and the
wait ran to its own 30s ceiling. Real Codex sends `clientId` on that echo, and
the fake now does too, which is what makes it a model of the provider rather
than a sketch of one.

The identity assertion is kept rather than dropped. Each send now asserts
`pending` with no identity at admission, then asserts the submission settles
`accepted` at `codex:<thread>:<turn>:0` once the echo lands. Same ordinal, but
earned from `identityFor` on the echo -- the key a replay recomputes -- instead
of guessed from the turn/start response. Ablated: removing `clientId` from the
two echoes leaves both submissions `pending` and fails both assertions, so the
assertion is load-bearing and not satisfied by something incidental.

Both suites' client fixtures now advertise the capability set the desktop
renderer sends in `src/main/ipc/runtime.ts`, which is what these suites mean by
a client. The older-client settlement wait keeps its own coverage in
`src/main/runtime/rpc/methods/structured-agent-session.test.ts`.

`structured-agent-session-runtime-exit.test.ts` asserts `pending` for the same
reason; it drives the host directly, so it never took the compatibility path,
and what proves delivery there is still the turn the reacquired provider starts.

The replay suite's "without dispatching it twice" property is untouched: one
`turn/start` call, one replayed ledger row.

* fix(codex): preserve unsettled dispatch correlations

* test(codex): type the dispatch fixtures instead of asserting over them

main's new casting gate (#20367 base) flags type assertions on changed
lines. Replace them with checked types: the recording sink already
satisfies its interface, both CodexSession fixtures are now annotated and
carry real collaborators, the settlement assertion compares whole
identities, and the integration helper reads submissions through the
host's public journalSnapshot instead of its private session map.

* fix(test): merge the duplicate doubt-reasons import the merge left behind

Both sides added an import from journal-dispatch-doubt-reasons and the
merge kept both statements, which the whole-repo native plugin gate
refuses under --deny-warnings.

* test(codex): a Fast mode turn is admitted, not accepted

#20506 landed its Fast mode tests against the dispatch contract this
branch replaces: a Codex send now returns admitted and settles its
identity on the provider echo. The tier assertions the test exists for
are untouched.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 13:37:13 -07:00

411 lines
15 KiB
TypeScript

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 { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import {
HOST_TEST_SESSION as SESSION,
hostTestAttachParams,
hostTestMessage
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime
} from './structured-agent-session-runtime'
describe('structured session runtime provider-exit wiring', () => {
let root: string | null = null
let operations = 0
const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`
afterEach(async () => {
await stopStructuredAgentSessionRuntime()
if (root) {
await rm(root, { recursive: true, force: true })
root = null
}
})
it('reacquires through the production callback and accepts a distinct next message', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-runtime-provider-exit-'))
operations = 0
const connections: {
connection: CodexAppServerConnection
handlers: CodexAppServerConnectionHandlers
}[] = []
let turn = 0
const openConnection = (async (_launch, handlers = {}) => {
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method, params) => {
if (method === 'thread/start') {
return { thread: { id: 'thread-runtime-exit' } }
}
if (method === 'thread/resume') {
return { thread: { id: (params as { threadId: string }).threadId } }
}
if (method === 'turn/start') {
return { turn: { id: `turn-${++turn}` } }
}
if (method === 'model/list') {
return {
data: [
{
model: 'gpt-test',
displayName: 'GPT Test',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
isDefault: true
}
],
nextCursor: null
}
}
return {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
}
connections.push({ connection, handlers })
return connection
}) as typeof openCodexAppServerConnection
const host = await ensureStructuredAgentSessionHost({
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root!,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
openCodexConnection: openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
attachParams.envelope.clientOperationId = operationId()
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
if (!attached.ok) {
throw new Error(
JSON.stringify({ refusal: attached.refusal, connections: connections.length })
)
}
await host.hold(SESSION, 'desktop-chat:1')
const exitedFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0
const exited = connections[0]
exited?.handlers.onExit?.(new Error('scripted provider exit'))
await vi.waitFor(() => expect(connections).toHaveLength(2))
const recoveredFence = host.deps.store.getRecord(SESSION)?.lease.runtimeFence
expect(recoveredFence).toBeGreaterThan(exitedFence)
if (recoveredFence === undefined) {
throw new Error('recovered lease omitted its fence')
}
const body = hostTestMessage('continue with a distinct message')
const envelope = {
sessionId: SESSION,
clientOperationId: operationId(),
expectedRuntimeFence: recoveredFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: SESSION,
fields: { body }
})
}
// `pending` is this send's real answer now, not a weaker one: admission settles
// when the transport takes the frame, and identity arrives later on the
// provider's echo. What proves the message reached the REACQUIRED provider is
// the turn it starts below, which is what this test exists to check.
await expect(
host.send({ callerKey: 'runtime-test' }, { envelope, body })
).resolves.toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } })
expect(turn).toBe(1)
})
it('does not reacquire when the production exit callback comes from a requested close', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-runtime-requested-close-'))
operations = 0
const connections: {
connection: CodexAppServerConnection
handlers: CodexAppServerConnectionHandlers
}[] = []
const openConnection = (async (_launch, handlers = {}) => {
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method, params) => {
if (method === 'thread/start') {
return { thread: { id: 'thread-runtime-close' } }
}
if (method === 'thread/resume') {
return { thread: { id: (params as { threadId: string }).threadId } }
}
if (method === 'turn/start') {
return { turn: { id: 'turn-close' } }
}
if (method === 'model/list') {
return {
data: [
{
model: 'gpt-test',
displayName: 'GPT Test',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
isDefault: true
}
],
nextCursor: null
}
}
return {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
handlers.onExit?.(new Error('requested close'))
return true
}
}
connections.push({ connection, handlers })
return connection
}) as typeof openCodexAppServerConnection
const host = await ensureStructuredAgentSessionHost({
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root!,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
openCodexConnection: openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
attachParams.envelope.clientOperationId = operationId()
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
if (!attached.ok) {
throw new Error(
JSON.stringify({ refusal: attached.refusal, connections: connections.length })
)
}
await host.hold(SESSION, 'desktop-chat:requested-close')
await stopStructuredAgentSessionRuntime()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(connections).toHaveLength(1)
expect(host.deps.store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
ownerProcess: null,
handoffStage: null
})
const restarted = await ensureStructuredAgentSessionHost({
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root!,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
openCodexConnection: openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
await restarted.restoreReadableSessions()
const history = restarted.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false)
expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual({
provider: 'codex',
threadId: 'thread-runtime-close'
})
})
it('waits for an in-flight recovery before tearing down the runtime', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-runtime-recovery-shutdown-'))
let releaseRecovery!: () => void
const recoveryReleased = new Promise<void>((resolve) => {
releaseRecovery = resolve
})
const connections: {
connection: CodexAppServerConnection
handlers: CodexAppServerConnectionHandlers
}[] = []
let opens = 0
const openConnection = (async (_launch, handlers = {}) => {
opens += 1
if (opens === 2) {
await recoveryReleased
}
const connection: CodexAppServerConnection = {
pid: 4321 + opens,
closed: false,
request: async (method, params) => {
if (method === 'thread/start') {
return { thread: { id: 'thread-runtime-shutdown' } }
}
if (method === 'thread/resume') {
return { thread: { id: (params as { threadId: string }).threadId } }
}
if (method === 'turn/start') {
return { turn: { id: 'turn-shutdown' } }
}
if (method === 'model/list') {
return {
data: [
{
model: 'gpt-test',
displayName: 'GPT Test',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
isDefault: true
}
],
nextCursor: null
}
}
return {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
}
connections.push({ connection, handlers })
return connection
}) as typeof openCodexAppServerConnection
const host = await ensureStructuredAgentSessionHost({
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root!,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
openCodexConnection: openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
attachParams.envelope.clientOperationId = operationId()
const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams)
expect(attached.ok).toBe(true)
await host.hold(SESSION, 'desktop-chat:shutdown-race')
connections[0]?.handlers.onExit?.(new Error('recovery is still opening'))
await vi.waitFor(() => expect(opens).toBe(2))
let stopped = false
const stopping = stopStructuredAgentSessionRuntime().then(() => {
stopped = true
})
await new Promise<void>((resolve) => setImmediate(resolve))
expect(stopped).toBe(false)
releaseRecovery()
await stopping
expect(stopped).toBe(true)
})
it('drains a final exit callback delivered by the adapter backstop and keeps the retry real', async () => {
// The first stop refuses, so host eviction cannot prove the child gone and aborts with the
// session still indexed. What finally stops it is `closeAll`, which delivers the exit
// callback AFTER host teardown has already run.
root = await mkdtemp(join(tmpdir(), 'orca-runtime-backstop-exit-'))
operations = 0
const connections: {
connection: CodexAppServerConnection
handlers: CodexAppServerConnectionHandlers
}[] = []
let closeAttempts = 0
const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
request: async (method, params) => {
if (method === 'thread/start') {
return { thread: { id: 'thread-runtime-backstop' } }
}
if (method === 'thread/resume') {
return { thread: { id: (params as { threadId: string }).threadId } }
}
if (method === 'turn/start') {
return { turn: { id: 'turn-backstop' } }
}
if (method === 'model/list') {
return {
data: [
{
model: 'gpt-test',
displayName: 'GPT Test',
hidden: false,
supportedReasoningEfforts: [],
defaultReasoningEffort: null,
isDefault: true
}
],
nextCursor: null
}
}
return {}
},
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => {
closeAttempts += 1
if (closeAttempts === 1) {
return false
}
handlers.onExit?.(new Error('adapter backstop close'))
return true
}
}
connections.push({ connection, handlers })
return connection
}
const host = await ensureStructuredAgentSessionHost({
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root!,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
openCodexConnection: openConnection,
readProcessStartTime: async () => 1_700_000_000_000
})
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
attachParams.envelope.clientOperationId = operationId()
expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({
ok: true
})
await host.hold(SESSION, 'desktop-chat:backstop')
await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow()
await new Promise<void>((resolve) => setImmediate(resolve))
// The backstop, not host eviction, is what stopped the child.
expect(closeAttempts).toBeGreaterThanOrEqual(2)
// The callback it delivered neither reacquired nor wrote a technical row.
expect(connections).toHaveLength(1)
const history = host.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false)
// The aborted eviction left the session reachable, so the next teardown is a real retry.
await stopStructuredAgentSessionRuntime()
expect(host.deps.store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
ownerProcess: null,
handoffStage: null
})
})
})