mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* 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>
411 lines
15 KiB
TypeScript
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
|
|
})
|
|
})
|
|
})
|