mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 00:02:34 +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>
140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import type {
|
|
AgentJournalItemIdentity,
|
|
AgentJournalMessageItem,
|
|
AgentSessionJournalIdentity
|
|
} from '../../shared/agent-session-journal-types'
|
|
import type {
|
|
CodexAppServerConnection,
|
|
CodexAppServerConnectionHandlers,
|
|
CodexAppServerLaunch,
|
|
openCodexAppServerConnection
|
|
} from './codex-app-server-connection'
|
|
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
|
import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter'
|
|
|
|
export const CODEX_TEST_THREAD_ID = 'thread-abc'
|
|
|
|
export const CODEX_TEST_USER_MESSAGE: AgentJournalMessageItem = {
|
|
kind: 'message',
|
|
role: 'user',
|
|
blocks: [{ type: 'text', text: 'ship it' }]
|
|
}
|
|
|
|
export type CodexTestRoute = (params: Record<string, unknown> | undefined) => unknown
|
|
|
|
type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
|
|
closed: boolean
|
|
launch: CodexAppServerLaunch
|
|
handlers: CodexAppServerConnectionHandlers
|
|
calls: { method: string; params?: Record<string, unknown> }[]
|
|
}
|
|
|
|
export type LateSettlement = {
|
|
sessionId: string
|
|
clientMessageId: string
|
|
providerIdentity: AgentJournalItemIdentity
|
|
}
|
|
|
|
/** A `codex app-server` whose turn traffic the test drives by hand. */
|
|
export function fakeCodexAppServer(routes: Record<string, CodexTestRoute> = {}): {
|
|
connections: FakeConnection[]
|
|
openConnection: typeof openCodexAppServerConnection
|
|
routes: Record<string, CodexTestRoute>
|
|
} {
|
|
const connections: FakeConnection[] = []
|
|
const openConnection = (async (launch, handlers = {}) => {
|
|
const connection: FakeConnection = {
|
|
launch,
|
|
handlers,
|
|
calls: [],
|
|
pid: 4321,
|
|
closed: false,
|
|
request: async (method, params) => {
|
|
connection.calls.push({ method, params })
|
|
return routes[method]?.(params) ?? {}
|
|
},
|
|
notify: () => {},
|
|
respond: () => {},
|
|
respondWithError: () => {},
|
|
close: async () => {
|
|
connection.closed = true
|
|
return true
|
|
}
|
|
}
|
|
connections.push(connection)
|
|
return connection
|
|
}) as typeof openCodexAppServerConnection
|
|
routes['thread/start'] ??= () => ({
|
|
thread: { id: CODEX_TEST_THREAD_ID, path: '/rollouts/abc.jsonl' }
|
|
})
|
|
return { connections, openConnection, routes }
|
|
}
|
|
|
|
/** A sink that records nothing but keeps the translator alive, which is what
|
|
* mints the identities a late settlement carries. */
|
|
export function recordingSink(): StructuredAgentSessionEventSink {
|
|
return {
|
|
appendItem: () => {},
|
|
appendTombstone: () => {},
|
|
publish: () => {}
|
|
}
|
|
}
|
|
|
|
export async function acquiredCodexAdapter(input: {
|
|
codex: ReturnType<typeof fakeCodexAppServer>
|
|
settlements: LateSettlement[]
|
|
sink?: StructuredAgentSessionEventSink
|
|
}): Promise<CodexStructuredSessionAdapter> {
|
|
const adapter = new CodexStructuredSessionAdapter({
|
|
resolveLaunch: async () => ({
|
|
command: 'codex',
|
|
args: ['app-server'],
|
|
cwd: '/work/repo',
|
|
codexHome: null,
|
|
resumeThreadId: null
|
|
}),
|
|
openConnection: input.codex.openConnection,
|
|
readProcessStartTime: async () => 1_700_000_000_000,
|
|
captureTurnProcesses: async () => null,
|
|
now: () => 1_700_000_000_500,
|
|
onDispatchSettledLate: (settlement) => input.settlements.push(settlement)
|
|
})
|
|
const identity: AgentSessionJournalIdentity = {
|
|
sessionId: 'session-1',
|
|
workspaceId: 'ws-1',
|
|
hostId: 'host-1',
|
|
agent: 'codex',
|
|
providerHandle: { kind: 'codex', threadId: CODEX_TEST_THREAD_ID }
|
|
}
|
|
await adapter.acquire({
|
|
identity,
|
|
fence: 7,
|
|
spawnToken: 'spawn-9',
|
|
events: input.sink ?? recordingSink()
|
|
})
|
|
return adapter
|
|
}
|
|
|
|
/** Codex's own echo of a user message Orca sent, inside `turnId`. */
|
|
export function echoUserMessage(
|
|
connection: FakeConnection,
|
|
input: { turnId: string; itemId: string; clientId?: string; threadId?: string }
|
|
): void {
|
|
connection.handlers.onNotification?.('item/started', {
|
|
threadId: input.threadId ?? CODEX_TEST_THREAD_ID,
|
|
turn: { id: input.turnId },
|
|
item: {
|
|
type: 'userMessage',
|
|
id: input.itemId,
|
|
...(input.clientId ? { clientId: input.clientId } : {})
|
|
}
|
|
})
|
|
}
|
|
|
|
export function startTurn(connection: FakeConnection, turnId: string): void {
|
|
connection.handlers.onNotification?.('turn/started', {
|
|
threadId: CODEX_TEST_THREAD_ID,
|
|
turn: { id: turnId }
|
|
})
|
|
}
|