Files
orca/src/main/codex/codex-structured-journal-items.ts
T
Merge Sim 698eb3a74d 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.
2026-09-11 11:31:17 -07:00

266 lines
9.3 KiB
TypeScript

import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement'
import {
codexItemIdentity,
codexJournalItem,
CodexTurnOrdinals,
readCodexThreadItem,
type CodexThreadItem
} from './codex-structured-item-translation'
import { createCodexStructuredItemStreams } from './codex-structured-item-streams'
import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds'
import { codexCommandOutlivesTurn } from './codex-command-lifecycle'
import type {
CodexItemTranslation,
CodexJournalTranslationAdmission,
CodexJournalTranslatorDeps
} from './codex-structured-journal-contracts'
import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts'
import {
MAX_CODEX_ACTIVE_ITEMS,
MAX_CODEX_DETAIL_BYTES,
MAX_CODEX_DETAIL_ENTRIES,
MAX_CODEX_IDENTITY_ENTRIES
} from './codex-structured-journal-limits'
import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink'
import type { CodexActiveJournalItem } from './codex-structured-journal-settlement'
import { readCodexJournalString } from './codex-structured-journal-translation-values'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { readCodexDispatchEcho } from './codex-structured-dispatch-echo'
export class CodexJournalItems {
readonly ordinals = new CodexTurnOrdinals()
readonly activeItems = new Map<string, CodexActiveJournalItem>()
readonly streams
private readonly identities = new Map<string, AgentJournalItemIdentity>()
private readonly details = new Map<string, string>()
constructor(
private readonly deps: Pick<
CodexJournalTranslatorDeps,
'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'onUserMessageEcho'
> & { maxMetadataBytes?: number },
private readonly activeTurn: (threadId: string) => string | null,
private readonly suppress: (threadId: string, turnId: string) => void
) {
this.streams = createCodexStructuredItemStreams({
sink: deps.sink,
coalesceMs: deps.coalesceMs,
maxRetainedBytes: deps.maxRetainedBytes,
schedule: deps.schedule,
maxMetadataBytes: deps.maxMetadataBytes,
identityFor: (threadId, params, item) => {
const turnId = readCodexTurnId(params) ?? this.activeTurn(threadId)
return this.identityFor(threadId, turnId, item)
}
})
}
detailFor(threadId: string, itemId: string): string | null {
return this.details.get(codexStructuredItemKey(threadId, itemId)) ?? null
}
handle(
event: { threadId: string; method: string; params: unknown },
source: 'live' | 'history' = 'live'
): CodexItemTranslation {
const params =
typeof event.params === 'object' && event.params !== null
? (event.params as Record<string, unknown>)
: {}
const item = readCodexThreadItem(params.item)
if (!item) {
return { handled: false }
}
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
const identity = this.identityFor(event.threadId, turnId, item)
// Count echoes for stable resume ordinals, but user bubbles come from submissions.
if (source === 'live' && item.type === 'userMessage') {
const echo = readCodexDispatchEcho(item, identity)
if (echo) {
this.deps.onUserMessageEcho?.(echo.clientMessageId, echo.providerIdentity)
}
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (item.type === 'contextCompaction' && event.method === 'item/started') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
if (
event.method !== 'item/completed' &&
!this.streams.canTrack(event.threadId, item, identity)
) {
return { handled: true, admission: { accepted: false, reason: 'failed' } }
}
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
const boundedCommand = Buffer.from(command, 'utf8')
.subarray(0, MAX_CODEX_DETAIL_BYTES)
.toString('utf8')
this.details.set(codexStructuredItemKey(event.threadId, item.id), boundedCommand)
}
const itemKey = codexStructuredItemKey(event.threadId, item.id)
if (!translated.body) {
if (event.method === 'item/completed') {
this.streams.forget(event.threadId, item.id)
this.activeItems.delete(itemKey)
} else {
this.track(event.threadId, turnId, item, identity)
const admission = this.trimActiveState()
if (!admission.accepted) {
return { handled: true, admission }
}
}
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
const admission = this.appendTranslated(event.method, identity, translated)
if (!admission.accepted) {
return { handled: true, admission }
}
if (event.method === 'item/completed') {
this.streams.forget(event.threadId, item.id)
this.activeItems.delete(itemKey)
} else {
this.track(event.threadId, turnId, item, identity)
const trimAdmission = this.trimActiveState()
if (!trimAdmission.accepted) {
return { handled: true, admission: trimAdmission }
}
}
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
dispose(): void {
this.streams.dispose()
this.identities.clear()
this.details.clear()
this.activeItems.clear()
}
private appendTranslated(
method: string,
identity: AgentJournalItemIdentity,
translated: ReturnType<typeof codexJournalItem>
): CodexJournalTranslationAdmission {
if (!translated.body) {
return CODEX_JOURNAL_ADMITTED
}
if (method === 'item/completed') {
const admission = appendCodexLifecycleItem(this.deps.sink, identity, translated.body)
return admission.accepted ? publishCodexLifecycle(this.deps.sink) : admission
}
const options = requiresTerminalSettlement(translated.body) ? { lifecycle: true } : {}
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(identity, translated.body, options)
: (this.deps.sink.appendItem(identity, translated.body), CODEX_JOURNAL_ADMITTED)
if (!admission.accepted) {
return admission
}
return this.deps.sink.tryPublish
? this.deps.sink.tryPublish(options)
: (this.deps.sink.publish(options), CODEX_JOURNAL_ADMITTED)
}
private track(
threadId: string,
turnId: string | null,
item: CodexThreadItem,
identity: AgentJournalItemIdentity
): void {
const retainedItem = codexCommandOutlivesTurn(item)
? (boundStreamItem(item) as CodexThreadItem)
: item
this.streams.track(threadId, retainedItem, identity)
this.activeItems.set(codexStructuredItemKey(threadId, item.id), {
threadId,
turnId,
identity,
item: retainedItem
})
}
private identityFor(
threadId: string,
turnId: string | null,
item: Parameters<typeof codexItemIdentity>[0]['item']
): AgentJournalItemIdentity {
const key = codexStructuredItemKey(threadId, item.id)
const existing = this.identities.get(key)
if (existing) {
return existing
}
const identity = codexItemIdentity({ threadId, turnId, item, ordinals: this.ordinals })
this.identities.set(key, identity)
while (this.identities.size > MAX_CODEX_IDENTITY_ENTRIES) {
const oldest = this.identities.keys().next().value
if (typeof oldest === 'string') {
this.identities.delete(oldest)
}
}
while (this.details.size > MAX_CODEX_DETAIL_ENTRIES) {
const oldest = this.details.keys().next().value
if (typeof oldest === 'string') {
this.details.delete(oldest)
}
}
return identity
}
private trimActiveState(): CodexJournalTranslationAdmission {
while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) {
const oldest = [...this.activeItems].find(
([, active]) => !codexCommandOutlivesTurn(active.item)
)?.[0]
if (typeof oldest !== 'string') {
break
}
const evicted = this.activeItems.get(oldest)
if (evicted) {
const translated = codexJournalItem(evicted.item).body
if (translated) {
const admission = appendCodexLifecycleItem(
this.deps.sink,
evicted.identity,
evictedActiveBody(translated)
)
if (!admission.accepted) {
return admission
}
const published = publishCodexLifecycle(this.deps.sink)
if (!published.accepted) {
return published
}
}
this.streams.forget(evicted.threadId, evicted.item.id)
this.suppress(evicted.threadId, evicted.turnId ?? 'outside-turn')
}
this.activeItems.delete(oldest)
}
return CODEX_JOURNAL_ADMITTED
}
}
function evictedActiveBody(body: AgentJournalItemBody): AgentJournalItemBody {
if (body.kind === 'tool-call' && body.state === 'running') {
return { ...body, state: 'failed' }
}
if (
(body.kind === 'approval' || body.kind === 'question') &&
body.resolution.state === 'pending'
) {
return {
...body,
resolution: {
state: 'cancelled',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
return body
}