mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
* fix(native-chat): settle in-flight sends when their turn ends A send the provider admits gets no dispatch row, by design: the provider's later acknowledgement is what settles it. If the turn carrying that send ends first, the acknowledgement can never arrive and the submission stays pending for the life of the session, so the chat reports work forever with no running turn. It also blocks /clear and /compact and holds the session open. Turn settlement now settles the sends that were in flight inside it. One routine owns the behaviour and both journal write paths use it, because the turn record reaches its terminal state through a plain item append on one provider and through a lifecycle batch on the other. Ownership is derived from journal order rather than stored: the pending set is captured inside the serialized row build, so exactly the sends preceding the terminal row are settled and later ones are untouched. Settlement records doubt rather than a rejection, since an unacknowledged send is never proof of non-delivery. A failed settlement is reported and never blocks the turn from settling or the next send. No schema or wire change: settlement writes ordinary dispatch rows that every client already decodes. * fix(native-chat): retire settled dispatch ownership * fix(native-chat): correlate terminal dispatch ownership * fix(native-chat): make dispatch ownership provider-authoritative * fix(native-chat): complete durable late settlement recovery * Resolve mainline conflicts in settlement plumbing * fix(codex): steer a mid-turn send into the running turn by name A message sent while a Codex turn runs, a queued card's Send-now included, went out as turn/start. Codex 0.148 and later steer that into the running turn and answer with its id, so the turn's end settles the send. Before 0.148, turn/start answers with its own submission id, which never opens or ends as a turn: the send was bound to a turn that never exists, and a Stop left it pending, so the chat read as working and /clear stayed blocked until the app exited. The send now goes in as turn/steer with expectedTurnId set to the turn Codex last reported started, and is bound to the turn the answer names. A steer Codex refuses took no input (the turn ended or changed, it cannot be steered, or this Codex has no turn/steer), so the send falls back to turn/start. A steer that times out is never re-sent and stays armed for its echo. Per-turn options ride on the next turn/start. * fix(codex): steer a send made before Codex opens the previous send's turn On a Codex before 0.148, a second send made after Codex answered the first but before it reported that turn started went out as turn/start. Codex folded it into the first turn but answered with an id that never opens or ends, so a Stop left it pending: the chat kept reading Working. A send now resolves its target the way Stop already does: the running turn, or else the turn Codex answered an earlier send into, once it opens (same bounded wait). The resolver moves into the turn-open-wait module and Stop and send share it. When Codex refuses a steer and a different turn is now running, the send steers that turn once before falling back to turn/start. The lifecycle fake's legacy mode now mints a false id for a start made while a turn is picked but unopened, and refuses a steer until that turn starts. * fix(codex): wait at most once for an answered turn Codex never opens A Codex before 0.148 can answer a send with a turn it then fails before starting, reporting only an `error` and no turn end. That turn stayed the answered-but-unopened turn for the rest of the session, so every later send made while the chat was idle, and every Stop naming no turn, waited the full open-wait first. When a wait ends without the turn opening, the dispatch correlation now records it, and later lookups skip it. A turn that opens later is still found through turn/started. The runtime test for a send made before the first turn opens now waits on a signal that the send is inside the open-wait instead of a fixed sleep, and pins that the send was steered. * test(codex): prove a legacy mid-turn send settles on Stop, and stop faking a steer Adds the user-visible outcome on a Codex before 0.148: a send made while a turn runs is withdrawn when a Stop ends that turn, the chat no longer owes work, and /compact is admitted after. The shared fake Codex servers no longer answer an unrouted turn/steer as a success; they refuse it as a Codex without that method would. Adds the case where Codex refuses both the steer and the fallback start, so the send is rejected in Codex's words and disarmed. * test(codex): type the fake Codex connection and wait recorder instead of casting
213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
import { agentSessionFailureFact, providerDiagnosticOf } from '../../shared/agent-session-failure'
|
|
import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types'
|
|
import type { NativeChatBlock } from '../../shared/native-chat-types'
|
|
import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
|
import {
|
|
isCodexAppServerRequestError,
|
|
type CodexAppServerConnection
|
|
} from './codex-app-server-connection'
|
|
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
|
|
import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
|
|
import { readCodexTurnId } from './codex-structured-thread-facts'
|
|
import {
|
|
codexRunningOrOpeningTurn,
|
|
type CodexTurnOpenWaits
|
|
} from './codex-structured-turn-open-wait'
|
|
import {
|
|
codexDispatchRejection,
|
|
codexTurnEndRejection
|
|
} from './codex-structured-turn-end-settlement'
|
|
import { decodeStructuredAgentSessionOptionValue } from '../../shared/structured-agent-session-option-codec'
|
|
|
|
// Writing a Codex turn and learning which message landed where, which are not
|
|
// the same event. The answer proves admission and nothing about identity, which
|
|
// the echo settles later; the turn it names is kept with the send, so that
|
|
// turn's end can settle it. A message sent while a turn is running goes in as
|
|
// `turn/steer` naming that turn, so the answer names the turn that carries it.
|
|
// `turn/start` would also steer it, with no second `turn/started`, but a Codex
|
|
// before 0.148 answers that with an id no turn ever opens or ends under. So a send
|
|
// made after Codex answered an earlier one, before it opened that turn, waits for
|
|
// the turn to open and steers it.
|
|
|
|
/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise
|
|
* become an arbitrary client-controlled `turn/start` parameter. Permission posture is owned by
|
|
* Agent Permissions and applied when the thread opens. */
|
|
const CODEX_TURN_OPTION_KEYS = new Set([
|
|
'model',
|
|
'effort',
|
|
'approvalsReviewer',
|
|
'personality',
|
|
'serviceTier',
|
|
'fastMode'
|
|
])
|
|
|
|
export function isCodexTurnOptionKey(key: string): boolean {
|
|
return CODEX_TURN_OPTION_KEYS.has(key)
|
|
}
|
|
|
|
/** The session state one turn needs. */
|
|
export type CodexTurnHost = {
|
|
connection: Pick<CodexAppServerConnection, 'request'>
|
|
threadId: string
|
|
options: Map<string, string>
|
|
reportedOptions?: { model?: string }
|
|
fastModeTierByModel: ReadonlyMap<string, string>
|
|
dispatchEchoes: CodexDispatchEchoes
|
|
activeTurnIds?: ReadonlySet<string>
|
|
turnOpenWaits: Pick<CodexTurnOpenWaits, 'wait'>
|
|
}
|
|
|
|
function turnInputFor(body: AgentJournalMessageItem): Record<string, unknown>[] {
|
|
const input: Record<string, unknown>[] = []
|
|
for (const block of body.blocks as NativeChatBlock[]) {
|
|
if (block.type === 'text' && block.text.length > 0) {
|
|
input.push({ type: 'text', text: block.text })
|
|
} else if (block.type === 'image-ref' && block.path) {
|
|
input.push({ type: 'localImage', path: block.path })
|
|
} else if (block.type === 'image-ref' && block.url) {
|
|
input.push({ type: 'image', url: block.url })
|
|
}
|
|
}
|
|
return input
|
|
}
|
|
|
|
function codexTurnOptions(host: CodexTurnHost): Record<string, string> {
|
|
const options = Object.fromEntries(
|
|
[...host.options].filter(([key]) => key !== 'fastMode' && key !== 'serviceTier')
|
|
)
|
|
const encodedFastMode = host.options.get('fastMode')
|
|
if (encodedFastMode === undefined) {
|
|
return options
|
|
}
|
|
const fastMode = decodeStructuredAgentSessionOptionValue('fastMode', encodedFastMode)
|
|
if (typeof fastMode !== 'boolean') {
|
|
throw new Error('codex fast mode must be encoded as true or false')
|
|
}
|
|
if (!fastMode) {
|
|
return { ...options, serviceTier: 'default' }
|
|
}
|
|
const model = host.options.get('model') ?? host.reportedOptions?.model
|
|
const tierId = model ? host.fastModeTierByModel.get(model) : undefined
|
|
// Fast is on but nothing has named the tier for this model yet, so there is no
|
|
// value to route to. Deliberately Standard rather than an omission: the tier
|
|
// persists on the thread, so omitting would silently keep routing a paid tier we
|
|
// cannot currently name, and discovery recovers the exact tier on a later turn.
|
|
if (!tierId) {
|
|
return { ...options, serviceTier: 'default' }
|
|
}
|
|
return { ...options, serviceTier: tierId }
|
|
}
|
|
|
|
/**
|
|
* Steers a send into the turn Codex last reported running. Null when Codex refused the steer,
|
|
* which it does before taking any input: that turn ended or changed, it cannot be steered, or
|
|
* this Codex has no `turn/steer`. Per-turn options ride on the next `turn/start`.
|
|
*/
|
|
async function steerCodexTurn(
|
|
host: CodexTurnHost,
|
|
expectedTurnId: string,
|
|
input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number }
|
|
): Promise<{ turnId: string } | null> {
|
|
try {
|
|
const answer = await host.connection.request(
|
|
'turn/steer',
|
|
{
|
|
threadId: host.threadId,
|
|
expectedTurnId,
|
|
clientUserMessageId: input.clientMessageId,
|
|
input: turnInputFor(input.body)
|
|
},
|
|
{ timeoutMs: input.timeoutMs }
|
|
)
|
|
return { turnId: readCodexTurnId(answer) ?? expectedTurnId }
|
|
} catch (error) {
|
|
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
|
|
return null
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hands one submission to Codex. False means the bounded correlation window
|
|
* refused it before the write; otherwise resolves with the turn Codex answered
|
|
* it into, or null when the answer named none.
|
|
*/
|
|
export async function startCodexTurn(
|
|
host: CodexTurnHost,
|
|
input: {
|
|
clientMessageId: string
|
|
body: AgentJournalMessageItem
|
|
requestedAt?: number
|
|
timeoutMs?: number
|
|
}
|
|
): Promise<{ turnId: string | null } | false> {
|
|
// Armed before the write: the echo and `turn/started` can both land while the
|
|
// response is in flight, and the start must snapshot this send in its frontier.
|
|
if (!host.dispatchEchoes.arm(input.clientMessageId, input.requestedAt)) {
|
|
return false
|
|
}
|
|
const runningTurnId = await codexRunningOrOpeningTurn(host)
|
|
let steered = runningTurnId ? await steerCodexTurn(host, runningTurnId, input) : null
|
|
// Refused because a turn Orca heard of meanwhile is running: steer that one, once.
|
|
const runningSince = steered ? undefined : [...(host.activeTurnIds ?? [])].at(-1)
|
|
if (runningSince && runningSince !== runningTurnId) {
|
|
steered = await steerCodexTurn(host, runningSince, input)
|
|
}
|
|
if (steered) {
|
|
return steered
|
|
}
|
|
const answer = await host.connection.request(
|
|
'turn/start',
|
|
{
|
|
threadId: host.threadId,
|
|
clientUserMessageId: input.clientMessageId,
|
|
input: turnInputFor(input.body),
|
|
...codexTurnOptions(host)
|
|
},
|
|
{ timeoutMs: input.timeoutMs }
|
|
)
|
|
return { turnId: readCodexTurnId(answer) }
|
|
}
|
|
|
|
/**
|
|
* One submission's outcome as the wire must read it: admitted means Codex owns
|
|
* the message and its identity settles on the echo, rejected is Codex answering
|
|
* and declining. Elapsed time is never evidence here, because the wait a
|
|
* steered send would face is bounded only by the running turn.
|
|
*/
|
|
export async function dispatchCodexTurn(
|
|
session: CodexTurnHost,
|
|
input: { clientMessageId: string; body: AgentJournalMessageItem; requestedAt?: number },
|
|
timeoutMs: number | undefined
|
|
): Promise<AgentSessionDispatchOutcome> {
|
|
let answer: { turnId: string | null } | false
|
|
try {
|
|
answer = await startCodexTurn(session, { ...input, timeoutMs })
|
|
} catch (error) {
|
|
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
|
|
// Codex answered and declined, so no echo for this write can arrive.
|
|
session.dispatchEchoes.disarm(input.clientMessageId)
|
|
// Codex's own words, when it gave any, are the one part of the error a person can use.
|
|
return {
|
|
state: 'rejected',
|
|
...codexDispatchRejection(
|
|
agentSessionFailureFact('providerRejected', { detail: providerDiagnosticOf(error) })
|
|
)
|
|
}
|
|
}
|
|
// A timeout or transport failure can happen after the frame was written.
|
|
// Keep the correlation armed so a later echo can prove delivery.
|
|
throw error
|
|
}
|
|
if (!answer) {
|
|
return { state: 'rejected', ...codexDispatchRejection(agentSessionFailureFact('queueFull')) }
|
|
}
|
|
// An answer read after the turn it names already ended is settled by that end.
|
|
const endedFirst = answer.turnId
|
|
? session.dispatchEchoes.bindTurn(input.clientMessageId, session.threadId, answer.turnId)
|
|
: null
|
|
const rejection = endedFirst ? codexTurnEndRejection(endedFirst) : null
|
|
return rejection ? { state: 'rejected', ...rejection } : { state: 'admitted' }
|
|
}
|