mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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.
266 lines
9.3 KiB
TypeScript
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
|
|
}
|