From 78fc4f091ae7cc62de68ad1f79ff6870ead100ff Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:42:56 -0700 Subject: [PATCH] fix(native-chat): harden round-two reliability --- config/scripts/pr-code-change-scope.mjs | 4 + config/scripts/pr-code-change-scope.test.mjs | 4 + .../codex-app-server-process-teardown.ts | 4 +- .../journal-log-file.test.ts | 168 +++++++++++++ .../agent-session-journal/journal-log-file.ts | 29 ++- .../agent-session-history-page.test.ts | 237 +++++++++++++++++- .../agent-session-history-page.ts | 51 +++- ...ed-agent-session-readable-restorer.test.ts | 43 ++++ ...uctured-agent-session-readable-restorer.ts | 22 +- ...ructured-agent-session-subscribers.test.ts | 92 ++++++- ...tructured-tui-recovery-claim-match.test.ts | 14 +- ...tured-agent-session-read-transport.test.ts | 134 ++++++++++ ...structured-agent-session-read-transport.ts | 49 +++- src/shared/agent-session-journal-item-key.ts | 33 ++- .../child-process-import-allowlist.txt | 3 +- 15 files changed, 843 insertions(+), 44 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-log-file.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c51b1c18a5c..461daa30697 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -93,6 +93,10 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', 'src/shared/browser-client-host-placement', + 'src/shared/agent-session-wire', + 'src/shared/agent-session-mutation-envelope', + 'src/main/native-chat/agent-session-wire/', + 'src/main/runtime/agent-session-record-store', 'src/main/runtime/rpc/dispatcher', 'src/main/runtime/rpc/methods/browser-tab-create-schema', 'src/main/runtime/rpc/methods/terminal', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index ea83796e6cb..20ae9cc60fc 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -181,6 +181,10 @@ describe('per-job path classification', () => { for (const file of [ 'src/shared/protocol-version.ts', 'src/shared/terminal-stream-protocol.ts', + 'src/shared/agent-session-wire.ts', + 'src/shared/agent-session-mutation-envelope.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-host.ts', + 'src/main/runtime/agent-session-record-store.ts', 'src/main/runtime/rpc/dispatcher.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts', 'src/main/runtime/rpc/methods/terminal.ts', diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/codex/codex-app-server-process-teardown.ts index 4e3bf537c22..cc7ea8c8d17 100644 --- a/src/main/codex/codex-app-server-process-teardown.ts +++ b/src/main/codex/codex-app-server-process-teardown.ts @@ -1,4 +1,4 @@ -import type { ChildProcess } from 'node:child_process' +import type { ChildProcessHandle } from '../../shared/child-process/run-process' import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' import { terminateWindowsProcessTree } from '../windows-process-tree-kill' @@ -8,7 +8,7 @@ const TOKEN_PROCESS_EXIT_TIMEOUT_MS = 3_500 const TOKEN_PROCESS_POLL_MS = 25 const activeTeardowns = new WeakMap>() -type TeardownChild = Pick +type TeardownChild = Pick export type CodexAppServerProcessTeardownDeps = { platform?: NodeJS.Platform diff --git a/src/main/native-chat/agent-session-journal/journal-log-file.test.ts b/src/main/native-chat/agent-session-journal/journal-log-file.test.ts new file mode 100644 index 00000000000..4e06f042657 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-log-file.test.ts @@ -0,0 +1,168 @@ +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendJournalRows, JOURNAL_SNAPSHOT_FILE, readJournalSnapshot } from './journal-log-file' +import type { JournalSnapshotFile } from './journal-log-file' +import type { JournalRow } from './journal-row-schema' +import { openAgentSessionJournal } from './journal-store' + +type FakeDirectoryHandle = { sync: ReturnType; close: ReturnType } + +let openDirectoryHook: ((path: unknown, flags: unknown) => FakeDirectoryHandle | undefined) | null = + null + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: (async (...args: Parameters) => { + const fake = openDirectoryHook?.(args[0], args[1]) + return fake ?? actual.open(...args) + }) as typeof actual.open + } +}) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-log-file-')) + openDirectoryHook = null +}) + +afterEach(async () => { + openDirectoryHook = null + await rm(root, { recursive: true, force: true }) +}) + +function validSnapshot(): JournalSnapshotFile { + return { + v: 1, + epoch: 'epoch-A', + compactedThrough: 2, + highestFence: 1, + items: [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + sequence: 2, + observedAt: 1_000 + } + ], + submissions: [], + receipts: [], + aliases: [], + tombstones: [{ itemId: 'codex:thread-1:turn-1:2', revision: 3 }], + tail: [] + } +} + +async function writeSnapshot(snapshot: unknown): Promise { + await writeFile(join(root, JOURNAL_SNAPSHOT_FILE), JSON.stringify(snapshot), 'utf-8') +} + +describe('readJournalSnapshot validation', () => { + it('accepts a well-formed snapshot, with and without the tombstones collection', async () => { + await writeSnapshot(validSnapshot()) + expect((await readJournalSnapshot(root)).status).toBe('valid') + + const { tombstones: _tombstones, ...withoutTombstones } = validSnapshot() + await writeSnapshot(withoutTombstones) + expect((await readJournalSnapshot(root)).status).toBe('valid') + }) + + it('classifies a JSON-valid non-array tombstones collection as invalid instead of valid', async () => { + await writeSnapshot({ ...validSnapshot(), tombstones: {} }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects tombstone entries that would poison seeding', async () => { + for (const tombstones of [ + [{ itemId: 42, revision: 1 }], + [{ itemId: 'codex:thread-1:turn-1:1', revision: 'one' }], + [{ itemId: 'codex:thread-1:turn-1:1', revision: Number.NaN }], + ['codex:thread-1:turn-1:1'] + ]) { + await writeSnapshot({ ...validSnapshot(), tombstones }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + } + }) + + it('rejects items and counters that only look shallowly plausible', async () => { + const missingSequence = validSnapshot() + missingSequence.items = [ + { itemId: 'codex:thread-1:turn-1:1', revision: 1, body: { kind: 'status', text: 'x' } } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(missingSequence) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + + await writeSnapshot({ ...validSnapshot(), compactedThrough: Number.NaN }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) +}) + +describe('journal startup isolation from a malformed snapshot', () => { + it('quarantines a JSON-valid malformed snapshot instead of throwing through open', async () => { + await writeSnapshot({ ...validSnapshot(), tombstones: {} }) + + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) + + // Degraded exactly like other corrupt snapshots: quarantined on disk, never + // silently deleted, and the session does not adopt state it cannot trust. + const entries = await readdir(root) + expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true) + expect(entries.includes(JOURNAL_SNAPSHOT_FILE)).toBe(false) + expect(journal.snapshot().items).toEqual([]) + }) +}) + +describe('appendJournalRows directory fsync', () => { + const ROW: JournalRow = { + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + v: 1, + epoch: 'epoch-A', + seq: 1, + fence: 0, + ts: 1_000 + } + + function hookDirectoryOpen(sync: ReturnType): FakeDirectoryHandle { + const fake: FakeDirectoryHandle = { sync, close: vi.fn(async () => undefined) } + openDirectoryHook = (path, flags) => (path === root && flags === 'r' ? fake : undefined) + return fake + } + + it('closes the directory handle when directory fsync fails', async () => { + const fake = hookDirectoryOpen( + vi.fn(async () => { + throw new Error('EINVAL: sync') + }) + ) + + // Tolerating unsupported directory fsync must not turn into a leak. + await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined() + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.close).toHaveBeenCalledTimes(1) + }) + + it('closes the directory handle when directory fsync succeeds', async () => { + const fake = hookDirectoryOpen(vi.fn(async () => undefined)) + + await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined() + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-log-file.ts b/src/main/native-chat/agent-session-journal/journal-log-file.ts index 8acc7743496..ac58867953a 100644 --- a/src/main/native-chat/agent-session-journal/journal-log-file.ts +++ b/src/main/native-chat/agent-session-journal/journal-log-file.ts @@ -8,7 +8,7 @@ // between publishing the snapshot and truncating the log leaves the log a // superset of the tail, and recovery unions the two by sequence — never a hole. -import { appendFile, mkdir, open, readFile } from 'node:fs/promises' +import { appendFile, mkdir, open, readFile, type FileHandle } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { join } from 'node:path' import { durableWriteTempPath, renameDurable, writeFileDurable } from '../../durable-file-write' @@ -156,12 +156,17 @@ function isJournalSnapshotFile(value: unknown): value is JournalSnapshotFile { typeof snapshot.v === 'number' && typeof snapshot.epoch === 'string' && snapshot.epoch.length > 0 && - typeof snapshot.compactedThrough === 'number' && - typeof snapshot.highestFence === 'number' && + Number.isInteger(snapshot.compactedThrough) && + (snapshot.compactedThrough as number) >= 0 && + Number.isInteger(snapshot.highestFence) && arrayOf(snapshot.items, isRenderItem) && arrayOf(snapshot.submissions, isSubmission) && arrayOf(snapshot.receipts, isReceipt) && arrayOf(snapshot.aliases, isAlias) && + // Older snapshots predate tombstones; absence is fine, a non-array is not — + // seeding iterates this collection, so a JSON-valid wrong shape must land + // in quarantine rather than throw through startup restoration. + (snapshot.tombstones === undefined || arrayOf(snapshot.tombstones, isTombstone)) && arrayOf(snapshot.tail, (row) => parseJournalRow(JSON.stringify(row)).ok) ) } @@ -181,11 +186,19 @@ function isRenderItem(value: unknown): boolean { return Boolean( item && typeof item.itemId === 'string' && - typeof item.revision === 'number' && + Number.isInteger(item.revision) && + Number.isInteger(item.sequence) && recordOf(item.body) ) } +function isTombstone(value: unknown): boolean { + const tombstone = recordOf(value) + return Boolean( + tombstone && typeof tombstone.itemId === 'string' && Number.isInteger(tombstone.revision) + ) +} + function isSubmission(value: unknown): boolean { const submission = recordOf(value) return Boolean(submission && typeof submission.clientMessageId === 'string') @@ -242,12 +255,16 @@ export async function appendJournalRows( } finally { await handle.close() } + let directory: FileHandle | undefined try { - const directory = await open(journalDir, 'r') + directory = await open(journalDir, 'r') await directory.sync() - await directory.close() } catch { // Directory fsync is unavailable on some platforms (notably Windows). + } finally { + // The tolerance above must not leak the descriptor when open succeeded + // but sync failed — one leaked handle per append adds up fast. + await directory?.close().catch(() => undefined) } } diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index ebd347c2ba3..51c2835c51d 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -1,17 +1,31 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { appendFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalSubmissionKey, + boundJournalKeyComponent +} from '../../../shared/agent-session-journal-item-key' import type { AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' -import { serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' +import { + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + serializeRemoteRuntimePayload +} from '../../../shared/remote-runtime-memory-limits' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JOURNAL_LOG_FILE } from '../agent-session-journal/journal-log-file' +import { + serializeJournalRow, + type JournalItemRow, + type JournalRow, + type JournalTombstoneRow +} from '../agent-session-journal/journal-row-schema' import { openAgentSessionJournal, type AgentSessionJournal @@ -399,3 +413,220 @@ describe('projectJournalBatch', () => { expect(page.page.removedItemIds).toEqual([]) }) }) + +/** Serialize through the actual channel gate and hand back the byte length. */ +function serializedPageBytes(value: unknown): number { + return Buffer.byteLength(serializeRemoteRuntimePayload(value), 'utf8') +} + +type RawSeedRow = + | Omit + | Omit + +/** Simulate rows admitted before identity bounding existed: written straight + * into the log, then loaded by a fresh journal instance. */ +async function reopenWithRawRows(rows: readonly RawSeedRow[]): Promise { + const full = rows.map( + (row) => + ({ + ...row, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: journal.epoch, + fence: 1, + ts: tick() + }) as JournalRow + ) + await appendFile( + join(root, JOURNAL_LOG_FILE), + `${full.map(serializeJournalRow).join('\n')}\n`, + 'utf-8' + ) + return openAgentSessionJournal({ identity: IDENTITY, journalDir: root, now: tick }) +} + +describe('pre-existing oversized identities', () => { + // The exact escape from the round-two review: a legal 5 MiB Codex turnId + // admitted before bounding, then tombstoned. Its removal id alone exceeds + // the 4 MiB outbound cap, so no page can ever carry it. + const HUGE_ITEM_ID = `codex:thread-1:${'h'.repeat(5 * 1024 * 1024)}:1` + + it('answers an unfittable pre-existing removal with a bounded reset instead of an unsendable page', async () => { + const seq = journal.cursor().sequence + const reopened = await reopenWithRawRows([ + { kind: 'item', itemId: HUGE_ITEM_ID, revision: 1, seq: seq + 1, body: body('big') }, + { kind: 'tombstone', itemId: HUGE_ITEM_ID, revision: 2, seq: seq + 2 } + ]) + + for (const sequence of [seq, seq + 1]) { + const result = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: { epoch: reopened.epoch, sequence }, + limit: 40 + }) + expect(result.ok).toBe(false) + if (result.ok) { + throw new Error('expected a reset') + } + expect(result.reset).toBe('cursor_compacted') + expect(serializedPageBytes(result.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + // The reset page replaces client state wholesale, so the removal is + // applied without carrying the id, and resuming from the live cursor + // starts past the unsendable row. + expect(result.page.items).toEqual([]) + const liveCursor = result.page.liveCursor + if (!liveCursor) { + throw new Error('expected a live cursor on the reset page') + } + expect(liveCursor.sequence).toBeGreaterThanOrEqual(seq + 2) + + const resumed = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: liveCursor, + limit: 40 + }) + expect(resumed.ok).toBe(true) + } + }) + + it('charges removal ids into the page budget and splits catch-up instead of overflowing', async () => { + const seq = journal.cursor().sequence + const removalIds = Array.from( + { length: 30 }, + (_, index) => `codex:thread-1:${'r'.repeat(250 * 1024)}:${index}` + ) + const reopened = await reopenWithRawRows( + removalIds.map((itemId, index) => ({ + kind: 'tombstone' as const, + itemId, + revision: 1, + seq: seq + 1 + index + })) + ) + + const seen = new Set() + let cursor = { epoch: reopened.epoch, sequence: seq } + let guard = 0 + while (true) { + guard += 1 + expect(guard).toBeLessThan(30) + const result = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 40 + }) + if (!result.ok) { + throw new Error(`expected a page, got reset ${result.reset}`) + } + expect(serializedPageBytes(result.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + for (const removed of result.page.removedItemIds) { + seen.add(removed) + } + cursor = result.page.window.nextCursor + if (!result.page.hasNewer) { + break + } + } + expect(seen).toEqual(new Set(removalIds)) + }) + + it('bounds the truncation marker id for a live oversized-id item', async () => { + const seq = journal.cursor().sequence + const reopened = await reopenWithRawRows([ + { kind: 'item', itemId: HUGE_ITEM_ID, revision: 1, seq: seq + 1, body: body('big') } + ]) + + const tail = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'tail', + limit: 40 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(serializedPageBytes(tail.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(tail.page.items).toHaveLength(1) + const marker = tail.page.items[0] + expect(marker?.body.kind).toBe('status') + expect(marker?.itemId).toBe(boundJournalKeyComponent(HUGE_ITEM_ID)) + expect(marker?.itemId.length).toBeLessThan(2048) + + const forward = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: { epoch: reopened.epoch, sequence: seq }, + limit: 40 + }) + if (!forward.ok) { + throw new Error(`expected a page, got reset ${forward.reset}`) + } + expect(serializedPageBytes(forward.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(forward.page.items[0]?.itemId).toBe(boundJournalKeyComponent(HUGE_ITEM_ID)) + }) +}) + +describe('identity bounding at admission', () => { + it('bounds a new oversized provider identity so its item and removal share one sendable key', async () => { + const oversized: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'T'.repeat(5 * 1024 * 1024), + ordinal: 1 + } + const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } + const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + expect(appended.itemId.length).toBeLessThan(2048) + expect(appended.itemId).toContain('~orca-oversized~') + + const beforeTombstone = journal.cursor() + await journal.appendTombstone(oversized, { fence: 1 }) + + const created = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: start, + limit: 40 + }) + if (!created.ok) { + throw new Error(`expected a page, got reset ${created.reset}`) + } + expect(serializedPageBytes(created.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(created.page.removedItemIds).toEqual([appended.itemId]) + + const removal = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: beforeTombstone, + limit: 40 + }) + if (!removal.ok) { + throw new Error(`expected a page, got reset ${removal.reset}`) + } + expect(serializedPageBytes(removal.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(removal.page.removedItemIds).toEqual([appended.itemId]) + }) + + it('keeps bounded keys deterministic and a fixed point of re-derivation', () => { + const oversized = 'x'.repeat(2 * 1024 * 1024) + const bounded = boundJournalKeyComponent(oversized) + expect(bounded).toBe(boundJournalKeyComponent(oversized)) + expect(boundJournalKeyComponent(bounded)).toBe(bounded) + expect(bounded.length).toBeLessThan(2048) + expect(boundJournalKeyComponent(`${oversized}y`)).not.toBe(bounded) + expect(boundJournalKeyComponent('short')).toBe('short') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index 22b0bf762ed..aa79ae8ee85 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -7,7 +7,10 @@ // read would silently skip that revision. Rows carry the revision, which is why // `after` is the only direction that can answer `cursor_compacted`. -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + agentJournalSubmissionKey, + boundJournalKeyComponent +} from '../../../shared/agent-session-journal-item-key' import type { AgentJournalCursor, AgentJournalRenderItem, @@ -34,6 +37,14 @@ import { projectJournalBatch } from './agent-session-journal-batch' * client paging. */ export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2 +/** Reserved for everything the page carries beyond its items and removal ids: + * cursors, session/epoch ids, and the RPC envelope. Charged up front so the + * content budget bounds the COMPLETE serialized result, not just the rows. */ +const HISTORY_PAGE_ENVELOPE_RESERVE_BYTES = 64 * 1024 + +const HISTORY_PAGE_CONTENT_BUDGET_BYTES = + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES - HISTORY_PAGE_ENVELOPE_RESERVE_BYTES + /** Item bytes plus the submission the page would carry alongside it. */ function historyEntryBytes( item: AgentJournalRenderItem, @@ -62,6 +73,10 @@ function oversizedHistoryItem( ): AgentJournalRenderItem { return { ...item, + // A pre-bounding id can exceed the budget by itself; the stand-in must not + // re-inflate the page it exists to bound. Bounding is deterministic, so + // re-reads keep deduplicating on the same key. + itemId: boundJournalKeyComponent(item.itemId), body: { kind: 'status', text: `[Orca: item truncated — ${byteLength} bytes exceeds the history page budget]` @@ -141,7 +156,7 @@ export function readAgentSessionHistory( windowed, 'newest', submissionBytesByItemId(snapshot.submissions), - AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + HISTORY_PAGE_CONTENT_BUDGET_BYTES ) return { ok: true, @@ -175,7 +190,7 @@ function buildHydrationPage( items, 'newest', submissionBytesByItemId(snapshot.submissions), - AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + HISTORY_PAGE_CONTENT_BUDGET_BYTES ) return buildPage({ snapshot, @@ -219,8 +234,18 @@ function readForward( return historyReset(snapshot, since.reset) } const submissionBytes = submissionBytesByItemId(snapshot.submissions) - const batchBytes = (items: readonly AgentJournalRenderItem[]): number => - items.reduce((total, item) => total + historyEntryBytes(item, submissionBytes), 0) + // The page cost is EVERYTHING variable it carries: items with their + // submissions AND removal ids — a legal pre-bounding tombstone id can dwarf + // every item on the page. + const pageContentBytes = ( + items: readonly AgentJournalRenderItem[], + removedItemIds: readonly string[] + ): number => + items.reduce((total, item) => total + historyEntryBytes(item, submissionBytes), 0) + + removedItemIds.reduce( + (total, itemId) => total + Buffer.byteLength(JSON.stringify(itemId), 'utf8') + 1, + 0 + ) // Rows replay forward, so the byte bound shrinks the ROW window rather than // clipping projected items: dropping an item while advancing the cursor past // the rows that touched it would lose that revision for good. @@ -236,7 +261,8 @@ function readForward( } while ( rows.length > 1 && - batchBytes(projected.batch.items) > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > + HISTORY_PAGE_CONTENT_BUDGET_BYTES ) { rows = rows.slice(0, Math.ceil(rows.length / 2)) const shrunk = projectJournalBatch({ @@ -252,14 +278,23 @@ function readForward( } // One row can still touch an over-budget item; degrade it visibly. const items = - batchBytes(projected.batch.items) > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > + HISTORY_PAGE_CONTENT_BUDGET_BYTES ? projected.batch.items.map((item) => { const bytes = historyEntryBytes(item, submissionBytes) - return bytes > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES + return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES ? oversizedHistoryItem(item, bytes) : item }) : projected.batch.items + if (pageContentBytes(items, projected.batch.removedItemIds) > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + // A single row's semantic payload — in practice a pre-bounding oversized + // removal id — can never fit any page, and truncating a removal id would + // break the client's keying. A bounded tail replaces the client's state + // wholesale, which applies the removal without carrying the id, and the + // client resumes from the live cursor past this row. + return historyReset(snapshot, 'cursor_compacted') + } const lastSequence = rows.at(-1)?.seq ?? cursor.sequence return { ok: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts new file mode 100644 index 00000000000..8859365b117 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) + +vi.mock('./structured-agent-session-restart-restore', () => ({ + restoreStructuredAgentSessionsOnRestart: restoreOnRestart +})) + +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' + +describe('StructuredAgentSessionReadableRestorer', () => { + beforeEach(() => { + restoreOnRestart.mockReset().mockResolvedValue(undefined) + }) + + it('passes targeted records to the restore pool in visible-first order', async () => { + const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map( + (sessionId) => ({ sessionId }) as AgentSessionRecord + ) + const restorer = new StructuredAgentSessionReadableRestorer({ + store: { listRecords: () => records } as never, + journalRoot: '/tmp/journals', + supportsRecord: () => true, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined, + restoreHandoff: async () => undefined + }) + + await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) + + expect(restoreOnRestart).toHaveBeenCalledOnce() + expect(restoreOnRestart.mock.calls[0][0].records.map((record) => record.sessionId)).toEqual([ + 'visible-a', + 'visible-b', + 'background-a', + 'background-b' + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts index cf2feba33fc..f4e09509b07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -30,15 +30,23 @@ export class StructuredAgentSessionReadableRestorer { } private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { - const targeted = sessionIds ? new Set(sessionIds) : null + const targetOrder = sessionIds + ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) + : null + const records = this.input.store + .listRecords() + .filter( + (record) => + this.input.supportsRecord(record) && (!targetOrder || targetOrder.has(record.sessionId)) + ) + if (targetOrder) { + records.sort( + (left, right) => targetOrder.get(left.sessionId)! - targetOrder.get(right.sessionId)! + ) + } await restoreStructuredAgentSessionsOnRestart({ ...this.input, - records: this.input.store - .listRecords() - .filter( - (record) => - this.input.supportsRecord(record) && (!targeted || targeted.has(record.sessionId)) - ) + records }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 437cfff38b6..18d2853a9f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -1,11 +1,18 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { appendFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import type { AgentSessionHandoffStatus, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + serializeRemoteRuntimePayload +} from '../../../shared/remote-runtime-memory-limits' +import { JOURNAL_LOG_FILE } from '../agent-session-journal/journal-log-file' +import { serializeJournalRow, type JournalRow } from '../agent-session-journal/journal-row-schema' import { openAgentSessionJournal } from '../agent-session-journal/journal-store' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -102,4 +109,87 @@ describe('AgentSessionSubscribers', () => { handoff }) }) + + it('catches a subscriber up past a pre-existing unsendable removal with a bounded reset', async () => { + const journalDir = join(root, 'oversized-removal-journal') + const seeded = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + // A row admitted before identity bounding: its removal id alone exceeds + // the outbound cap, so no catch-up batch can ever carry it. + const hugeItemId = `codex:thread-1:${'h'.repeat(5 * 1024 * 1024)}:1` + const resumeCursor = seeded.cursor() + const seq = resumeCursor.sequence + const rows: JournalRow[] = [ + { + kind: 'item', + itemId: hugeItemId, + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'big' }] }, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: seeded.epoch, + seq: seq + 1, + fence: 1, + ts: 2_000 + }, + { + kind: 'tombstone', + itemId: hugeItemId, + revision: 2, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: seeded.epoch, + seq: seq + 2, + fence: 1, + ts: 2_001 + } + ] + await appendFile( + join(journalDir, JOURNAL_LOG_FILE), + `${rows.map(serializeJournalRow).join('\n')}\n`, + 'utf-8' + ) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + + const subscribers = new AgentSessionSubscribers() + const events: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 1, + cursor: resumeCursor, + emit: (event) => events.push(event) + }) + + // Every event a remote subscriber receives must fit the outbound channel. + for (const event of events) { + expect(Buffer.byteLength(serializeRemoteRuntimePayload(event), 'utf8')).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + } + const reset = events.find((event) => event.type === 'reset') + expect(reset).toBeDefined() + + // Forward progress: the reset advanced the subscriber past the unsendable + // row, so the next publish has nothing stale to re-deliver. + const settled = events.length + subscribers.publish(SESSION, journal) + expect(events.slice(settled).filter((event) => event.type === 'reset')).toEqual([]) + }) }) diff --git a/src/main/runtime/structured-tui-recovery-claim-match.test.ts b/src/main/runtime/structured-tui-recovery-claim-match.test.ts index 0a654198d31..39495872b93 100644 --- a/src/main/runtime/structured-tui-recovery-claim-match.test.ts +++ b/src/main/runtime/structured-tui-recovery-claim-match.test.ts @@ -4,26 +4,26 @@ import { type StructuredTuiRecoveryClaimCandidate } from './structured-tui-recovery-claim-match' -// Captured from packaged run 31759132745 while the first restart claim failed. +// Modeled after a packaged restart whose first recovery claim failed. const PACKAGED_CANDIDATE: StructuredTuiRecoveryClaimCandidate = { expectedWorkspaceId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/brennanbenson/orca/workspaces/orca/unified-gate', + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', claimMatches: true, pty: { connected: true, ptyId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/brennanbenson/orca/workspaces/orca/unified-gate@@fda34510', + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', incarnationId: '4cf23679-8987-487d-a24c-dba3bed1b442', worktreeId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/brennanbenson/orca/workspaces/orca/unified-gate' + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture' }, owner: { phase: 'live', ptyId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/brennanbenson/orca/workspaces/orca/unified-gate@@fda34510', + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', surface: { worktreeId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/brennanbenson/orca/workspaces/orca/unified-gate', + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', tabId: 'ced3bd39-262b-41f3-a446-92ceab4f938c', leafId: 'd4e9d94d-8ec3-4d0d-8ca3-52730ba61c24' } @@ -32,7 +32,7 @@ const PACKAGED_CANDIDATE: StructuredTuiRecoveryClaimCandidate = { sessionResolved: true, tabPresent: true, ptyId: - '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/brennanbenson/orca/workspaces/orca/unified-gate@@fda34510', + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', incarnationId: null } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts new file mode 100644 index 00000000000..053492cf908 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' + +type SubscribeAttempt = { + closed: PromiseWithResolvers<{ unsubscribe: () => void }> + onClose: () => void + onError: (error: unknown) => void + onEvent: (event: AgentSessionSubscribeEvent) => void + unsubscribe: ReturnType void>> +} + +const target = { kind: 'local' } as const + +function snapshot(sequence: number): AgentSessionSubscribeEvent { + const cursor: AgentJournalCursor = { epoch: 'epoch-a', sequence } + const page: AgentSessionHistoryPage = { + sessionId: 'session-a', + epoch: cursor.epoch, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false + } + return { type: 'snapshot', sessionId: 'session-a', page, fence: sequence } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('structured agent-session read transport generations', () => { + const attempts: SubscribeAttempt[] = [] + + beforeEach(() => { + attempts.length = 0 + vi.clearAllMocks() + mocks.subscribe.mockImplementation((_target, _params, onEvent, onError, onClose) => { + const attempt: SubscribeAttempt = { + closed: Promise.withResolvers<{ unsubscribe: () => void }>(), + onClose, + onError, + onEvent, + unsubscribe: vi.fn<() => void>() + } + attempts.push(attempt) + return attempt.closed.promise + }) + }) + + function start(applyEvent: (event: AgentSessionSubscribeEvent) => void, applyError = vi.fn()) { + return startStructuredAgentSessionReadTransport({ + applyEvent, + applyError, + getCursor: () => null, + refreshTail: async () => undefined, + sessionId: 'session-a', + target + }) + } + + it('ignores opening frames after disposal and a replacement transport starts', async () => { + const applyEvent = vi.fn() + const applyError = vi.fn() + const retired = start(applyEvent, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) + + retired.dispose() + const replacement = start(applyEvent, applyError) + await flushPromises() + expect(attempts).toHaveLength(2) + + attempts[0].onEvent(snapshot(1)) + attempts[0].onError(new Error('retired error')) + attempts[0].onClose() + expect(applyEvent).not.toHaveBeenCalled() + expect(applyError).not.toHaveBeenCalled() + + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(attempts[0].unsubscribe).toHaveBeenCalledOnce() + + attempts[1].onEvent(snapshot(2)) + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2)) + replacement.dispose() + }) + + it('ignores callbacks from a subscription superseded by reconnect', async () => { + vi.useFakeTimers() + try { + const applyEvent = vi.fn() + const applyError = vi.fn() + const transport = start(applyEvent, applyError) + await flushPromises() + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + + attempts[0].onClose() + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + + attempts[0].onEvent(snapshot(1)) + attempts[0].onError(new Error('stale error')) + expect(applyEvent).not.toHaveBeenCalled() + expect(applyError).not.toHaveBeenCalled() + + attempts[1].onEvent(snapshot(2)) + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2)) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + transport.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index d0e22f1b641..5aae1096645 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -39,6 +39,7 @@ export function startStructuredAgentSessionReadTransport(args: { let stopped = false let connected = false let opening = false + let generation = 0 let unsubscribe = (): void => {} let resumeCursor = args.getCursor() const coalescer = createStructuredAgentSessionEventCoalescer(args.applyEvent) @@ -46,7 +47,11 @@ export function startStructuredAgentSessionReadTransport(args: { shouldStop: () => stopped || connected, reconnect: () => void open() }) - const handleEvent = (event: AgentSessionSubscribeEvent): void => { + const isCurrentGeneration = (candidate: number): boolean => !stopped && candidate === generation + const handleEvent = (event: AgentSessionSubscribeEvent, eventGeneration: number): void => { + if (!isCurrentGeneration(eventGeneration)) { + return + } if (event.type === 'snapshot' || event.type === 'reset') { resumeCursor = event.page.liveCursor ?? event.page.window.nextCursor } else if ( @@ -69,6 +74,8 @@ export function startStructuredAgentSessionReadTransport(args: { return } opening = true + coalescer.flush() + const openGeneration = ++generation unsubscribe() unsubscribe = (): void => {} try { @@ -76,22 +83,28 @@ export function startStructuredAgentSessionReadTransport(args: { const handle = await subscribeStructuredAgentSession( args.target, { sessionId: args.sessionId, ...(resumeCursor ? { cursor: resumeCursor } : {}) }, - handleEvent, + (event) => handleEvent(event, openGeneration), (error) => { + if (!isCurrentGeneration(openGeneration)) { + return + } closedDuringOpen = true connected = false args.applyError(String(error)) reconnectScheduler.schedule() }, () => { + if (!isCurrentGeneration(openGeneration)) { + return + } closedDuringOpen = true connected = false reconnectScheduler.schedule() } ) - if (stopped || closedDuringOpen) { + if (!isCurrentGeneration(openGeneration) || closedDuringOpen) { handle.unsubscribe() - if (!stopped) { + if (isCurrentGeneration(openGeneration)) { reconnectScheduler.schedule() } } else { @@ -99,37 +112,57 @@ export function startStructuredAgentSessionReadTransport(args: { unsubscribe = handle.unsubscribe } } catch (error) { + if (!isCurrentGeneration(openGeneration)) { + return + } connected = false args.applyError(String(error)) reconnectScheduler.schedule() } finally { - opening = false + if (openGeneration === generation) { + opening = false + } } } const refresh = (): void => { + const refreshGeneration = generation void args .refreshTail(() => stopped) .then(() => { + if (!isCurrentGeneration(refreshGeneration)) { + return + } resumeCursor = args.getCursor() if (!connected) { reconnectScheduler.schedule(0) } }) - .catch((error) => args.applyError(String(error))) + .catch((error) => { + if (isCurrentGeneration(refreshGeneration)) { + args.applyError(String(error)) + } + }) } + const initialGeneration = generation void args .refreshTail(() => stopped) .then(() => { + if (!isCurrentGeneration(initialGeneration)) { + return + } resumeCursor = args.getCursor() return open() }) .catch((error) => { - args.applyError(String(error)) - reconnectScheduler.schedule() + if (isCurrentGeneration(initialGeneration)) { + args.applyError(String(error)) + reconnectScheduler.schedule() + } }) return { dispose: () => { stopped = true + generation += 1 reconnectScheduler.dispose() coalescer.dispose() unsubscribe() diff --git a/src/shared/agent-session-journal-item-key.ts b/src/shared/agent-session-journal-item-key.ts index 9548bc4de63..31c4b77b69b 100644 --- a/src/shared/agent-session-journal-item-key.ts +++ b/src/shared/agent-session-journal-item-key.ts @@ -8,8 +8,39 @@ import type { AgentJournalItemIdentity } from './agent-session-journal-types' const KEY_DELIMITER = ':' +/** Longest raw component a key may embed. Real provider ids are tens of bytes; + * anything larger would push the composed key past wire page budgets, so it + * travels as a stable digest instead of verbatim. */ +export const MAX_JOURNAL_KEY_COMPONENT_CHARS = 1024 + +/** + * Deterministic stand-in for an oversized key component: same input, same + * output, so revisions and tombstones of one identity still share a key, and + * re-deriving from a parsed key is a fixed point (the bounded form is far + * below the cap). The head keeps keys debuggable; length plus two independent + * hashes makes an accidental collision practically impossible. Pure JS because + * clients derive keys too and cannot reach node:crypto. + */ +export function boundJournalKeyComponent(value: string): string { + if (value.length <= MAX_JOURNAL_KEY_COMPONENT_CHARS) { + return value + } + const h1 = fnv1a32(value, 0x811c9dc5).toString(16).padStart(8, '0') + const h2 = fnv1a32(value, 0x0100_0193).toString(16).padStart(8, '0') + return `${value.slice(0, 40)}~orca-oversized~${value.length}~${h1}${h2}` +} + +function fnv1a32(value: string, seed: number): number { + let hash = seed >>> 0 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x0100_0193) >>> 0 + } + return hash >>> 0 +} + function encodePart(value: string | number): string { - return encodeURIComponent(String(value)) + return encodeURIComponent(boundJournalKeyComponent(String(value))) } /** diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index c11bd8fd43f..874ab972787 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -14,6 +14,8 @@ # `detached: true` for a process-group kill, a synchronous taskkill, and an stdio shape other # than all-pipes (a long-lived `ssh -N` must not be handed a stdin pipe nobody closes). # Migrating them means teaching run-process.ts those options first. +# codex-app-server-posix-supervisor.ts requires child_process only inside its +# dependency-free POSIX supervisor script, never in Orca's process. # # The src/main/git/command-runner/* entries are the seven spawn sites that used # to live in the single line src/main/git/runner.ts; splitting that file moved @@ -45,7 +47,6 @@ src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-posix-supervisor.ts -src/main/codex/codex-app-server-process-teardown.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-state-db-backfill-recovery.ts src/main/codex/codex-wsl-hook-install-plan.ts