Files
orca/src/main/codex/codex-structured-journal-generic-frames.ts
T
Brennan Benson b4b708c2c4 fix(native-chat): a Codex stream retry is one warning row that updates in place (#23684)
* fix(native-chat): a provider's own retry progress is quoted in its retry row

A retry row whose fact carries a detail the provider wrote for a person now
quotes it, the same way a rejected message or failed compaction does, so the
row says how the retry is going. A log detail still stays out of the sentence.

* fix(native-chat): a Codex stream retry is one warning row that updates in place

An error Codex says it will retry used to fall through to the generic frame
row: red, and a new row for every attempt. It now writes one providerRetrying
row per retry run, warning-toned, revised by each attempt with Codex's own
progress sentence. A run is the retry frames of one turn with nothing else the
thread journals between them; every attempt still publishes, so the idle sweep
keeps seeing activity. Errors Codex will not retry are unchanged.

* fix(native-chat): a Codex retry row says it is retrying and keeps the frame behind Details

The quoted retry sentence leads with "is retrying", which holds for any
provider's progress text. The Codex retry row also keeps the whole bounded
frame behind the row's Details, as the generic row did, so Codex's
additionalDetails stays available to diagnose a retry.

* test(native-chat): a Codex retry re-handled after backpressure keeps its one row

Pins the run being opened before the write: a first attempt whose publish is
refused and is handed back must revise the row it already wrote, not open a
second run. Also stops the fixture claiming Codex sends an idle thread status
beside each retry, which the app server does not do.

* perf(native-chat): a Codex frame with no retry run open is not classified

Ending a retry run classified every non-retry frame, and classifying walks the
whole payload: every streaming delta, and every large item/completed, paid a
walk about as costly as parsing the frame. Only a thread with a run open needs
the answer, so the classification now runs only there.

* fix(native-chat): each Codex retry attempt is its own warning row, with what failed on its second line

A stream error Codex says it will retry is written as its own warning row
with a providerRetrying fact, under the same per-frame identity every
Codex frame row gets. The host no longer tracks retry runs or rewrites
one row in place, so there is no run state to open, end or clear, and no
frame has to be classified to end a run. Every attempt publishes, which
keeps renewing the idle clock while Codex retries.

Codex's additionalDetails, which its own UI shows under the progress
message, is kept on the fact as the retry's cause and printed on the
row's second line. Errors Codex will not retry are unchanged.

* fix(native-chat): a transcript draws only the latest row of a provider retry run

The shared structured message projection, which both the desktop and the
mobile transcript read, collapses a run of retry rows into its latest
row. A run is retry rows from the same agent with no other drawn row
between them; a row that draws nothing, or a queued send drawn after the
conversation, does not split it. The earlier attempts stay in the
journal.

* test(native-chat): the retry-run render test uses the message list's current props

* fix(native-chat): a Codex frame row is named for its connection, so a later one never revises it

Frame rows were named provider-frame:codex:<n> from a counter that starts
over with every connection, so the first frame row after a reconnect in the
same session revised an earlier connection's row in place, at its old spot.
Each connection's frame rows now carry the acquisition generation, minted
once before the translator is built: provider-frame:codex:<generation>:<n>.
Rows already written keep their identities.

* fix(native-chat): agents retrying at once each keep one row, read from the row's own agent

* test(native-chat): a reconnect's Codex rows are named for the acquisition that received them

* fix(native-chat): a retry run is one agent's, so another agent's row never splits it

Each agent's rows are drawn apart: the session's own rows are the conversation,
and a subagent's rows open in that subagent's section. Splitting a run on any
other row in the flat list left two adjacent retry rows on screen whenever
another agent wrote between two attempts: a subagent finishing a command while
the session reconnected, or the session working while a subagent reconnected.

A run is now per agent: an agent's retry rows with none of its own other rows
between them, drawn as its latest.

* test(native-chat): a subagent's retry run is checked in its own section, and the run rule's words say same agent

* test(mobile): the retry-rows test typechecks, so the test ratchet keeps checking it
2026-09-30 15:05:22 -07:00

273 lines
10 KiB
TypeScript

import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types'
import {
unhandledProviderFrameJournalItem,
type UnhandledProviderFrameJournalItem,
type UnhandledProviderFrameJournalItemOptions
} from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type {
CodexJournalTranslationAdmission,
CodexJournalTranslatorDeps
} from './codex-structured-journal-contracts'
import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts'
import {
MAX_CODEX_GENERIC_BOOKKEEPING_BYTES,
MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES,
MAX_CODEX_GENERIC_ROWS_PER_TURN,
MAX_CODEX_GENERIC_TURN_BUCKETS
} from './codex-structured-journal-limits'
import { readCodexTurnId } from './codex-structured-thread-facts'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import type { CodexRowAttribution } from './codex-subagent-linkage'
const OVERFLOW_BUCKET = '__codex-generic-overflow__'
/** `producer` is absent only on the overflow bucket, which pools every thread's
* evicted turns and so has no single author: it reads as the session's own. */
type SuppressedSummary = {
count: number
publishedCount: number
producer?: { threadId: string; turnId: string }
}
function boundedTurnBucket(threadId: string, turnId: string): string {
const encoded = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`
if (Buffer.byteLength(encoded, 'utf8') <= 512) {
return encoded
}
let hash = 2166136261
for (const byte of Buffer.from(encoded, 'utf8')) {
hash ^= byte
hash = Math.imul(hash, 16777619)
}
return `${encoded.slice(0, 160)}:${(hash >>> 0).toString(16)}`
}
function defaultSchedule(run: () => void, ms: number): () => void {
const timer = setTimeout(run, ms)
timer.unref?.()
return () => clearTimeout(timer)
}
function publish(sink: StructuredAgentSessionEventSink): CodexJournalTranslationAdmission {
return sink.tryPublish ? sink.tryPublish() : (sink.publish(), CODEX_JOURNAL_ADMITTED)
}
export class CodexJournalGenericFrames {
private readonly genericRowsByTurn = new Map<string, number>()
private readonly suppressedRowsByTurn = new Map<string, SuppressedSummary>()
private readonly bucketOrder = new Map<string, number>()
private readonly schedule: NonNullable<CodexJournalTranslatorDeps['schedule']>
private readonly suppressionCoalesceMs: number
private nextBucketOrder = 0
private bookkeepingBytes = 0
private fallbackSequence = 0
private cancelSuppressionFlush: (() => void) | null = null
constructor(
private readonly deps: Pick<
CodexJournalTranslatorDeps,
'sink' | 'schedule' | 'coalesceMs' | 'acquisitionId'
> & {
attributionFor: CodexRowAttribution
},
private readonly activeTurn: (threadId: string) => string | null
) {
this.schedule = deps.schedule ?? defaultSchedule
this.suppressionCoalesceMs = deps.coalesceMs ?? 60
}
appendUnhandled(
kind: string,
payload: unknown,
threadId: string,
options?: UnhandledProviderFrameJournalItemOptions
): CodexJournalTranslationAdmission {
const translated = unhandledProviderFrameJournalItem(
'codex',
kind,
payload,
DEFAULT_JOURNAL_PAYLOAD_LIMITS,
options
)
// A frame the classifier declines is deliberately not journaled, which is success.
// Failing admission here force-closes the provider through the retry queue.
return translated ? this.appendFrameRow(threadId, payload, translated) : CODEX_JOURNAL_ADMITTED
}
/** One frame's own row, under the identity every frame row gets, however it is worded. */
appendFrameRow(
threadId: string,
payload: unknown,
translated: {
body: AgentJournalStatusItem
classification: UnhandledProviderFrameJournalItem['classification']
}
): CodexJournalTranslationAdmission {
const frameTurnId = readCodexTurnId(payload) ?? this.activeTurn(threadId)
const turnId = frameTurnId ?? 'outside-turn'
const bucket = this.bucketFor(threadId, turnId)
const rowCount = this.genericRowsByTurn.get(bucket) ?? 0
// The cap bounds noise, never evidence: an error frame is always journaled, and
// capped frames stay countable through one summary row per turn.
const isError = translated.classification === 'error-surface'
if (!isError && rowCount >= MAX_CODEX_GENERIC_ROWS_PER_TURN) {
this.addSuppressed(bucket, 1, { threadId, turnId })
this.recordBucket(bucket)
this.scheduleSuppressedRows()
return CODEX_JOURNAL_ADMITTED
}
if (isError) {
const suppressionAdmission = this.flush()
if (!suppressionAdmission.accepted) {
return suppressionAdmission
}
}
this.fallbackSequence += 1
const identity = {
provider: 'orca' as const,
clientMessageId: `provider-frame:codex:${this.deps.acquisitionId ?? 'acquisition'}:${this.fallbackSequence}`
}
const attribution = this.deps.attributionFor(threadId, frameTurnId)
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(identity, translated.body, attribution)
: (this.deps.sink.appendItem(identity, translated.body, attribution), CODEX_JOURNAL_ADMITTED)
if (!admission.accepted) {
this.fallbackSequence -= 1
return admission
}
this.genericRowsByTurn.set(bucket, rowCount + 1)
this.recordBucket(bucket)
return publish(this.deps.sink)
}
suppress(threadId: string, turnId: string, count = 1): void {
const bucket = this.bucketFor(threadId, turnId)
this.addSuppressed(bucket, count, { threadId, turnId })
this.recordBucket(bucket)
}
flush = (): CodexJournalTranslationAdmission => {
this.cancelSuppressionFlush?.()
this.cancelSuppressionFlush = null
let wrote = false
const ready: SuppressedSummary[] = []
let blocked: CodexJournalTranslationAdmission | null = null
for (const [bucket, summary] of this.suppressedRowsByTurn) {
if (summary.count === summary.publishedCount) {
continue
}
const text =
bucket === OVERFLOW_BUCKET
? `${summary.count} more provider notification${summary.count === 1 ? '' : 's'} not shown across evicted turns`
: `${summary.count} more provider notification${summary.count === 1 ? '' : 's'} not shown for this turn`
const identity = {
provider: 'orca' as const,
clientMessageId: `provider-frame-suppressed:codex:${bucket}`
}
// A summary across evicted turns names no producer and belongs to no turn.
const options = {
coalescingKey: `provider-frame-suppressed:codex:${bucket}`,
...(summary.producer
? this.deps.attributionFor(summary.producer.threadId, summary.producer.turnId)
: { turnScope: AGENT_JOURNAL_THREAD_SCOPE })
}
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(identity, { kind: 'status', text }, options)
: (this.deps.sink.appendItem(identity, { kind: 'status', text }, options),
CODEX_JOURNAL_ADMITTED)
if (!admission.accepted) {
blocked ??= admission
continue
}
ready.push(summary)
wrote = true
}
if (wrote) {
const admission = publish(this.deps.sink)
if (!admission.accepted) {
blocked ??= admission
} else {
for (const summary of ready) {
summary.publishedCount = summary.count
}
}
}
if (blocked) {
this.scheduleSuppressedRows()
return blocked
}
return CODEX_JOURNAL_ADMITTED
}
dispose(): void {
this.cancelSuppressionFlush?.()
this.genericRowsByTurn.clear()
this.suppressedRowsByTurn.clear()
this.bucketOrder.clear()
this.bookkeepingBytes = 0
}
private scheduleSuppressedRows(): void {
this.cancelSuppressionFlush ??= this.schedule(() => {
this.cancelSuppressionFlush = null
this.flush()
}, this.suppressionCoalesceMs)
}
private bucketFor(threadId: string, turnId: string): string {
const requested = boundedTurnBucket(threadId, turnId)
return Buffer.byteLength(requested, 'utf8') > MAX_CODEX_GENERIC_BOOKKEEPING_BYTES
? OVERFLOW_BUCKET
: requested
}
private addSuppressed(
bucket: string,
count: number,
producer?: SuppressedSummary['producer']
): void {
const summary = this.suppressedRowsByTurn.get(bucket) ?? {
count: 0,
publishedCount: 0,
...(producer && bucket !== OVERFLOW_BUCKET ? { producer } : {})
}
summary.count += count
this.suppressedRowsByTurn.set(bucket, summary)
}
private recordBucket(bucket: string): void {
if (!this.bucketOrder.has(bucket)) {
this.bucketOrder.set(bucket, this.nextBucketOrder++)
this.bookkeepingBytes += Buffer.byteLength(bucket, 'utf8')
}
while (
(this.bucketOrder.size > MAX_CODEX_GENERIC_TURN_BUCKETS ||
this.genericRowsByTurn.size + this.suppressedRowsByTurn.size >
MAX_CODEX_GENERIC_BOOKKEEPING_ENTRIES ||
this.bookkeepingBytes > MAX_CODEX_GENERIC_BOOKKEEPING_BYTES) &&
this.bucketOrder.size > 1
) {
const oldest = [...this.bucketOrder.entries()]
.filter(([id]) => id !== OVERFLOW_BUCKET && id !== bucket)
.sort((a, b) => a[1] - b[1])[0]?.[0]
if (!oldest) {
break
}
const suppressed = this.suppressedRowsByTurn.get(oldest)
this.removeBucket(oldest)
if (suppressed && suppressed.count > suppressed.publishedCount) {
this.recordBucket(OVERFLOW_BUCKET)
this.addSuppressed(OVERFLOW_BUCKET, suppressed.count - suppressed.publishedCount)
}
}
}
private removeBucket(bucket: string): void {
this.genericRowsByTurn.delete(bucket)
this.suppressedRowsByTurn.delete(bucket)
this.bookkeepingBytes = Math.max(0, this.bookkeepingBytes - Buffer.byteLength(bucket, 'utf8'))
this.bucketOrder.delete(bucket)
}
}