Merge remote-tracking branch 'origin/main' into mobile-rearch

# Conflicts:
#	mobile/src/session/use-mobile-native-chat-image-attachments.ts
This commit is contained in:
Jinwoo-H
2026-09-04 18:41:52 -04:00
452 changed files with 19201 additions and 6538 deletions
File diff suppressed because one or more lines are too long
@@ -22,6 +22,20 @@ function workingStatus(): AgentAwakeStatus {
}
}
describe('AgentAwakeService status array ownership', () => {
it('does not observe rows appended to the caller array after setStatuses', () => {
const service = new AgentAwakeService()
service.setMode('auto')
const statuses: AgentAwakeStatus[] = [workingStatus()]
service.setStatuses(statuses)
const before = service.getWorkingAgentCount()
statuses.push(workingStatus(), workingStatus())
expect(service.getWorkingAgentCount()).toBe(before)
})
})
function createBlocker() {
const startedIds = new Set<number>()
let nextId = 1
+4 -2
View File
@@ -105,7 +105,8 @@ export class AgentAwakeService {
}
setStatuses(statuses: AgentAwakeStatus[]): void {
this.statuses = statuses.map((status) => ({ ...status }))
// Copy the array, not every row: the hook server allocates each row fresh per event.
this.statuses = [...statuses]
this.refresh('status-change')
}
@@ -171,7 +172,8 @@ export class AgentAwakeService {
private getEligibleRunningStatusCount(): number {
const now = this.now()
return this.statuses.filter((status) => this.isWakeEligible(status, now)).length
// Counted in place: the filtered array was only ever measured, and this runs per hook event.
return this.statuses.reduce((count, s) => count + (this.isWakeEligible(s, now) ? 1 : 0), 0)
}
private isWakeEligible(status: AgentAwakeStatus, now: number): boolean {
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { parseClaudeUsageRecord } from './transcript-record-parser'
function assistantLine(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-01-01T00:00:00.000Z',
message: { usage: { input_tokens: 3, output_tokens: 5 } },
...overrides
})
}
describe('assistant-record prefilter', () => {
it('still parses an ordinary assistant record', () => {
expect(parseClaudeUsageRecord(assistantLine())?.inputTokens).toBe(3)
})
it('rejects a user record that never mentions assistant', () => {
const userLine = JSON.stringify({
type: 'user',
sessionId: 'session-1',
timestamp: '2026-01-01T00:00:00.000Z',
message: { content: 'x'.repeat(200) }
})
expect(parseClaudeUsageRecord(userLine)).toBeNull()
})
it('rejects a non-assistant record that happens to contain the word assistant', () => {
const userLine = JSON.stringify({
type: 'user',
sessionId: 'session-1',
timestamp: '2026-01-01T00:00:00.000Z',
message: { content: 'ask the assistant about this' }
})
expect(parseClaudeUsageRecord(userLine)).toBeNull()
})
})
@@ -84,10 +84,30 @@ function dedupeClaudeUsageTurns(
return deduped
}
/**
* Necessary condition for `JSON.parse(line).type === 'assistant'`, checked before the parse.
*
* Sound for any transcript written by a standard JSON serializer: `JSON.stringify` (which writes
* these files) escapes only quotes, backslashes and control characters, never ASCII letters, so
* the decoded value can only be `assistant` if the line spells it literally. The gate over-admits
* freely — the `parsed.type` check below stays authoritative.
*
* A `\u`-escape fallback was measured and rejected: it costs a second full-line scan and made
* transcripts whose tool results contain control characters 1.43x slower overall.
*/
function mayEncodeAssistantType(line: string): boolean {
return line.includes('assistant')
}
function parseClaudeUsageSourceRecord(
line: string,
fallbackSessionId: string | null = null
): ClaudeUsageParsedSourceTurn | null {
// Only assistant records carry usage, but transcripts interleave user/tool-result lines that
// routinely embed whole files. Reject those before paying for a full parse.
if (!mayEncodeAssistantType(line)) {
return null
}
let parsed: ClaudeUsageSourceRecord
try {
parsed = JSON.parse(line) as ClaudeUsageSourceRecord
@@ -1,7 +1,14 @@
import type { ChildProcess } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
findSelfInitiatedTreeKills,
resetSelfInitiatedTreeKillLogForTest
} from '../crash-reporting/self-initiated-tree-kill-log'
import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown'
/** Above pid_max on every supported POSIX host, so the group signal is a real ESRCH. */
const UNREACHABLE_PGID = 2_147_483_647
function child() {
return {
pid: 1234,
@@ -10,6 +17,10 @@ function child() {
}
describe('terminateCodexAppServerProcessTree', () => {
beforeEach(() => {
resetSelfInitiatedTreeKillLogForTest()
})
it('waits for the Windows tree kill before releasing the wrapper', async () => {
const target = child()
const release = Promise.withResolvers<void>()
@@ -120,6 +131,55 @@ describe('terminateCodexAppServerProcessTree', () => {
expect(target.kill).not.toHaveBeenCalled()
})
/**
* `selfInitiatedTreeKillCount` decides whether a `render-process-gone` was
* ours. A group that had already exited was killed by nobody, so crediting it
* puts a suspect in the five-second window that Orca never issued. Exercised
* through the real `process.kill(-pgid)` because the swallow being tested
* lives in the production default, not in an injectable seam.
*/
it('does not claim a snapshot group that was already gone', async () => {
const target = { pid: UNREACHABLE_PGID, kill: vi.fn(() => true) as ChildProcess['kill'] }
await expect(
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'darwin',
captureDescendants: async () => ({
rootPgid: UNREACHABLE_PGID,
descendants: [],
capturedAtMs: 1
}),
terminateDescendants: async () => true
})
).resolves.toBe(true)
expect(target.kill).toHaveBeenLastCalledWith('SIGKILL')
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([])
})
it('claims a snapshot group the signal actually reached', async () => {
const target = child()
const signalProcessGroup = vi.fn()
await expect(
terminateCodexAppServerProcessTree(target, undefined, {
platform: 'darwin',
captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }),
terminateDescendants: async () => true,
signalProcessGroup
})
).resolves.toBe(true)
expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL')
expect(findSelfInitiatedTreeKills(Date.now())).toEqual([
expect.objectContaining({
pid: 1234,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
])
})
it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => {
const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true))
const targets = killMocks.map((kill, index) => ({
@@ -128,19 +128,24 @@ async function terminatePosixTree(
if (descendantsExited && snapshot.rootPgid === rootPid) {
const signalGroup =
deps.signalProcessGroup ??
((pgid: number, signal: NodeJS.Signals) => {
try {
process.kill(-pgid, signal)
} catch {
// Group already exited.
}
((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal))
let groupSignalled = false
try {
signalGroup(snapshot.rootPgid, 'SIGKILL')
groupSignalled = true
} catch {
// Already-gone is still the desired outcome, but nothing here killed it,
// and a crumb for a kill we never landed is a false render-process-gone suspect.
}
if (groupSignalled) {
// Outside the try, as in terminateDedicatedPosixGroup: that catch is the
// already-gone contract, not a breadcrumb handler.
recordSelfInitiatedTreeKill({
pid: snapshot.rootPgid,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
signalGroup(snapshot.rootPgid, 'SIGKILL')
recordSelfInitiatedTreeKill({
pid: snapshot.rootPgid,
site: 'codex-app-server-teardown',
scope: 'posix-process-group'
})
}
}
if (!descendantsExited) {
child.kill('SIGCONT')
@@ -20,10 +20,7 @@ export function codexJournalPromptIdPart(value: string): string {
}
const suffix = `#${digestPayload(value).slice(0, 32)}`
const bounded = boundPayload(value, {
inlineHeadBytes: CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES - suffix.length,
maxSessionBytes: Number.MAX_SAFE_INTEGER,
maxAppendsPerWindow: Number.MAX_SAFE_INTEGER,
appendWindowMs: Number.MAX_SAFE_INTEGER
inlineHeadBytes: CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES - suffix.length
})
return `${bounded.head}${suffix}`
}
@@ -17,14 +17,8 @@ export function codexStructuredItemKey(threadId: string, itemId: string): string
return `${key.slice(0, 960)}:${(hash >>> 0).toString(16)}`
}
export function pendingPatchBytes(pending: {
body: unknown
blobs: readonly { payload: string }[]
}): number {
return (
Buffer.byteLength(JSON.stringify(pending.body), 'utf8') +
pending.blobs.reduce((total, blob) => total + Buffer.byteLength(blob.payload, 'utf8'), 0)
)
export function pendingPatchBytes(pending: { body: unknown }): number {
return Buffer.byteLength(JSON.stringify(pending.body), 'utf8')
}
export function boundStreamItem(item: Record<string, unknown>): Record<string, unknown> {
@@ -24,7 +24,6 @@ export type CodexItemStreamState = {
export type CodexPendingItemPatch = {
identity: AgentJournalItemIdentity
body: NonNullable<ReturnType<typeof codexJournalItem>['body']>
blobs: ReturnType<typeof codexJournalItem>['blobs']
}
export type CodexStructuredItemStreamAdmission =
@@ -103,8 +103,8 @@ export function createCodexStructuredItemStreams(
}
const options = { coalescingKey: `checkpoint:${agentJournalItemKey(state.identity)}` }
const admission = deps.sink.tryAppendItem
? deps.sink.tryAppendItem(state.identity, translated.body, translated.blobs, options)
: (deps.sink.appendItem(state.identity, translated.body, translated.blobs, options),
? deps.sink.tryAppendItem(state.identity, translated.body, options)
: (deps.sink.appendItem(state.identity, translated.body, options),
{ accepted: true as const })
if (!admission.accepted) {
return false
@@ -167,9 +167,8 @@ export function createCodexStructuredItemStreams(
}
for (const [key, pending] of pendingPatches) {
const admission = deps.sink.tryAppendItem
? deps.sink.tryAppendItem(pending.identity, pending.body, pending.blobs)
: (deps.sink.appendItem(pending.identity, pending.body, pending.blobs),
{ accepted: true as const })
? deps.sink.tryAppendItem(pending.identity, pending.body)
: (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const })
if (!admission.accepted) {
flushed = false
continue
@@ -193,9 +192,8 @@ export function createCodexStructuredItemStreams(
return { accepted: true }
}
const admission = deps.sink.tryAppendItem
? deps.sink.tryAppendItem(pending.identity, pending.body, pending.blobs)
: (deps.sink.appendItem(pending.identity, pending.body, pending.blobs),
{ accepted: true as const })
? deps.sink.tryAppendItem(pending.identity, pending.body)
: (deps.sink.appendItem(pending.identity, pending.body), { accepted: true as const })
if (!admission.accepted) {
return admission
}
@@ -237,8 +235,7 @@ export function createCodexStructuredItemStreams(
if (translated.body) {
const nextPending: CodexPendingItemPatch = {
identity: state.identity,
body: translated.body,
blobs: translated.blobs
body: translated.body
}
const previous = pendingPatches.get(key)
const previousBytes = previous ? pendingPatchBytes(previous) : 0
@@ -220,12 +220,6 @@ describe('codex item bodies', () => {
throw new Error('expected bounded command output')
}
expect(body.output.head.length).toBeLessThan(20_000)
expect(translated.blobs).toEqual([
{
digest: body.output.digest,
payload: output
}
])
})
it('continues to accept camel-case command completion output', () => {
@@ -172,7 +172,6 @@ function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed'
export type CodexJournalItem = {
body: AgentJournalItemBody | null
blobs: { digest: string; payload: string }[]
handled: boolean
}
@@ -190,10 +189,6 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
state: commandState(item),
...(bounded === null ? {} : { output: bounded.bounded })
},
blobs:
output !== null && bounded?.bounded.truncated
? [{ digest: bounded.bounded.digest, payload: output }]
: [],
handled: true
}
}
@@ -215,7 +210,6 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
input: boundToolInput({ changes: item.changes ?? null }, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: commandState(item)
},
blobs: [],
handled: true
}
}
@@ -227,7 +221,6 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
path: changes.length === 1 ? changes[0]!.path : `${changes.length} files`,
patch: bounded
},
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: patch }] : [],
handled: true
}
}
@@ -246,7 +239,6 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
blocks.length === 0
? null
: { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks },
blobs: [],
handled: true
}
}
@@ -266,14 +258,11 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
text === null
? null
: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text },
blobs: [],
handled: true
}
}
const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item)
return unhandled
? { body: unhandled.body, blobs: unhandled.blobs, handled: false }
: { body: null, blobs: [], handled: true }
return unhandled ? { body: unhandled.body, handled: false } : { body: null, handled: true }
}
export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null {
@@ -292,7 +281,7 @@ export function codexStreamingMessageBody(text: string): AgentJournalItemBody {
/** Snapshot body for any item-level stream, keyed onto its parent item. */
export function codexStreamingJournalItem(item: CodexThreadItem, text: string): CodexJournalItem {
if (item.type === 'agentMessage') {
return { body: codexStreamingMessageBody(text), blobs: [], handled: true }
return { body: codexStreamingMessageBody(text), handled: true }
}
if (item.type === 'commandExecution') {
return commandItem({ ...item, aggregatedOutput: text })
@@ -304,10 +293,9 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string):
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded
return {
body: { kind: 'diff', path: path ?? 'pending patch', patch: bounded },
blobs: bounded.truncated ? [{ digest: bounded.digest, payload: text }] : [],
handled: true
}
}
const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return { body: { kind: 'status', text: bounded.text }, blobs: [], handled: true }
return { body: { kind: 'status', text: bounded.text }, handled: true }
}
@@ -91,13 +91,11 @@ export class CodexJournalGenericFrames {
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(
{ provider: 'orca', clientMessageId: `provider-frame:codex:${this.fallbackSequence}` },
translated.body,
translated.blobs
translated.body
)
: (this.deps.sink.appendItem(
{ provider: 'orca', clientMessageId: `provider-frame:codex:${this.fallbackSequence}` },
translated.body,
translated.blobs
translated.body
),
CODEX_JOURNAL_ADMITTED)
if (!admission.accepted) {
@@ -136,13 +134,11 @@ export class CodexJournalGenericFrames {
kind: 'status',
text
},
[],
{ coalescingKey: `provider-frame-suppressed:codex:${bucket}` }
)
: (this.deps.sink.appendItem(
{ provider: 'orca', clientMessageId: `provider-frame-suppressed:codex:${bucket}` },
{ kind: 'status', text },
[],
{ coalescingKey: `provider-frame-suppressed:codex:${bucket}` }
),
CODEX_JOURNAL_ADMITTED)
@@ -2,7 +2,7 @@ import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-lifecycle-capacity'
import { requiresTerminalSettlement } from '../native-chat/agent-session-journal/journal-terminal-settlement'
import {
codexItemIdentity,
codexJournalItem,
@@ -126,19 +126,13 @@ export class CodexJournalItems {
return CODEX_JOURNAL_ADMITTED
}
if (method === 'item/completed') {
const admission = appendCodexLifecycleItem(
this.deps.sink,
identity,
translated.body,
translated.blobs
)
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, translated.blobs, options)
: (this.deps.sink.appendItem(identity, translated.body, translated.blobs),
CODEX_JOURNAL_ADMITTED)
? this.deps.sink.tryAppendItem(identity, translated.body, options)
: (this.deps.sink.appendItem(identity, translated.body), CODEX_JOURNAL_ADMITTED)
if (!admission.accepted) {
return admission
}
@@ -243,14 +243,12 @@ function appendLifecycleMutations(
for (const mutation of chunk) {
if (mutation.kind === 'item') {
if (sink.tryAppendItem) {
admission = sink.tryAppendItem(mutation.identity, mutation.body, [], {
lifecycle: true
})
admission = sink.tryAppendItem(mutation.identity, mutation.body, { lifecycle: true })
if (!admission.accepted) {
return admission
}
} else {
sink.appendItem(mutation.identity, mutation.body, [], { lifecycle: true })
sink.appendItem(mutation.identity, mutation.body, { lifecycle: true })
}
} else {
if (sink.tryAppendTombstone) {
@@ -4,7 +4,6 @@ import type {
} from '../../shared/agent-session-journal-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionJournalBlob,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement'
@@ -20,13 +19,12 @@ function criticalAdmission(
export function appendCodexLifecycleItem(
sink: StructuredAgentSessionEventSink,
identity: AgentJournalItemIdentity,
body: AgentJournalItemBody,
blobs: readonly StructuredAgentSessionJournalBlob[] = []
body: AgentJournalItemBody
): CodexJournalTranslationAdmission {
if (sink.tryAppendItem) {
return criticalAdmission(sink.tryAppendItem(identity, body, blobs, { lifecycle: true }))
return criticalAdmission(sink.tryAppendItem(identity, body, { lifecycle: true }))
}
sink.appendItem(identity, body, blobs, { lifecycle: true })
sink.appendItem(identity, body, { lifecycle: true })
return CODEX_JOURNAL_ADMITTED
}
@@ -89,11 +89,11 @@ describe('codex journal translation', () => {
const { translator, tap } = translatorWith()
let rejectTerminal = true
const appendItem = tap.sink.appendItem
tap.sink.tryAppendItem = (identity, body, blobs, options) => {
tap.sink.tryAppendItem = (identity, body, options) => {
if (rejectTerminal && body.kind === 'tool-call' && body.state === 'failed') {
return { accepted: false as const, reason: 'backpressure' as const }
}
appendItem(identity, body, blobs, options)
appendItem(identity, body, options)
return { accepted: true as const }
}
for (let index = 0; index <= 256; index += 1) {
@@ -40,7 +40,6 @@ export function publishCodexTurnLifecycle(input: {
text: 'Codex is working…',
turnLifecycle: { turnId: input.turnId, state: input.state }
},
[],
{ lifecycle: true }
)
: (input.sink.appendItem(
@@ -50,7 +49,6 @@ export function publishCodexTurnLifecycle(input: {
text: 'Codex is working…',
turnLifecycle: { turnId: input.turnId, state: input.state }
},
[],
{ lifecycle: true }
),
ADMITTED)
+1 -4
View File
@@ -37,10 +37,7 @@ export class CodexTurnOrdinals {
const suffix = `#${digestPayload(value).slice(0, 24)}`
return `${
boundPayload(encoded, {
inlineHeadBytes: 256 - Buffer.byteLength(suffix, 'utf8'),
maxSessionBytes: Number.MAX_SAFE_INTEGER,
maxAppendsPerWindow: Number.MAX_SAFE_INTEGER,
appendWindowMs: Number.MAX_SAFE_INTEGER
inlineHeadBytes: 256 - Buffer.byteLength(suffix, 'utf8')
}).head
}${suffix}`
}
@@ -1,77 +0,0 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
// ─── System memory at gone time ─────────────────────────────────────
// Why: the system outlives the crashed process, so this IS sampleable at
// process-gone — it separates "renderer grew huge" from "machine out of
// memory/commit", which the per-process buckets alone cannot.
// Timing honesty: this reads AFTER the crashed process's memory returned to
// the OS, so free/swapFree can look healthier than they were at kill time.
// Platform honesty: swap* exist on Windows/Linux only. On Linux `free` is
// /proc/meminfo MemFree and is NOT the pressure signal — it excludes page cache
// and other reclaimable memory; `available` (MemAvailable, Linux-only) is. On
// macOS `free` is near-meaningless (file cache and compression keep it low on
// healthy machines); fileBacked/purgeable are the only reclaimability proxy this
// API gives there, and none of these fields answers "was the machine under
// pressure" on macOS — that needs a signal Electron does not expose.
type CrashReportDetails = Record<string, CrashReportDetailValue>
export function memoryKBFieldMB(value: unknown): number | undefined {
const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined
return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024)
}
type SystemMemoryInfoLike = {
total?: unknown
free?: unknown
available?: unknown
swapTotal?: unknown
swapFree?: unknown
fileBacked?: unknown
purgeable?: unknown
}
type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null
function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null {
const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike })
.getSystemMemoryInfo
if (typeof read !== 'function') {
return null
}
try {
return read.call(process)
} catch {
return null
}
}
let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo
export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void {
systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo
}
export function getSystemMemoryAtGoneDetails(): CrashReportDetails {
const info = systemMemoryInfoReader()
if (!info) {
return {}
}
const details: CrashReportDetails = {}
const fields: readonly [keyof SystemMemoryInfoLike, string][] = [
['total', 'systemMemoryTotalMB'],
['free', 'systemMemoryFreeMB'],
['available', 'systemMemoryAvailableMB'],
['swapTotal', 'systemMemorySwapTotalMB'],
['swapFree', 'systemMemorySwapFreeMB'],
['fileBacked', 'systemMemoryFileBackedMB'],
['purgeable', 'systemMemoryPurgeableMB']
]
for (const [field, key] of fields) {
const mb = memoryKBFieldMB(info[field])
if (mb !== undefined) {
details[key] = mb
}
}
return details
}
@@ -0,0 +1,379 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getSystemMemoryDetails,
setSystemMemoryInfoReaderForTest,
withSwapVolumeFreeSpace
} from './system-memory-details'
import {
readSwapVolumeFreeSpace,
setSwapVolumeFreeSpaceReaderForTest,
type SwapVolumeFreeSpace
} from './swap-volume-free-space'
import { samplePreGoneSystemMemory } from './pre-gone-host-memory'
import {
buildProcessGoneCrashDetails,
resetPreGoneCrashSamplingForTest,
samplePreGoneProcessMetrics,
startPreGoneCrashSampling
} from './process-gone-diagnostics'
type MetricFixture = {
pid: number
creationTime: number
type: string
memory: { workingSetSize: number; peakWorkingSetSize?: number; privateBytes?: number }
}
const { appMetricsMock } = vi.hoisted(() => ({
appMetricsMock: vi.fn<() => MetricFixture[]>(() => [])
}))
vi.mock('electron', () => ({ app: { getAppMetrics: appMetricsMock } }))
const BROWSER_AND_RENDERER: MetricFixture[] = [
{ pid: 10, creationTime: 1, type: 'Browser', memory: { workingSetSize: 1024 * 250 } },
{
pid: 11,
creationTime: 2,
type: 'Tab',
memory: { workingSetSize: 1024 * 400, peakWorkingSetSize: 1024 * 420, privateBytes: 1024 * 260 }
}
]
const BROWSER_ONLY: MetricFixture[] = [BROWSER_AND_RENDERER[0]]
const UNDER_COMMIT_PRESSURE = {
total: 16_000 * 1024,
free: 400 * 1024,
swapTotal: 48_000 * 1024,
swapFree: 200 * 1024
}
const AFTER_THE_CORPSE_RELEASED = {
total: 16_000 * 1024,
free: 3_000 * 1024,
swapTotal: 48_000 * 1024,
swapFree: 2_900 * 1024
}
// Commit limit ~= RAM: a disabled or fixed pagefile, which no amount of empty
// disk can grow into. `swapTotal > total` is all this API can say about that.
const FIXED_PAGEFILE_UNDER_PRESSURE = {
total: 16_000 * 1024,
free: 300 * 1024,
swapTotal: 16_100 * 1024,
swapFree: 180 * 1024
}
const NO_PAGEFILE_UNDER_PRESSURE = {
...FIXED_PAGEFILE_UNDER_PRESSURE,
swapTotal: 15_900 * 1024
}
const BEFORE_THE_STORM = {
total: 16_000 * 1024,
free: 9_000 * 1024,
swapTotal: 48_000 * 1024,
swapFree: 30_000 * 1024
}
describe('pre-gone host memory', () => {
beforeEach(() => {
resetPreGoneCrashSamplingForTest()
setSystemMemoryInfoReaderForTest(null)
setSwapVolumeFreeSpaceReaderForTest(null)
appMetricsMock.mockClear()
appMetricsMock.mockReturnValue(BROWSER_AND_RENDERER)
})
it('carries a pre-gone host reading, not only the post-mortem one', async () => {
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
await samplePreGoneSystemMemory(Date.now() - 5_000)
// The renderer dies; its ~400 MB returns to the OS, so the gone-time read
// now shows a much healthier machine than the one that refused the alloc.
setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED)
appMetricsMock.mockReturnValue(BROWSER_ONLY)
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
expect(details.systemMemorySwapFreeMB).toBe(2_900)
expect(details.systemMemoryPreGoneSwapFreeMB).toBe(200)
expect(details.systemMemoryPreGoneFreeMB).toBe(400)
expect(details.systemMemoryPreGoneTotalMB).toBe(16_000)
// Why: host memory keeps its own key family, so a `systemMemory` prefix scan sees both reads.
expect(
Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem'))
).toEqual([])
})
// Why this decides the cluster: 200 MB available commit is only a REFUSAL when
// the pagefile cannot grow, which is what the volume's free space says.
it('reports swap-volume free space so low commit can be told from refused commit', async () => {
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
await samplePreGoneSystemMemory(Date.now() - 5_000)
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(120)
// Which volume was measured: Windows only names the DEFAULT pagefile drive.
expect(details.systemMemoryPreGoneSwapVolume).toBe('C:')
})
it('omits swap-volume free space on Linux, where swap cannot grow into free disk', async () => {
// Linux swap is a fixed partition, a fixed-size swapfile, or zram; reporting
// root-fs free space next to SwapFreeMB 0 would read as headroom that is not there.
setSwapVolumeFreeSpaceReaderForTest(null)
await expect(readSwapVolumeFreeSpace('linux')).resolves.toBeUndefined()
})
it('labels the reading with the pressure verdict the platform can actually give', () => {
// Windows available commit is only a REFUSAL when the pagefile cannot grow,
// which nothing here proves, so no label may read as that verdict.
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
const windowsCommit = getSystemMemoryDetails('win32')
expect(windowsCommit.systemMemoryPressureSignal).toBe('available-commit-unqualified')
expect(
withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32')
.systemMemoryPressureSignal
).toBe('available-commit-volume-cotimed')
// A volume number from a different moment describes a different machine.
expect(
withSwapVolumeFreeSpace(windowsCommit, { freeMB: 120, volume: 'C:' }, 'win32', false)
.systemMemoryPressureSignal
).toBe('available-commit-unqualified')
setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, free: 400 * 1024 }))
expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('none')
setSystemMemoryInfoReaderForTest(() => ({ total: 16_000 * 1024, available: 900 * 1024 }))
expect(getSystemMemoryDetails('linux').systemMemoryPressureSignal).toBe('mem-available')
// darwin free/fileBacked/purgeable answer reclaimability, never pressure.
setSystemMemoryInfoReaderForTest(() => ({
total: 16_000 * 1024,
free: 272 * 1024,
fileBacked: 2_694 * 1024,
purgeable: 0
}))
expect(getSystemMemoryDetails('darwin').systemMemoryPressureSignal).toBe('none')
})
// Why this and not the volume number: the branch's own repro needed a pagefile
// that CANNOT grow to kill anything, and neither the pagefile maximum nor its
// drive is readable here — `swapVolumeAnchor` measures SystemRoot's volume,
// which a relocated pagefile does not live on.
it('never reads free disk as proof the pagefile could have grown', () => {
setSystemMemoryInfoReaderForTest(() => FIXED_PAGEFILE_UNDER_PRESSURE)
const fixedPagefile = withSwapVolumeFreeSpace(
getSystemMemoryDetails('win32'),
{ freeMB: 812_000, volume: 'C:' },
'win32'
)
// 180 MB of commit beside 812 GB of free disk: co-timed, and still not a
// verdict — reading it as "the pagefile had room" is the opposite conclusion.
expect(fixedPagefile.systemMemoryPressureSignal).toBe('available-commit-volume-cotimed')
// The one decisive win32 case: commit limit at or below RAM means there is
// no pagefile behind it, so the floor cannot heal however empty the disk is.
setSystemMemoryInfoReaderForTest(() => NO_PAGEFILE_UNDER_PRESSURE)
expect(
withSwapVolumeFreeSpace(
getSystemMemoryDetails('win32'),
{ freeMB: 812_000, volume: 'C:' },
'win32'
).systemMemoryPressureSignal
).toBe('available-commit-hard-capped')
})
// Why the verdict and not just the field: a statfs issued on a healthy host at
// t=0 that resolves 20 s into a commit storm prints "200 MB commit, 40 GB of
// pagefile headroom" — which reads as NOT a commit refusal, the opposite
// conclusion, under the branch's most confident label.
it('will not let a statfs that outlived its tick qualify the win32 commit verdict', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers()
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
try {
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
setSwapVolumeFreeSpaceReaderForTest(
() =>
new Promise<SwapVolumeFreeSpace>((resolve) => {
resolveVolume = resolve
})
)
void samplePreGoneSystemMemory(0)
// The storm arrives; the in-flight latch makes every tick skip the merge,
// so the pending statfs is as old as the tick that STARTED it.
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
await samplePreGoneSystemMemory(10_000)
await samplePreGoneSystemMemory(20_000)
resolveVolume({ freeMB: 40_000, volume: 'C:' })
await vi.advanceTimersByTimeAsync(0)
vi.setSystemTime(20_000)
const stale = buildProcessGoneCrashDetails({}, 'renderer')
expect(stale.systemMemoryPreGoneSwapFreeMB).toBe(200)
// The pre-storm volume number still ships — but carrying its own age, and
// without promoting the verdict the analyst reads.
expect(stale.systemMemoryPreGoneSwapVolumeFreeMB).toBe(40_000)
expect(stale.systemMemoryPreGoneSampleAgeMs).toBe(0)
expect(stale.systemMemoryPreGoneSwapVolumeAgeMs).toBe(20_000)
expect(stale.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
// The next tick's statfs answers on its own tick, so it qualifies again.
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 900, volume: 'C:' }))
await samplePreGoneSystemMemory(30_000)
vi.setSystemTime(30_000)
const fresh = buildProcessGoneCrashDetails({}, 'renderer')
expect(fresh.systemMemoryPreGoneSwapVolumeFreeMB).toBe(900)
expect(fresh.systemMemoryPreGoneSwapVolumeAgeMs).toBe(0)
expect(fresh.systemMemoryPreGonePressureSignal).toBe('available-commit-volume-cotimed')
} finally {
vi.useRealTimers()
Object.defineProperty(process, 'platform', platform)
}
})
// Round 5: sample identity alone could not see these ticks. A host read that
// returns nothing leaves the sample object in place, so `sample === issuedFor`
// still held 25 s and two ticks later and the statfs re-qualified the verdict.
it('will not let ticks with a failed host read pass a stale statfs off as co-timed', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers()
let resolveVolume: (value: SwapVolumeFreeSpace) => void = () => {}
try {
setSystemMemoryInfoReaderForTest(() => BEFORE_THE_STORM)
setSwapVolumeFreeSpaceReaderForTest(
() =>
new Promise<SwapVolumeFreeSpace>((resolve) => {
resolveVolume = resolve
})
)
void samplePreGoneSystemMemory(0)
// GlobalMemoryStatusEx starts failing: the sample is neither replaced nor erased.
setSystemMemoryInfoReaderForTest(() => null)
await samplePreGoneSystemMemory(10_000)
await samplePreGoneSystemMemory(20_000)
resolveVolume({ freeMB: 40_000, volume: 'C:' })
await vi.advanceTimersByTimeAsync(0)
vi.setSystemTime(25_000)
const details = buildProcessGoneCrashDetails({}, 'renderer')
// 25 s of lag: the label must not say co-timed beside that age.
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(25_000)
expect(details.systemMemoryPreGonePressureSignal).toBe('available-commit-unqualified')
} finally {
vi.useRealTimers()
Object.defineProperty(process, 'platform', platform)
}
})
it("arms the host sampler on its own unref'd 10 s timer, not the metric sweep's", async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
const readHostMemory = vi.fn(() => UNDER_COMMIT_PRESSURE)
setSystemMemoryInfoReaderForTest(readHostMemory)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 120, volume: 'C:' }))
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
try {
startPreGoneCrashSampling()
// Literal millisecond values: asserting the constants against themselves
// would let a cadence regression through, and 37 s of staleness is the bug.
expect(setIntervalSpy.mock.calls.map(([, ms]) => ms)).toEqual([60_000, 10_000])
for (const { value } of setIntervalSpy.mock.results) {
expect((value as NodeJS.Timeout).hasRef()).toBe(false)
}
expect(readHostMemory).toHaveBeenCalledTimes(1)
readHostMemory.mockReturnValue(AFTER_THE_CORPSE_RELEASED)
await vi.advanceTimersByTimeAsync(10_000)
// One host tick, no extra metric sweep: the two samplers run independently.
expect(readHostMemory).toHaveBeenCalledTimes(2)
expect(appMetricsMock).toHaveBeenCalledTimes(1)
const details = buildProcessGoneCrashDetails({}, 'renderer')
expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0)
expect(details.systemMemoryPreGoneSwapFreeMB).toBe(2_900)
} finally {
setIntervalSpy.mockRestore()
vi.useRealTimers()
}
})
it('commits the host reading without waiting on the swap-volume statfs', async () => {
// Why: statfs is slowest during the paging storm this sampler targets, and
// a hung volume must not stall or silently skip host sampling.
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => new Promise(() => {}))
void samplePreGoneSystemMemory(Date.now() - 5_000)
expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(200)
// A second tick still refreshes the reading while that statfs hangs.
setSystemMemoryInfoReaderForTest(() => AFTER_THE_CORPSE_RELEASED)
void samplePreGoneSystemMemory(Date.now())
expect(buildProcessGoneCrashDetails({}, 'renderer').systemMemoryPreGoneSwapFreeMB).toBe(2_900)
})
it('publishes no pre-gone host keys when every memory field failed to read', async () => {
// Why not "no keys at all": the reading always carries its signal label, so a
// committed empty one would ship an age and a volume number with no memory
// numbers beside them — a disk-free figure standing in for a host reading.
setSystemMemoryInfoReaderForTest(() => ({ total: Number.NaN, free: undefined }))
await samplePreGoneSystemMemory(Date.now())
const details = buildProcessGoneCrashDetails({}, 'renderer')
expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([])
})
it('carries the last volume reading forward, aged, instead of dropping it', async () => {
vi.useFakeTimers()
try {
setSystemMemoryInfoReaderForTest(() => UNDER_COMMIT_PRESSURE)
setSwapVolumeFreeSpaceReaderForTest(() => Promise.resolve({ freeMB: 42, volume: 'C:' }))
await samplePreGoneSystemMemory(0)
// The next tick's statfs hangs — during the paging storm this targets, that
// is the normal case — so the tick has no volume reading of its own, and
// the sample that replaces the last one would otherwise drop the field.
setSwapVolumeFreeSpaceReaderForTest(() => new Promise<never>(() => {}))
void samplePreGoneSystemMemory(10_000)
vi.setSystemTime(10_000)
const details = buildProcessGoneCrashDetails({}, 'renderer')
expect(details.systemMemoryPreGoneSwapVolumeFreeMB).toBe(42)
expect(details.systemMemoryPreGoneSwapVolume).toBe('C:')
expect(details.systemMemoryPreGoneSampleAgeMs).toBe(0)
// Carried, not re-read: it ships at its real age, never as a fresh number.
expect(details.systemMemoryPreGoneSwapVolumeAgeMs).toBe(10_000)
} finally {
vi.useRealTimers()
}
})
it('keeps a failed host read from erasing the process-metric sample', async () => {
samplePreGoneProcessMetrics(Date.now() - 5_000)
setSystemMemoryInfoReaderForTest(() => {
throw new Error('getSystemMemoryInfo unavailable')
})
await samplePreGoneSystemMemory(Date.now() - 5_000)
setSystemMemoryInfoReaderForTest(null)
const details = buildProcessGoneCrashDetails({ processType: 'renderer' }, 'renderer')
expect(details.processMetricsPreGoneRendererWorkingSetMB).toBe(400)
expect(Object.keys(details).filter((key) => key.startsWith('systemMemoryPreGone'))).toEqual([])
})
})
@@ -0,0 +1,164 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
import { readSwapVolumeFreeSpace } from './swap-volume-free-space'
import {
getSystemMemoryDetails,
SYSTEM_MEMORY_KEY_PREFIX,
withSwapVolumeFreeSpace
} from './system-memory-details'
// ─── Pre-gone host memory sampling ──────────────────────────────────
// Why sample at all: the gone-time host read lands after the corpse released
// its pages, so it reports a healthier machine than the one that refused the
// allocation.
// Why 10 s and not the 60 s process-metrics cadence: at 60 s, four of five
// field OOMs carried a ~37 s old host reading — far too stale to see a
// transient commit refusal. A refusal shorter than the interval stays
// invisible; no cadence fixes that.
export const PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS = 10_000
type CrashReportDetails = Record<string, CrashReportDetailValue>
type PreGoneSystemMemorySample = {
details: CrashReportDetails
sampledAtMs: number
/** Tick that ISSUED the statfs now merged in — never the tick it resolved on. */
swapVolumeSampledAtMs?: number
}
let preGoneSample: PreGoneSystemMemorySample | null = null
let preGoneTimer: ReturnType<typeof setInterval> | null = null
let swapVolumeReadInFlight = false
let samplingGeneration = 0
let sampleTick = 0
const PRESSURE_SIGNAL_KEY = `${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`
/**
* Carries the last volume reading onto the sample that replaces its own.
*
* Why: a statfs slower than one tick would otherwise make the field vanish from
* the reports it exists for — the next tick replaces the sample wholesale, and
* the in-flight latch keeps intervening ticks from merging anything. It ships
* with its own (now larger) age and, not being co-timed, never names the label.
*/
function withCarriedSwapVolume(sample: PreGoneSystemMemorySample): PreGoneSystemMemorySample {
const previous = preGoneSample
if (!previous || previous.swapVolumeSampledAtMs === undefined) {
return sample
}
const freeMB = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]
const volume = previous.details[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]
if (typeof freeMB !== 'number' || typeof volume !== 'string') {
return sample
}
return {
...sample,
details: withSwapVolumeFreeSpace(sample.details, { freeMB, volume }, process.platform, false),
swapVolumeSampledAtMs: previous.swapVolumeSampledAtMs
}
}
function commitHostMemorySample(nowMs: number): boolean {
try {
const details = getSystemMemoryDetails()
// Why not `length === 0`: the signal label is appended unconditionally, so a
// reading that resolved no memory field at all still arrives with one key.
if (!Object.keys(details).some((key) => key !== PRESSURE_SIGNAL_KEY)) {
return false
}
preGoneSample = withCarriedSwapVolume({ details, sampledAtMs: nowMs })
return true
} catch {
// Why: a failed read must not erase the previous good sample.
return false
}
}
async function mergeSwapVolumeFreeSpace(issuedOnTick: number): Promise<void> {
if (swapVolumeReadInFlight) {
return
}
swapVolumeReadInFlight = true
const generation = samplingGeneration
const issuedFor = preGoneSample
try {
const volume = await readSwapVolumeFreeSpace()
if (volume && preGoneSample && generation === samplingGeneration) {
// Why only its own tick qualifies: a statfs that outlived its tick carries a
// pre-storm volume number, and the latch makes that lag unbounded. It still
// ships beside its age, but it may not decide the verdict.
// Why the tick counter and not sample identity: a tick whose host read fails
// leaves the sample object in place, so identity alone reads as co-timed.
const coTimed = issuedOnTick === sampleTick
preGoneSample = {
...preGoneSample,
details: withSwapVolumeFreeSpace(preGoneSample.details, volume, process.platform, coTimed),
swapVolumeSampledAtMs: issuedFor?.sampledAtMs
}
}
} catch {
// Why: the memory reading is already committed and stands on its own.
} finally {
swapVolumeReadInFlight = false
}
}
export async function samplePreGoneSystemMemory(nowMs: number = Date.now()): Promise<void> {
// Why commit before awaiting: the volume read is a statfs, and under the very
// paging storm this targets it is slowest — it must never delay, or (via an
// in-flight latch) skip, the cheap synchronous host reading.
const tick = ++sampleTick
if (!commitHostMemorySample(nowMs)) {
return
}
await mergeSwapVolumeFreeSpace(tick)
}
export function startPreGoneSystemMemorySampling(
intervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS
): void {
if (preGoneTimer) {
return
}
void samplePreGoneSystemMemory()
preGoneTimer = setInterval(() => void samplePreGoneSystemMemory(), intervalMs)
preGoneTimer.unref?.()
}
export function resetPreGoneSystemMemorySamplingForTest(): void {
if (preGoneTimer) {
clearInterval(preGoneTimer)
}
preGoneTimer = null
preGoneSample = null
swapVolumeReadInFlight = false
// Why bump: an already-awaited volume read must not repopulate a reset sample.
samplingGeneration += 1
}
/** Keyed as `systemMemoryPreGone*` so a scan over the `systemMemory` family sees both reads. */
export function preGoneSystemMemoryDetails(nowMs: number): CrashReportDetails {
if (!preGoneSample) {
return {}
}
const details: CrashReportDetails = {
[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSampleAgeMs`]: Math.max(
0,
nowMs - preGoneSample.sampledAtMs
)
}
// Why its own age: the volume read resolves out of band, so it can be older
// than the memory reading printed beside it, and that gap must be readable.
if (preGoneSample.swapVolumeSampledAtMs !== undefined) {
details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGoneSwapVolumeAgeMs`] = Math.max(
0,
nowMs - preGoneSample.swapVolumeSampledAtMs
)
}
for (const [key, value] of Object.entries(preGoneSample.details)) {
details[`${SYSTEM_MEMORY_KEY_PREFIX}PreGone${key.slice(SYSTEM_MEMORY_KEY_PREFIX.length)}`] =
value
}
return details
}
@@ -2,11 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildProcessGoneCrashDetails,
collectProcessGoneMetricDetails,
resetPreGoneProcessMetricsSamplingForTest,
resetPreGoneCrashSamplingForTest,
samplePreGoneProcessMetrics,
startPreGoneProcessMetricsSampling
startPreGoneCrashSampling
} from './process-gone-diagnostics'
import { setSystemMemoryInfoReaderForTest } from './gone-time-system-memory'
import { setSystemMemoryInfoReaderForTest } from './system-memory-details'
type MetricFixture = {
pid?: number
@@ -27,7 +27,7 @@ vi.mock('electron', () => ({
describe('process gone diagnostics', () => {
beforeEach(() => {
resetPreGoneProcessMetricsSamplingForTest()
resetPreGoneCrashSamplingForTest()
setSystemMemoryInfoReaderForTest(null)
})
@@ -141,8 +141,8 @@ describe('process gone diagnostics', () => {
appMetricsMock.mockReturnValue([
{ pid: 30, type: 'Tab', memory: { workingSetSize: 1024 * 100 } }
])
startPreGoneProcessMetricsSampling(1_000)
startPreGoneProcessMetricsSampling(1_000)
startPreGoneCrashSampling(1_000)
startPreGoneCrashSampling(1_000)
// A crash inside the first interval already has a sample to draw from.
expect(buildProcessGoneCrashDetails({}, 'renderer')).toMatchObject({
@@ -582,12 +582,12 @@ describe('process gone diagnostics', () => {
it("arms an unref'd interval so sampling never holds the event loop open", () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
try {
startPreGoneProcessMetricsSampling(60_000)
startPreGoneCrashSampling(60_000)
const timer = setIntervalSpy.mock.results[0]?.value as NodeJS.Timeout
expect(timer.hasRef()).toBe(false)
} finally {
setIntervalSpy.mockRestore()
resetPreGoneProcessMetricsSamplingForTest()
resetPreGoneCrashSamplingForTest()
}
})
@@ -641,7 +641,7 @@ describe('process gone diagnostics', () => {
expect(details.systemMemoryTotalMB).toBe(16_384)
})
it('samples system memory at gone time but never into the pre-gone snapshot', () => {
it('samples system memory at gone time but never into the processMetrics family', () => {
appMetricsMock.mockReturnValue([{ pid: 1, type: 'Browser', memory: { workingSetSize: 0 } }])
samplePreGoneProcessMetrics()
setSystemMemoryInfoReaderForTest(() => ({
@@ -658,7 +658,9 @@ describe('process gone diagnostics', () => {
systemMemorySwapTotalMB: 8_192,
systemMemorySwapFreeMB: 40
})
expect(details.processMetricsPreGoneSystemMemoryTotalMB).toBeUndefined()
expect(
Object.keys(details).filter((key) => key.startsWith('processMetricsPreGoneSystem'))
).toEqual([])
})
it('leaves records unflagged when the crashed bucket is still populated', () => {
@@ -3,7 +3,13 @@ import {
sanitizeCrashReportDetails,
type CrashReportDetailValue
} from '../../shared/crash-reporting'
import { getSystemMemoryAtGoneDetails, memoryKBFieldMB } from './gone-time-system-memory'
import { getSystemMemoryDetails, memoryKBFieldMB } from './system-memory-details'
import {
PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS,
preGoneSystemMemoryDetails,
resetPreGoneSystemMemorySamplingForTest,
startPreGoneSystemMemorySampling
} from './pre-gone-host-memory'
type ProcessMetricLike = {
pid?: unknown
@@ -204,8 +210,9 @@ export function samplePreGoneProcessMetrics(nowMs: number = Date.now()): void {
}
}
export function startPreGoneProcessMetricsSampling(
intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS
export function startPreGoneCrashSampling(
intervalMs: number = PROCESS_METRICS_PRE_GONE_SAMPLE_INTERVAL_MS,
systemMemoryIntervalMs: number = PRE_GONE_SYSTEM_MEMORY_SAMPLE_INTERVAL_MS
): void {
if (preGoneSampleTimer) {
return
@@ -213,14 +220,16 @@ export function startPreGoneProcessMetricsSampling(
samplePreGoneProcessMetrics()
preGoneSampleTimer = setInterval(() => samplePreGoneProcessMetrics(), intervalMs)
preGoneSampleTimer.unref?.()
startPreGoneSystemMemorySampling(systemMemoryIntervalMs)
}
export function resetPreGoneProcessMetricsSamplingForTest(): void {
export function resetPreGoneCrashSamplingForTest(): void {
if (preGoneSampleTimer) {
clearInterval(preGoneSampleTimer)
}
preGoneSampleTimer = null
preGoneSample = null
resetPreGoneSystemMemorySamplingForTest()
}
const PROCESS_METRICS_KEY_PREFIX = 'processMetrics'
@@ -271,7 +280,7 @@ export function buildProcessGoneCrashDetails(
const crashDetails: CrashReportDetails = {
...sanitizedDetails,
...liveMetricDetails,
...getSystemMemoryAtGoneDetails()
...getSystemMemoryDetails()
}
// Why: with the crasher gone, Largest names a survivor — flag that so the
// live buckets are read as "everyone else", not as the crashed process.
@@ -290,8 +299,10 @@ export function buildProcessGoneCrashDetails(
if (liveMetricDetails[crashedBucketCountKey] === 0 || sampledSameBucketProcessVanished) {
crashDetails.processMetricsCrashedProcessAbsent = true
}
const nowMs = Date.now()
if (preGoneSample) {
Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, Date.now()))
Object.assign(crashDetails, preGoneSampleDetails(preGoneSample, nowMs))
}
Object.assign(crashDetails, preGoneSystemMemoryDetails(nowMs))
return crashDetails
}
@@ -0,0 +1,67 @@
import { statfs } from 'node:fs/promises'
import path from 'node:path'
// Why: a system-managed Windows pagefile — and a macOS swapfile — only grows
// into free space on its own volume, so low available commit is a REFUSED
// allocation only when that volume is full too. Linux is excluded on purpose:
// its swap is a fixed partition, a fixed-size swapfile, or zram, none of which
// grow into root-fs free space, so the number would read as headroom that
// cannot exist. The measured volume ships alongside because Windows only names
// the DEFAULT pagefile drive; a relocated pagefile lives elsewhere.
const BYTES_PER_MB = 1024 * 1024
export type SwapVolumeFreeSpace = {
freeMB: number
/** Which volume was measured, separator-trimmed so redaction sees no path. */
volume: string
}
type SwapVolumeFreeSpaceReader = (
platform: NodeJS.Platform
) => Promise<SwapVolumeFreeSpace | undefined>
function swapVolumeAnchor(platform: NodeJS.Platform): string | undefined {
if (platform === 'win32') {
const anchor = process.env.SystemRoot || process.env.SystemDrive
return anchor ? path.parse(anchor).root || anchor : undefined
}
return platform === 'darwin' ? path.sep : undefined
}
function volumeLabel(root: string): string {
const trimmed = root.replace(/[\\/]+$/, '')
return trimmed.length > 0 ? trimmed : root
}
async function statfsSwapVolumeFreeSpace(
platform: NodeJS.Platform
): Promise<SwapVolumeFreeSpace | undefined> {
const root = swapVolumeAnchor(platform)
if (!root) {
return undefined
}
try {
const stats = await statfs(root)
const bytes = Number(stats.bsize) * Number(stats.bavail)
return Number.isFinite(bytes)
? { freeMB: Math.round(Math.max(0, bytes) / BYTES_PER_MB), volume: volumeLabel(root) }
: undefined
} catch {
return undefined
}
}
let swapVolumeFreeSpaceReader: SwapVolumeFreeSpaceReader = statfsSwapVolumeFreeSpace
export function setSwapVolumeFreeSpaceReaderForTest(
reader: SwapVolumeFreeSpaceReader | null
): void {
swapVolumeFreeSpaceReader = reader ?? statfsSwapVolumeFreeSpace
}
export function readSwapVolumeFreeSpace(
platform: NodeJS.Platform = process.platform
): Promise<SwapVolumeFreeSpace | undefined> {
return swapVolumeFreeSpaceReader(platform)
}
@@ -0,0 +1,161 @@
import type { CrashReportDetailValue } from '../../shared/crash-reporting'
import type { SwapVolumeFreeSpace } from './swap-volume-free-space'
// ─── Host system memory for crash reports ───────────────────────────
// Why: the system outlives the crashed process, so this IS sampleable at
// process-gone — it separates "renderer grew huge" from "machine out of
// memory/commit", which the per-process buckets alone cannot. The gone-time
// caller reads AFTER the corpse returned its pages, so free/swapFree read
// healthier than at kill time; the pre-gone sampler carries a live reading past
// that.
// Every reading is labelled `systemMemoryPressureSignal` so no report can be
// read as a pressure verdict the platform never gave:
// win32 — swapFree is MEMORYSTATUSEX.ullAvailPageFile, i.e. available
// COMMIT, which pagefile growth can heal (a 127 MB commit floor healed to
// 2029 MB mid-hold on the win-lowspec repro, killing nothing). Free space on
// the swap volume does NOT establish that it could: a fixed-size or disabled
// pagefile grows into no amount of empty disk, its maximum is unreadable
// here (needs a registry read), and the measured volume is only the DEFAULT
// pagefile drive. So a co-timed volume reading is context beside the commit
// number — `available-commit-volume-cotimed` — never a verdict. The one
// decisive win32 case is a commit limit at or below RAM: no pagefile exists
// to grow, so the floor cannot heal (`available-commit-hard-capped`).
// linux — MemAvailable is the real signal; MemFree is not (it excludes page
// cache and other reclaimable memory).
// darwin — none. `free` stays low on healthy machines and
// fileBacked/purgeable are only a reclaimability proxy. The real signal
// needs `memory_pressure -Q`; Orca's reader for it
// (src/main/memory/host-memory.ts) is on-demand, and spawning a subprocess
// on a 10 s app-lifetime timer costs more than the gap it closes.
type CrashReportDetails = Record<string, CrashReportDetailValue>
export const SYSTEM_MEMORY_KEY_PREFIX = 'systemMemory'
export function memoryKBFieldMB(value: unknown): number | undefined {
const kb = typeof value === 'number' && Number.isFinite(value) ? value : undefined
return kb === undefined ? undefined : Math.round(Math.max(0, kb) / 1024)
}
type SystemMemoryInfoLike = {
total?: unknown
free?: unknown
available?: unknown
swapTotal?: unknown
swapFree?: unknown
fileBacked?: unknown
purgeable?: unknown
}
type SystemMemoryInfoReader = () => SystemMemoryInfoLike | null
/** How far this reading may be read as a "was the host under pressure" verdict. */
export type SystemMemoryPressureSignal =
| 'available-commit-hard-capped'
| 'available-commit-volume-cotimed'
| 'available-commit-unqualified'
| 'mem-available'
| 'none'
function readElectronSystemMemoryInfo(): SystemMemoryInfoLike | null {
const read = (process as NodeJS.Process & { getSystemMemoryInfo?: () => SystemMemoryInfoLike })
.getSystemMemoryInfo
if (typeof read !== 'function') {
return null
}
try {
return read.call(process)
} catch {
return null
}
}
let systemMemoryInfoReader: SystemMemoryInfoReader = readElectronSystemMemoryInfo
export function setSystemMemoryInfoReaderForTest(reader: SystemMemoryInfoReader | null): void {
systemMemoryInfoReader = reader ?? readElectronSystemMemoryInfo
}
function numericDetail(details: CrashReportDetails, suffix: string): number | undefined {
const value = details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`]
return typeof value === 'number' ? value : undefined
}
/** Windows commit limit = RAM + pagefile, so a limit at or below RAM has no pagefile behind it. */
function pagefileBacksCommit(details: CrashReportDetails): boolean | undefined {
const total = numericDetail(details, 'TotalMB')
const swapTotal = numericDetail(details, 'SwapTotalMB')
return total === undefined || swapTotal === undefined ? undefined : swapTotal > total
}
function pressureSignal(
platform: NodeJS.Platform,
details: CrashReportDetails,
volumeCoTimed = true
): SystemMemoryPressureSignal {
if (platform === 'win32' && `${SYSTEM_MEMORY_KEY_PREFIX}SwapFreeMB` in details) {
if (pagefileBacksCommit(details) === false) {
return 'available-commit-hard-capped'
}
return volumeCoTimed && `${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB` in details
? 'available-commit-volume-cotimed'
: 'available-commit-unqualified'
}
if (platform === 'linux' && `${SYSTEM_MEMORY_KEY_PREFIX}AvailableMB` in details) {
return 'mem-available'
}
return 'none'
}
export function getSystemMemoryDetails(
platform: NodeJS.Platform = process.platform
): CrashReportDetails {
const info = systemMemoryInfoReader()
if (!info) {
return {}
}
const details: CrashReportDetails = {}
const fields: readonly [keyof SystemMemoryInfoLike, string][] = [
['total', 'TotalMB'],
['free', 'FreeMB'],
['available', 'AvailableMB'],
['swapTotal', 'SwapTotalMB'],
['swapFree', 'SwapFreeMB'],
['fileBacked', 'FileBackedMB'],
['purgeable', 'PurgeableMB']
]
for (const [field, suffix] of fields) {
const mb = memoryKBFieldMB(info[field])
if (mb !== undefined) {
details[`${SYSTEM_MEMORY_KEY_PREFIX}${suffix}`] = mb
}
}
details[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, details)
return details
}
/**
* Merges the statfs-derived volume datum, which needs an await and so is only
* reachable from the periodic sampler, and relabels the reading it sits beside.
*
* `coTimed` false means the statfs outlived the tick that issued it, so this
* volume number and the commit number beside it describe different moments —
* during a pagefile-growth storm that is exactly when they diverge, and a
* pre-storm 40 GB printed next to 200 MB of commit reads as "the pagefile had
* room", the opposite conclusion. The datum still ships (with its own age), but
* only a co-timed one is named in the label.
*/
export function withSwapVolumeFreeSpace(
details: CrashReportDetails,
volume: SwapVolumeFreeSpace,
platform: NodeJS.Platform = process.platform,
coTimed = true
): CrashReportDetails {
const merged: CrashReportDetails = {
...details,
[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolumeFreeMB`]: volume.freeMB,
[`${SYSTEM_MEMORY_KEY_PREFIX}SwapVolume`]: volume.volume
}
merged[`${SYSTEM_MEMORY_KEY_PREFIX}PressureSignal`] = pressureSignal(platform, merged, coTimed)
return merged
}
+6 -1
View File
@@ -10,6 +10,7 @@ import {
prepareWslLinkedWorktreeGitRouting
} from '../wsl-linked-worktree-git-routing'
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import { annotateWslHostFailure } from './wsl-host-failure'
import type { GitAdmissionTier, GitExecOptions } from './git-exec-options'
import { execFileCapture, execFileCaptureToTermination } from './exec-file-capture'
import {
@@ -96,7 +97,7 @@ async function gitExecFileAsyncUnlocked(
? {}
: { createTimeoutError: () => new GitCommandTimeoutError(timeoutMs) })
}
return options.terminationBarrier
const captured = options.terminationBarrier
? execFileCaptureToTermination(
command.binary,
command.args,
@@ -104,6 +105,10 @@ async function gitExecFileAsyncUnlocked(
command.termination
)
: execFileCapture(command.binary, command.args, captureOptions)
// Why: a dead WSL distro fails with an empty stderr, so the span would carry no cause at all.
return captured.catch((error: unknown) => {
throw annotateWslHostFailure(error, command)
})
}
const runCapturedCommand = async (): Promise<{ stdout: string; stderr: string }> => {
let result: { stdout: string | Buffer; stderr: string | Buffer }
@@ -63,6 +63,11 @@ export function nonInteractiveGitEnv(
platform: NodeJS.Platform = process.platform
): NodeJS.ProcessEnv {
const next = promptGuardGitEnv(env, platform)
if (platform === 'win32') {
// Why: without it wsl.exe writes its OWN failures ("no distribution with the supplied name") as
// UTF-16LE (#9010), which is how a dead distro reached telemetry as an error with no text.
next.WSL_UTF8 = '1'
}
if (!next.GIT_SSH_COMMAND) {
next.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes'
if (platform === 'win32') {
@@ -0,0 +1,143 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))
vi.mock('node:child_process', () => ({
execFile: execFileMock,
execFileSync: vi.fn(),
spawn: vi.fn()
}))
vi.mock('../../observability/instrumentation', () => ({
withGitSpan: (_attributes: unknown, run: (span: unknown) => unknown) =>
run({ setAttribute: () => {} })
}))
vi.mock('../../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
import { gitExecFileAsync } from '../runner'
import { _resetGitAdmissionForTests } from './git-subprocess-admission'
import { nonInteractiveGitEnv } from './git-process-env'
import { annotateWslHostFailure, readWslHostFailureDiagnostic } from './wsl-host-failure'
import type { ResolvedCommand } from './wsl-command-resolution'
const WSL_COMMAND: ResolvedCommand = {
binary: 'wsl.exe',
args: ['-d', 'kali-linux', '--exec', 'sh', '-lc', 'git worktree list'],
cwd: 'C:\\Users\\paulius',
wsl: { distro: 'kali-linux', linuxPath: '/home/paulius/bugbounty' },
wslMode: 'login-shell'
}
const WSL_DIAGNOSTIC =
'There is no distribution with the supplied name.\r\nError code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\r\n'
/** wsl.exe without WSL_UTF8 writes its own diagnostic as UTF-16LE, which reaches Node as NUL-riddled text. */
function asUtf16Mojibake(text: string): string {
return [...text].map((character) => `${character}\u0000`).join('')
}
function hostFailure(stdout: string): Error {
return Object.assign(new Error('Command failed: wsl.exe -d kali-linux --exec sh -lc ...'), {
code: 4294967295,
stdout,
stderr: ''
})
}
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
describe('wsl.exe host failure classification', () => {
it('reads the diagnostic wsl.exe left on stdout, including UTF-16 output', () => {
expect(readWslHostFailureDiagnostic(hostFailure(WSL_DIAGNOSTIC), WSL_COMMAND)).toContain(
'Wsl/Service/WSL_E_DISTRO_NOT_FOUND'
)
expect(
readWslHostFailureDiagnostic(hostFailure(asUtf16Mojibake(WSL_DIAGNOSTIC)), WSL_COMMAND)
).toContain('Wsl/Service/WSL_E_DISTRO_NOT_FOUND')
})
it('leaves a guest failure and a non-WSL command alone', () => {
const guestFailure = Object.assign(new Error('Command failed'), {
code: 1,
stdout: '',
stderr: 'fatal: not a git repository\n'
})
expect(readWslHostFailureDiagnostic(guestFailure, WSL_COMMAND)).toBeNull()
// Same exit code, but wsl.exe was never involved.
expect(
readWslHostFailureDiagnostic(hostFailure(WSL_DIAGNOSTIC), {
binary: 'git',
args: ['status'],
cwd: '/repo',
wsl: null,
wslMode: null
})
).toBeNull()
})
it('moves the diagnostic into the message the span records', () => {
const error = annotateWslHostFailure(hostFailure(WSL_DIAGNOSTIC), WSL_COMMAND) as Error & {
wslHostFailure?: boolean
wslDistro?: string
code?: number
}
expect(error.message).toContain('Wsl/Service/WSL_E_DISTRO_NOT_FOUND')
expect(error.message).toContain('kali-linux')
expect(error.wslHostFailure).toBe(true)
expect(error.wslDistro).toBe('kali-linux')
// The original failure detail must survive for callers that classify on it.
expect(error.code).toBe(4294967295)
expect(error.message).toContain('Command failed: wsl.exe')
})
})
describe('WSL-routed git subprocess', () => {
beforeEach(() => {
execFileMock.mockReset()
})
afterEach(() => {
_resetGitAdmissionForTests()
})
it('sets WSL_UTF8 so wsl.exe explains itself in UTF-8', () => {
expect(nonInteractiveGitEnv({}, 'win32').WSL_UTF8).toBe('1')
expect(nonInteractiveGitEnv({}, 'darwin').WSL_UTF8).toBeUndefined()
})
it('reports a dead distro instead of an empty git error', async () => {
execFileMock.mockImplementation((_command, _args, _options, callback) => {
const child = new EventEmitter() as EventEmitter & { pid: number; kill: () => void }
child.pid = 4321
child.kill = () => {}
queueMicrotask(() =>
callback?.(
hostFailure(asUtf16Mojibake(WSL_DIAGNOSTIC)),
asUtf16Mojibake(WSL_DIAGNOSTIC),
''
)
)
return child
})
const failure = await withPlatform('win32', () =>
gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: '\\\\wsl.localhost\\kali-linux\\home\\paulius\\bugbounty'
}).then(
() => null,
(error: unknown) => error as Error
)
)
expect(failure?.message).toContain('Wsl/Service/WSL_E_DISTRO_NOT_FOUND')
expect(execFileMock.mock.calls.at(-1)?.[2]?.env?.WSL_UTF8).toBe('1')
})
})
@@ -0,0 +1,55 @@
import type { ResolvedCommand } from './wsl-command-resolution'
/** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */
export const WSL_HOST_FAILURE_EXIT_CODE = 0xffffffff
function outputText(value: unknown): string {
if (typeof value === 'string') {
return value
}
return Buffer.isBuffer(value) ? value.toString('utf8') : ''
}
/**
* The message wsl.exe prints when it — not the guest — failed: a distro that was renamed or
* removed, or a VM that would not start.
*
* Why this needs decoding at all: wsl.exe exits 0xFFFFFFFF, leaves stderr EMPTY, and writes
* `Error code: Wsl/Service/WSL_E_*` to stdout, so every caller that reports stderr reports nothing.
* NULs are stripped because a wsl.exe that ignores WSL_UTF8 writes that line as UTF-16LE (#9010).
*/
export function readWslHostFailureDiagnostic(
error: unknown,
command: ResolvedCommand
): string | null {
if (!command.wsl || !error || typeof error !== 'object') {
return null
}
const { code, status, stdout, stderr } = error as {
code?: unknown
status?: unknown
stdout?: unknown
stderr?: unknown
}
const exitCode = typeof code === 'number' ? code : typeof status === 'number' ? status : null
// A guest failure always explains itself on stderr; an empty one plus this exit is the host.
if (exitCode !== WSL_HOST_FAILURE_EXIT_CODE || outputText(stderr).trim().length > 0) {
return null
}
const diagnostic = outputText(stdout).replaceAll('\u0000', '').trim()
return diagnostic.length > 0 ? diagnostic : 'wsl.exe reported no diagnostic.'
}
/**
* Move a wsl.exe host failure into the error's message, which is what `git.exec` spans record ahead
* of the stack. Left untouched when the failure came from git itself.
*/
export function annotateWslHostFailure(error: unknown, command: ResolvedCommand): unknown {
const diagnostic = readWslHostFailureDiagnostic(error, command)
if (diagnostic === null || !(error instanceof Error) || !command.wsl) {
return error
}
const distro = command.wsl.distro
error.message = `wsl.exe host failure (distro "${distro}"): ${diagnostic}\n${error.message}`
return Object.assign(error, { wslHostFailure: true, wslDistro: distro })
}
+16 -11
View File
@@ -26,17 +26,22 @@ export async function getSubmoduleStatus(
const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath)
const limit = resolveGitStatusLimit(options.limit)
// Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work.
const workingResult = options.staged
? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult)
: await getStatus(submoduleWorktreePath, options)
// Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows.
const fromOid = options.staged
? await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)
: (await readGitlinkOidFromIndex(worktreePath, submodulePath, options)) ||
(await readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options))
const toOid = options.staged
? await readGitlinkOidFromIndex(worktreePath, submodulePath, options)
: await readWorkingSubmoduleHead(submoduleWorktreePath, options)
// These three reads are independent, so they run concurrently — on SSH/WSL each one is a real round trip.
const [workingResult, fromOid, toOid] = await Promise.all([
options.staged
? Promise.resolve<GitStatusResult>({ entries: [], conflictOperation: 'unknown' })
: getStatus(submoduleWorktreePath, options),
// Why: a moved gitlink (clean worktree) has no status rows; surface the parent-commit→checkout range as inner rows.
options.staged
? readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)
: readGitlinkOidFromIndex(worktreePath, submodulePath, options).then(
(indexOid) =>
indexOid || readGitlinkOidFromTree(worktreePath, 'HEAD', submodulePath, options)
),
options.staged
? readGitlinkOidFromIndex(worktreePath, submodulePath, options)
: readWorkingSubmoduleHead(submoduleWorktreePath, options)
])
if (fromOid && toOid && fromOid !== toOid) {
const rangeEntries = await computeSubmoduleRangeEntries(
submoduleWorktreePath,
+31
View File
@@ -395,4 +395,35 @@ describe('getSubmoduleStatus', () => {
expect(result.didHitLimit).toBe(true)
expect(result.statusLength).toBe(2)
})
it('overlaps the inner status, index and HEAD reads instead of serializing them', async () => {
const OLD_OID = 'a'.repeat(40)
const NEW_OID = 'b'.repeat(40)
readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n')
existsSyncMock.mockReturnValue(false)
const events: string[] = []
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
const name = args.includes('status') ? 'status' : args[0]
events.push(`start:${name}`)
if (name === 'status') {
// Hold the inner status open so a serialized caller could not have started the oid reads.
await new Promise((resolve) => setTimeout(resolve, 5))
}
events.push(`end:${name}`)
if (name === 'ls-files') {
return { stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }
}
if (name === 'rev-parse') {
return { stdout: `${NEW_OID}\n` }
}
return { stdout: '' }
})
await getSubmoduleStatus('/repo', 'flutter_mine')
// Serialized reads only start ls-files/rev-parse once the inner status has resolved.
expect(events.indexOf('start:ls-files')).toBeLessThan(events.indexOf('end:status'))
expect(events.indexOf('start:rev-parse')).toBeLessThan(events.indexOf('end:status'))
})
})
+42 -22
View File
@@ -35,17 +35,7 @@ export async function listWorktreeGraph(
? worktrees
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return []
}
}
}
if (isNotGitRepositoryError(err)) {
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
return []
}
console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err)
@@ -64,17 +54,7 @@ export async function listWorktreesUnshared(
: worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree))
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return []
}
}
}
if (isNotGitRepositoryError(err)) {
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
return []
}
// Why: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors.
@@ -83,6 +63,24 @@ export async function listWorktreesUnshared(
}
}
/**
* The two failures where an empty listing is the repo's true answer, not a broken scan: the repo
* path is gone, or it is not a Git repo. Every other failure means the scan could not read Git.
*/
async function isTrueEmptyWorktreeListing(repoPath: string, err: unknown): Promise<boolean> {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return true
}
}
}
return isNotGitRepositoryError(err)
}
export async function listWorktreesStrict(
repoPath: string,
options: GitWorktreeExecOptions = {}
@@ -97,6 +95,28 @@ export async function listWorktreesStrict(
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
}
/**
* Strict except for the two true empties above.
*
* Why: a Git or host failure (dead WSL distro, hung mount) softened to `[]` reaches the detected
* listing as an *authoritative* empty scan, which then permanently prunes the repo's worktrees and
* the agent tabs attached to them. Rejecting keeps that listing non-authoritative, while a deleted
* repo still reports empty so real removals prune.
*/
export async function listWorktreesStrictAllowingTrueEmpty(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
return await listWorktreesStrict(repoPath, options)
} catch (err) {
if (await isTrueEmptyWorktreeListing(repoPath, err)) {
return []
}
throw err
}
}
export async function annotateSparseCheckoutStatus(
repoPath: string,
worktrees: GitWorktreeInfo[],
+20 -2
View File
@@ -2,7 +2,8 @@ import type { GitWorktreeInfo } from '../../shared/worktree/types'
import {
annotateSparseCheckoutStatus,
listWorktreeGraph as listWorktreeGraphUnshared,
listWorktreesStrict as listWorktreesStrictUnshared
listWorktreesStrict as listWorktreesStrictUnshared,
listWorktreesStrictAllowingTrueEmpty as listWorktreesStrictAllowingTrueEmptyUnshared
} from './worktree-listing'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
@@ -10,7 +11,7 @@ import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
// Why: share concurrent `git worktree list` scans, which are expensive on Windows.
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
type WorktreeScanKind = 'graph' | 'lenient' | 'strict'
type WorktreeScanKind = 'graph' | 'lenient' | 'strict' | 'strict-true-empty'
// Why: mutation generations prevent listings from joining stale scans.
const worktreeScanGenerations = new Map<string, number>()
@@ -139,3 +140,20 @@ export function listWorktreesSharedStrict(
): Promise<GitWorktreeInfo[]> {
return shareWorktreeScan(repoPath, options, 'strict', listWorktreesStrictUnshared)
}
/**
* The detected scan's discipline: reject a Git/host failure so it cannot publish as an
* authoritative empty listing, but still answer `[]` for a repo that is gone or not a repo.
* Its own kind because neither a strict nor a lenient joiner may inherit that middle contract.
*/
export function listWorktreesSharedStrictAllowingTrueEmpty(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
return shareWorktreeScan(
repoPath,
options,
'strict-true-empty',
listWorktreesStrictAllowingTrueEmptyUnshared
)
}
+2 -1
View File
@@ -32,7 +32,8 @@ export {
_resetWorktreeScanCacheForTests,
listWorktreeGraph,
listWorktrees,
listWorktreesSharedStrict
listWorktreesSharedStrict,
listWorktreesSharedStrictAllowingTrueEmpty
} from './worktree-scan-cache'
export { bumpWorktreeScanGeneration as notifyPreparedWorktreeMutation } from './worktree-scan-cache'
export { addSparseWorktree } from './worktree-sparse-add'
@@ -49,6 +49,7 @@ function recordRendererBreadcrumbTrace(
const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees'
const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn'
const REACT_COMMIT_CASCADE_BREADCRUMB = 'react_commit_cascade'
const REPLAY_GUARD_WEDGED_BREADCRUMB = 'terminal_replay_guard_wedged_release'
const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
'renderer_error',
'renderer_unhandled_rejection',
@@ -56,6 +57,7 @@ const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([
DUPLICATE_TAB_OWNER_BREADCRUMB,
PARK_VERDICT_CHURN_BREADCRUMB,
REACT_COMMIT_CASCADE_BREADCRUMB,
REPLAY_GUARD_WEDGED_BREADCRUMB,
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB
])
const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
@@ -69,6 +71,11 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000
// 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count
// — the only signal these carry — in one slot.
const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted'])
// Why: the 30-slot ring is the scarce sink; the durable span stream is not. For
// bounded-rate pane telemetry whose multiplicity is the whole signal, spans are the
// only place a burst survives the restart that clears the ring, so coalesce the ring
// but keep every event's span.
const PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES = new Set([REPLAY_GUARD_WEDGED_BREADCRUMB])
function rendererBreadcrumbCoalesceKey(
name: string,
@@ -77,6 +84,13 @@ function rendererBreadcrumbCoalesceKey(
if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) {
return name
}
// Why presence and not value: `ptyId`/`tabIdHash` are absent on the restore call
// site (layout-serialization restoreScrollbackBuffers) and present on reattach, so
// their presence is the call-site identity a mixed burst would otherwise lose. Four
// slots per storm at most, regardless of pane count.
if (name === REPLAY_GUARD_WEDGED_BREADCRUMB) {
return `${name}:${data?.ptyId ? 'pty' : ''}:${data?.tabIdHash ? 'tab' : ''}`
}
// Why trigger and not name alone: `burst` means damping engaged a commit
// short of React #185, `window` means slow benign churn. Collapsing them
// would drop the near-crash signal into a slow-churn slot. Still bounded —
@@ -191,9 +205,13 @@ export function recordRendererBreadcrumbFromRenderer(
minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS,
...(origin ? { origin } : {})
})
// Why: tracing every suppressed duplicate would preserve the same
// serialization and disk churn that breadcrumb coalescing removes.
if (coalesceResult) {
if (PER_EVENT_TRACED_COALESCED_BREADCRUMB_NAMES.has(args.name)) {
// Why the raw data: every event already gets its own span, so folding the ring's
// running count in here would double-count in any span-stream total.
recordRendererBreadcrumbTrace(args.name, data)
} else if (coalesceResult) {
// Why gated: tracing every suppressed duplicate would preserve the same
// serialization and disk churn that breadcrumb coalescing removes.
recordRendererBreadcrumbTrace(
args.name,
coalesceResult.suppressedSinceLast > 0
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot,
recordCrashBreadcrumb
} from '../crash-reporting/crash-breadcrumb-store'
import { recordRendererBreadcrumbFromRenderer } from './crash-reporting-renderer-breadcrumbs'
type SpanOptions = { attributes: Record<string, unknown> }
const startSpanMock = vi.fn((_name: string, _options: SpanOptions) => ({ end: () => {} }))
vi.mock('../observability/tracer', () => ({
startSpan: (name: string, options: SpanOptions) => startSpanMock(name, options)
}))
const WEDGE_BREADCRUMB = 'terminal_replay_guard_wedged_release'
/** Reattach-path shape: identity-bearing (`tabIdHash`, optionally `ptyId`). */
function emitReattachWedge(pane: number, withPtyId = false): void {
recordRendererBreadcrumbFromRenderer({
name: WEDGE_BREADCRUMB,
data: {
paneId: pane,
leafIdHash: `leaf${String(pane).padStart(5, '0')}`,
tabIdHash: `tab${String(pane).padStart(6, '0')}`,
worktreeIdHash: 'caa15fa9',
...(withPtyId ? { ptyId: `…@@pty-${pane}` } : {})
}
})
}
/** Restore-path shape (restoreScrollbackBuffers): no tabIdHash, no ptyId. */
function emitRestoreWedge(pane: number): void {
recordRendererBreadcrumbFromRenderer({
name: WEDGE_BREADCRUMB,
data: { paneId: pane, leafIdHash: `leaf${String(pane).padStart(5, '0')}` }
})
}
function wedgeCrumbs(): ReturnType<typeof getCrashBreadcrumbSnapshot> {
return getCrashBreadcrumbSnapshot().filter((entry) => entry.name === WEDGE_BREADCRUMB)
}
function wedgeSpanCount(): number {
return startSpanMock.mock.calls.filter(
(call) => call[1].attributes['breadcrumb.name'] === WEDGE_BREADCRUMB
).length
}
beforeEach(() => {
startSpanMock.mockClear()
})
afterEach(() => {
clearCrashBreadcrumbsForTest()
})
// One mount/reveal/wake transition expires every in-flight replay write at once, so
// the burst reaches the 30-slot ring as N distinct entries. Field span streams measure
// bursts of 26 in 0.96s and 62 over 85s. No captured report in the 09-02 corpus shows
// a ring that actually drained — all nine bursts predate their report's ring window —
// so this bounds a demonstrated hazard, not an observed loss, and must not cost the
// durable span evidence that did carry those bursts.
describe('replay-guard wedge burst against the fixed-size breadcrumb ring', () => {
it('costs one ring slot per call site and preserves the pre-crash trail', () => {
for (let index = 0; index < 10; index += 1) {
recordCrashBreadcrumb(`pre_crash_evidence_${index}`, { index })
}
for (let pane = 0; pane < 26; pane += 1) {
emitReattachWedge(pane)
}
const snapshot = getCrashBreadcrumbSnapshot()
expect(snapshot.filter((entry) => entry.name.startsWith('pre_crash_evidence_'))).toHaveLength(
10
)
expect(wedgeCrumbs()).toHaveLength(1)
})
it('carries the burst multiplicity into the ring as suppressedSinceLast', () => {
for (let pane = 0; pane < 26; pane += 1) {
emitReattachWedge(pane)
}
// 26 emissions: one owns the slot, 25 fold into it.
expect(wedgeCrumbs()[0]?.data?.suppressedSinceLast).toBe(25)
})
// The 121-event field corpus lives entirely in the renderer.breadcrumb span stream,
// and the ring is cleared by the restart that usually precedes the crash report, so
// ring coalescing must not suppress the per-event spans.
it('still emits one durable span per wedge event', () => {
for (let pane = 0; pane < 26; pane += 1) {
emitReattachWedge(pane)
}
expect(wedgeSpanCount()).toBe(26)
// Why no count on the span: one span per event already carries the multiplicity.
expect(
startSpanMock.mock.calls.some((call) =>
JSON.stringify(call[1]).includes('suppressedSinceLast')
)
).toBe(false)
})
// Bundle 8907a508 mixes restore-path (identity-less) and reattach-path crumbs in one
// window; name-only keying would report only the last one's shape.
it('keeps restore-path and reattach-path call sites in separate slots', () => {
emitRestoreWedge(1)
emitRestoreWedge(2)
emitReattachWedge(3)
emitReattachWedge(4, true)
const crumbs = wedgeCrumbs()
expect(crumbs).toHaveLength(3)
expect(crumbs.map((crumb) => Boolean(crumb.data?.tabIdHash))).toEqual([false, true, true])
expect(crumbs.map((crumb) => Boolean(crumb.data?.ptyId))).toEqual([false, false, true])
})
it('bounds a many-pane burst to one slot within a call site', () => {
for (let pane = 0; pane < 40; pane += 1) {
emitReattachWedge(pane, pane % 2 === 0)
}
expect(wedgeCrumbs()).toHaveLength(2)
})
})
@@ -0,0 +1,372 @@
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type * as ProjectGroupsModule from '../../shared/project-groups'
import { buildProjectGroupChildIndex, getProjectGroupSubtreeIds } from '../../shared/project-groups'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Project } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
})
vi.mock('../../shared/project-groups', async () => {
const actual = await vi.importActual<typeof ProjectGroupsModule>('../../shared/project-groups')
return {
...actual,
buildProjectGroupChildIndex: vi.fn(actual.buildProjectGroupChildIndex),
getProjectGroupSubtreeIds: vi.fn(actual.getProjectGroupSubtreeIds)
}
})
type StoreFixture = {
repos: Repo[]
projects: Project[]
projectGroups: ProjectGroup[]
folderWorkspaces: FolderWorkspace[]
workspaceDir?: string
}
type StoreCallCounts = {
getRepos: number
getProjects: number
getProjectGroups: number
getFolderWorkspaces: number
}
function makeCountingStore(fixture: StoreFixture): { store: Store; counts: StoreCallCounts } {
const counts: StoreCallCounts = {
getRepos: 0,
getProjects: 0,
getProjectGroups: 0,
getFolderWorkspaces: 0
}
const store = {
getRepos: () => {
counts.getRepos += 1
// Match the real store, which rehydrates fresh repo objects on every read.
return fixture.repos.map((repo) => ({ ...repo }))
},
getProjects: () => {
counts.getProjects += 1
return fixture.projects.map((project) => ({ ...project }))
},
getProjectGroups: () => {
counts.getProjectGroups += 1
return fixture.projectGroups.map((group) => ({ ...group }))
},
getFolderWorkspaces: () => {
counts.getFolderWorkspaces += 1
return fixture.folderWorkspaces.map((workspace) => ({ ...workspace }))
},
getSettings: () => ({ nestWorkspaces: false, workspaceDir: fixture.workspaceDir ?? '' })
} as unknown as Store
return { store, counts }
}
/**
* The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the
* new root list against the old one rather than against a hand-written expectation.
*/
function referenceAllowedRoots(store: Store): string[] {
const scopeStore = store as unknown as {
getRepos: () => Repo[]
getProjectGroups?: () => ProjectGroup[]
getFolderWorkspaces?: () => FolderWorkspace[]
getSettings: () => { workspaceDir?: string; nestWorkspaces?: boolean }
}
const localRepos = scopeStore.getRepos().filter((repo) => !repo.connectionId)
const settings = scopeStore.getSettings()
const scopeRepos = scopeStore.getRepos()
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const isRemoteOnly = (
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined
): boolean => {
if (connectionId) {
return true
}
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
const candidates = scopeRepos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
isPathInsideOrEqual(folderPath, repo.path)
)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
const folderScopeRoots: string[] = []
for (const group of projectGroups) {
if (group.parentPath && !isRemoteOnly(group.parentPath, group.id, group.connectionId)) {
folderScopeRoots.push(resolve(group.parentPath))
}
}
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
const connectionId =
workspace.connectionId ??
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
null
if (!isRemoteOnly(workspace.folderPath, workspace.projectGroupId, connectionId)) {
folderScopeRoots.push(resolve(workspace.folderPath))
}
}
const roots = [...localRepos.map((repo) => resolve(repo.path)), ...folderScopeRoots]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))
} else {
for (const repo of localRepos) {
roots.push(
resolve(
computeWorkspaceRoot(
repo.path,
getWorktreePathSettings(repo, settings as never, getWorktreeMirrorDistro(store, repo))
)
)
)
}
}
}
return roots
}
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path'>): Repo {
return {
displayName: overrides.id,
badgeColor: '#000000',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeGroup(overrides: Partial<ProjectGroup> & Pick<ProjectGroup, 'id'>): ProjectGroup {
return {
name: overrides.id,
parentPath: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeWorkspace(
overrides: Partial<FolderWorkspace> & Pick<FolderWorkspace, 'id' | 'folderPath'>
): FolderWorkspace {
return {
projectGroupId: 'group-root',
name: overrides.id,
comment: '',
linkedTask: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
/** Repos, nested groups, folder workspaces (one not a git worktree), and an SSH repo. */
function makeMixedFixture(): StoreFixture {
const repos = [
makeRepo({ id: 'repo-local', path: '/repos/app', projectGroupId: 'group-root' }),
makeRepo({ id: 'repo-nested', path: '/repos/nested', projectGroupId: 'group-child' }),
makeRepo({ id: 'repo-folder', path: '/folders/plain', kind: 'folder' }),
makeRepo({
id: 'repo-ssh',
path: '/remote/app',
connectionId: 'ssh-1',
projectGroupId: 'group-remote'
})
]
const projectGroups = [
makeGroup({ id: 'group-root', parentPath: '/folders/root' }),
makeGroup({ id: 'group-child', parentGroupId: 'group-root', parentPath: '/folders/child' }),
makeGroup({ id: 'group-grandchild', parentGroupId: 'group-child' }),
makeGroup({ id: 'group-remote', parentPath: '/remote/scope' }),
makeGroup({ id: 'group-connection', parentPath: '/remote/via-group', connectionId: 'ssh-1' })
]
const folderWorkspaces = [
makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }),
// Not a git worktree: a plain folder workspace under a folder-kind repo.
makeWorkspace({
id: 'ws-plain',
folderPath: '/folders/plain/scratch',
projectGroupId: 'group-child'
}),
makeWorkspace({ id: 'ws-remote', folderPath: '/remote/ws', projectGroupId: 'group-remote' }),
makeWorkspace({
id: 'ws-connection',
folderPath: '/remote/direct',
projectGroupId: 'group-connection'
}),
makeWorkspace({
id: 'ws-unlinked',
folderPath: '/folders/unlinked',
projectGroupId: 'group-orphan'
})
]
const projects: Project[] = [
{
id: 'project-1',
displayName: 'App',
badgeColor: '#000000',
sourceRepoIds: ['repo-local', 'repo-nested'],
createdAt: 1,
updatedAt: 1
},
{
id: 'project-2',
displayName: 'Folder',
badgeColor: '#000000',
sourceRepoIds: ['repo-folder'],
createdAt: 1,
updatedAt: 1
}
]
return { repos, projects, projectGroups, folderWorkspaces, workspaceDir: '/workspaces' }
}
beforeEach(() => {
invalidateAuthorizedRootsCache()
vi.mocked(buildProjectGroupChildIndex).mockClear()
vi.mocked(getProjectGroupSubtreeIds).mockClear()
})
describe('getAllowedRoots', () => {
it('produces the same roots as the pre-change implementation', () => {
const { store } = makeCountingStore(makeMixedFixture())
expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store))
})
it('reads the store once and indexes project groups once per build', () => {
const fixture = makeMixedFixture()
const { store, counts } = makeCountingStore(fixture)
getAllowedRoots(store)
expect.soft(counts.getRepos).toBe(1)
expect.soft(counts.getProjectGroups).toBe(1)
expect.soft(counts.getFolderWorkspaces).toBe(1)
// Batched runtime resolution scans the project list once, not once per local repo.
expect.soft(counts.getProjects).toBe(1)
// The per-scope subtree walk no longer rebuilds the parent->children index.
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1)
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
})
})
describe('resolveAuthorizedPath allowed-root reuse', () => {
let repoRoot: string
let outsideRoot: string
let store: Store
let counts: StoreCallCounts
beforeEach(async () => {
repoRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-allowed-roots-'))
outsideRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-outside-'))
const fixture = makeMixedFixture()
fixture.repos = [makeRepo({ id: 'repo-local', path: repoRoot }), ...fixture.repos]
fixture.projects[0]!.sourceRepoIds = ['repo-local']
;({ store, counts } = makeCountingStore(fixture))
})
afterEach(async () => {
await rm(repoRoot, { recursive: true, force: true })
await rm(outsideRoot, { recursive: true, force: true })
})
it('builds the allowed-root list once per call across repeated reads', async () => {
const dirPath = join(repoRoot, 'src')
await mkdir(dirPath)
await writeFile(join(dirPath, 'index.ts'), 'export {}\n')
const callCount = 5
for (let index = 0; index < callCount; index += 1) {
await resolveAuthorizedPath(dirPath, store)
await resolveAuthorizedPath(join(dirPath, 'index.ts'), store)
}
const buildCount = callCount * 2
// One build per authorization, not one per raw-path check plus one per realpath check.
expect.soft(counts.getFolderWorkspaces).toBe(buildCount)
expect.soft(counts.getRepos).toBe(buildCount)
expect.soft(counts.getProjects).toBe(buildCount)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(buildCount)
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
})
// Why (both symlink cases): creating a symlink on Windows needs elevation or
// Developer Mode, so these would fail EPERM in setup rather than exercise the
// escape check. Every non-symlink case still runs there.
it.skipIf(process.platform === 'win32')(
'still refuses a symlink that escapes every allowed root',
async () => {
const secret = join(outsideRoot, 'secret.txt')
await writeFile(secret, 'secret\n')
const escape = join(repoRoot, 'escape.txt')
await symlink(secret, escape)
await expect(resolveAuthorizedPath(escape, store)).rejects.toThrow('Access denied')
expect(vi.mocked(listRepoWorktreeGraph)).toHaveBeenCalled()
}
)
it('builds no allowed-root list at all for a granted external path', async () => {
const external = join(outsideRoot, 'external.md')
await writeFile(external, 'notes\n')
authorizeExternalPath(external)
counts.getRepos = 0
counts.getProjects = 0
counts.getFolderWorkspaces = 0
for (let index = 0; index < 5; index += 1) {
await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external)
}
// The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read.
expect.soft(counts.getRepos).toBe(0)
expect.soft(counts.getProjects).toBe(0)
expect.soft(counts.getFolderWorkspaces).toBe(0)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled()
})
it.skipIf(process.platform === 'win32')(
'still refuses a directory symlink that escapes every allowed root',
async () => {
const outsideDir = join(outsideRoot, 'nested')
await mkdir(outsideDir)
await writeFile(join(outsideDir, 'file.txt'), 'secret\n')
const escape = join(repoRoot, 'escape-dir')
await symlink(outsideDir, escape)
await expect(resolveAuthorizedPath(join(escape, 'file.txt'), store)).rejects.toThrow(
'Access denied'
)
}
)
})
+44 -15
View File
@@ -1,9 +1,16 @@
import { resolve } from 'node:path'
import type { Store } from '../persistence'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import {
getWorktreeMirrorDistroForRuntime,
resolveLocalProjectRuntimesForRepos
} from '../project-runtime-git-options'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
import {
buildProjectGroupChildIndex,
collectProjectGroupSubtreeIds,
type ProjectGroupChildIndex
} from '../../shared/project-groups'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
@@ -11,18 +18,22 @@ import type { Repo } from '../../shared/repo-types'
type FolderScopeStore = Pick<Store, 'getRepos'> &
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
function filterLocalRepos(repos: readonly Repo[]): Repo[] {
return repos.filter((repo) => !repo.connectionId)
}
export function getLocalRepos(store: Store) {
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
return store.getRepos().filter((repo) => !repo.connectionId)
return filterLocalRepos(store.getRepos())
}
function getFolderScopeCandidateRepos(
folderPath: string,
projectGroupId: string,
projectGroups: readonly ProjectGroup[],
childGroupIndex: ProjectGroupChildIndex,
repos: readonly Repo[]
): Repo[] {
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId)
return repos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
@@ -34,13 +45,18 @@ function isRemoteOnlyFolderScope(
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined,
projectGroups: readonly ProjectGroup[],
childGroupIndex: ProjectGroupChildIndex,
repos: readonly Repo[]
): boolean {
if (connectionId) {
return true
}
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
const candidates = getFolderScopeCandidateRepos(
folderPath,
projectGroupId,
childGroupIndex,
repos
)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
@@ -55,16 +71,22 @@ function getFolderWorkspaceConnectionId(
)
}
function getLocalFolderScopeRoots(store: Store): string[] {
function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] {
const scopeStore = store as FolderScopeStore
const repos = scopeStore.getRepos()
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const childGroupIndex = buildProjectGroupChildIndex(projectGroups)
const roots: string[] = []
for (const group of projectGroups) {
if (
group.parentPath &&
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
!isRemoteOnlyFolderScope(
group.parentPath,
group.id,
group.connectionId,
childGroupIndex,
repos
)
) {
roots.push(resolve(group.parentPath))
}
@@ -75,7 +97,7 @@ function getLocalFolderScopeRoots(store: Store): string[] {
workspace.folderPath,
workspace.projectGroupId,
getFolderWorkspaceConnectionId(workspace, projectGroups),
projectGroups,
childGroupIndex,
repos
)
) {
@@ -86,16 +108,19 @@ function getLocalFolderScopeRoots(store: Store): string[] {
}
export function getAllowedRoots(store: Store): string[] {
const localRepos = getLocalRepos(store)
// Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC.
const repos = store.getRepos()
const localRepos = filterLocalRepos(repos)
const settings = store.getSettings()
const roots = [
...localRepos.map((repo) => resolve(repo.path)),
...getLocalFolderScopeRoots(store)
...getLocalFolderScopeRoots(store, repos)
]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))
} else {
const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos)
for (const repo of localRepos) {
roots.push(
resolve(
@@ -104,7 +129,11 @@ export function getAllowedRoots(store: Store): string[] {
// Why enriched here too: placement has to agree with the create
// flow, or renderer file access is denied for a worktree Orca
// just put on the WSL side.
getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo))
getWorktreePathSettings(
repo,
settings,
getWorktreeMirrorDistroForRuntime(projectRuntimeByRepoId.get(repo.id))
)
)
)
)
+51 -14
View File
@@ -43,7 +43,24 @@ export function authorizeExternalPath(targetPath: string): void {
} catch {}
}
export function isPathAllowed(targetPath: string, store: Store): boolean {
/**
* One allowed-root list shared by every check in a single authorization.
*
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
* before the list was hoisted out of the individual checks.
*/
type AllowedRootsSnapshot = { get: () => readonly string[] }
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
}
export function isPathAllowed(
targetPath: string,
store: Store,
allowedRoots?: AllowedRootsSnapshot
): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
@@ -53,7 +70,9 @@ export function isPathAllowed(targetPath: string, store: Store): boolean {
return true
}
}
return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root))
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
}
export type ResolveAuthorizedPathOptions = {
@@ -69,7 +88,10 @@ export async function resolveAuthorizedPath(
options: ResolveAuthorizedPathOptions = {}
): Promise<string> {
const resolvedTarget = resolve(targetPath)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store))) {
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
// every authorization below; each candidate is still checked against it in full.
const allowedRoots = createAllowedRootsSnapshot(store)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
@@ -80,14 +102,15 @@ export async function resolveAuthorizedPath(
realParent = await realpath(dirname(resolvedTarget))
} catch (error) {
if (isENOENT(error)) {
return resolveAuthorizedMissingPath(resolvedTarget, store)
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
throw error
}
const candidateTarget = resolve(realParent, basename(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -100,7 +123,8 @@ export async function resolveAuthorizedPath(
const realTarget = resolve(await realpath(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -110,11 +134,15 @@ export async function resolveAuthorizedPath(
if (!isENOENT(error)) {
throw error
}
return resolveAuthorizedMissingPath(resolvedTarget, store)
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
}
async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise<string> {
async function resolveAuthorizedMissingPath(
resolvedTarget: string,
store: Store,
allowedRoots: AllowedRootsSnapshot
): Promise<string> {
let existingAncestor = resolvedTarget
const missingSegments: string[] = []
@@ -124,7 +152,8 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
const candidateTarget = resolve(realAncestor, ...missingSegments)
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -148,9 +177,9 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
async function isPathAllowedIncludingRegisteredWorktrees(
targetPath: string,
store: Store,
options: { canonicalSourcePath?: string } = {}
options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {}
): Promise<boolean> {
if (isPathAllowed(targetPath, store)) {
if (isPathAllowed(targetPath, store, options.allowedRoots)) {
return true
}
@@ -158,7 +187,14 @@ async function isPathAllowedIncludingRegisteredWorktrees(
return true
}
if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) {
if (
await isPathAllowedByCanonicalAllowedRoot(
targetPath,
options.canonicalSourcePath,
store,
options.allowedRoots
)
) {
return true
}
@@ -178,12 +214,13 @@ async function isPathAllowedIncludingRegisteredWorktrees(
async function isPathAllowedByCanonicalAllowedRoot(
targetPath: string,
sourcePath: string | undefined,
store: Store
store: Store,
allowedRoots?: AllowedRootsSnapshot
): Promise<boolean> {
if (!sourcePath) {
return false
}
for (const root of getAllowedRoots(store)) {
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
const resolvedRoot = resolve(root)
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
continue
@@ -0,0 +1,15 @@
import { BrowserWindow } from 'electron'
import { ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL } from '../../shared/orca-profiles'
export function broadcastOrcaProfileAuthStatusChanged(): void {
for (const window of BrowserWindow.getAllWindows()) {
if (window.isDestroyed()) {
continue
}
try {
window.webContents.send(ORCA_PROFILE_AUTH_STATUS_CHANGED_CHANNEL)
} catch {
// A renderer can disappear between isDestroyed() and send().
}
}
}
+8
View File
@@ -45,6 +45,8 @@ import {
signOutCurrentOrcaProfile
} from '../orca-profiles/profile-cloud-service'
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
import { onOrcaCloudSessionInvalidated } from '../orca-profiles/profile-cloud-session-invalidation'
import { broadcastOrcaProfileAuthStatusChanged } from './orca-profile-auth-status-broadcast'
type RegisterOrcaProfileHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
@@ -178,6 +180,12 @@ export function registerOrcaProfileHandlers(
getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
)
// Why: a background refresh can revoke the session with no renderer request in
// flight, so push the change instead of waiting for the next pane to ask.
// Why not options.onAuthMutation: that hook drives the relay coordinator, which
// is the caller that just failed the refresh — re-entering it here would be a loop.
onOrcaCloudSessionInvalidated(broadcastOrcaProfileAuthStatusChanged)
ipcMain.handle(
'orcaProfiles:createLocal',
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
+12 -5
View File
@@ -170,7 +170,10 @@ export function installPtyInspectIpcHandlers(deps: {
ipcMain.handle(
'pty:inspectProcess',
async (_event, args: { id: string; expectedIncarnationId?: string }) => {
async (
_event,
args: { id: string; expectedIncarnationId?: string; scanChildProcesses?: boolean }
) => {
// Why: same routing hazard as pty:hasPty — an unroutable id must read as client-only unverifiable, not as a local-provider answer or a raised IPC error.
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
return clientOnlyUnverifiableInspection('terminal_gone')
@@ -182,10 +185,14 @@ export function installPtyInspectIpcHandlers(deps: {
if (!hasPtyProviderForInspection(args.id)) {
return clientOnlyUnverifiableInspection('terminal_gone')
}
return args.expectedIncarnationId
? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, {
expectedIncarnationId: args.expectedIncarnationId
})
const options = {
...(args.expectedIncarnationId
? { expectedIncarnationId: args.expectedIncarnationId }
: {}),
...(args.scanChildProcesses === true ? { scanChildProcesses: true } : {})
}
return Object.keys(options).length > 0
? inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id, options)
: inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id)
}
)
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input'
import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate'
import { ptyOwnership } from '../provider/ownership-state'
import { createPtyWriteInput } from './write-input'
const PTY_ID = 'pty-chunk-yield'
const { provider } = vi.hoisted(() => ({ provider: { write: vi.fn() } }))
vi.mock('../provider/registry', () => ({
tryGetProviderForPty: (id: string) => (id === PTY_ID ? provider : undefined)
}))
const realSetImmediate = globalThis.setImmediate
const realReadmit = agentSessionPtyWriteGate.readmit.bind(agentSessionPtyWriteGate)
const THREE_CHUNK_INPUT = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)
const mainWindow = {
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send: vi.fn() }
}
/** Resolves after `turns` real check-phase passes; never touches the (faked) timer queue. */
function afterImmediateTurns(turns: number): Promise<'stalled'> {
return new Promise((resolve) => {
const step = (remaining: number): void => {
if (remaining === 0) {
resolve('stalled')
return
}
realSetImmediate(() => step(remaining - 1))
}
step(turns)
})
}
function createWriteInput(): ReturnType<typeof createPtyWriteInput>['writePtyInput'] {
return createPtyWriteInput({
mainWindow: mainWindow as never,
clearHiddenRendererResizeOutput: vi.fn()
}).writePtyInput
}
beforeEach(() => {
ptyOwnership.set(PTY_ID, null)
provider.write.mockReset()
mainWindow.webContents.send.mockReset()
// Why: only setTimeout is faked. A setTimeout(0) yield would stall the write forever here,
// while a setImmediate yield still runs in Node's check phase — the race below is deterministic.
vi.useFakeTimers({ toFake: ['setTimeout'] })
})
afterEach(() => {
ptyOwnership.delete(PTY_ID)
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('chunked pty write yield', () => {
it('yields between chunks via setImmediate, not a timer, and readmits before each later chunk', async () => {
const events: string[] = []
provider.write.mockImplementation((_id: string, data: string) => {
events.push(`write:${data.length}`)
})
vi.spyOn(agentSessionPtyWriteGate, 'readmit').mockImplementation((...args) => {
events.push('readmit')
return realReadmit(...args)
})
vi.spyOn(globalThis, 'setImmediate').mockImplementation(((callback: () => void) => {
events.push('yield')
return realSetImmediate(callback)
}) as typeof setImmediate)
const outcome = await Promise.race([
createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }),
afterImmediateTurns(50)
])
expect(outcome).toBe(true)
expect(vi.getTimerCount()).toBe(0)
expect(events).toEqual([
`write:${TERMINAL_INPUT_CHUNK_MAX_BYTES}`,
'yield',
'readmit',
`write:${TERMINAL_INPUT_CHUNK_MAX_BYTES}`,
'yield',
'readmit',
'write:8'
])
expect(mainWindow.webContents.send).not.toHaveBeenCalled()
})
it('does not yield for input that fits in a single chunk', async () => {
const immediate = vi.spyOn(globalThis, 'setImmediate')
const outcome = createWriteInput()({
id: PTY_ID,
data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)
})
expect(outcome).toBe(true)
expect(provider.write).toHaveBeenCalledTimes(1)
expect(immediate).not.toHaveBeenCalled()
})
it('stops after a yield once readmission refuses', async () => {
const readmit = vi.spyOn(agentSessionPtyWriteGate, 'readmit').mockReturnValue({
admitted: false,
refusal: {
code: 'agent_session_checkpoint_stale',
sessionId: 'session-1',
ownerRuntimeKind: null,
handoffStage: null,
ownerPid: null,
runtimeFence: null
}
})
const outcome = await Promise.race([
createWriteInput()({ id: PTY_ID, data: THREE_CHUNK_INPUT }),
afterImmediateTurns(50)
])
expect(outcome).toBe(false)
expect(provider.write).toHaveBeenCalledTimes(1)
expect(readmit).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith(
'pty:writeUnavailable',
expect.objectContaining({ id: PTY_ID })
)
})
})
+3 -1
View File
@@ -152,7 +152,9 @@ export function createPtyWriteInput(deps: {
first = false
provider.write(id, chunk.value)
if (!nextChunk.done) {
await new Promise((resolve) => setTimeout(resolve, 0))
// setImmediate, not setTimeout(0): the yield exists to let abort/data callbacks run
// between chunks, and a clamped timer tick per 16 KiB is pure latency.
await new Promise((resolve) => setImmediate(resolve))
}
chunk = nextChunk
nextChunk = chunks.next()
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { sshProviders } from '../provider/registry'
import { listProcessesWithHostScopeFromRuntimeController } from './inventory-operations'
import type { PtyRuntimeControllerDeps } from './controller-deps'
/**
* `hostScopeCensusIsComplete` discounts a `runtime:` host in `omittedHostIds` on the strength of
* one fact about this process: it has no paired-runtime PTY provider, so it never queried that
* host and never owed it coverage. This file pins the producer side of that fact.
*
* What it catches: a new branch here that spells a queried host `runtime:`. Every id this
* function emits is built by `toSshExecutionHostId` or is `LOCAL_EXECUTION_HOST_ID`, so a third
* shape is the observable form of "a runtime host can now answer an inventory" — at which point
* the client predicate would start calling a genuine gap complete.
*
* What it does NOT catch, so do not lean on it: a runtime-backed transport registered under an
* SSH connection id still reports as `ssh:` and passes, which is fine — the predicate only
* discounts the `runtime:` spelling. The consolidation moving the SSH path onto orcad is expected
* to look exactly like that. The other route into `queriedHostIds` is separately fenced to
* `kind === 'ssh'` in `orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts`.
*/
describe('the hosts a PTY inventory can report having queried', () => {
afterEach(() => {
sshProviders.clear()
})
it('emits only local and ssh spellings, never a paired-runtime one', async () => {
const listProcesses = vi.fn(async () => [])
sshProviders.set('box-1', { listProcesses } as never)
// A connection id shaped like an environment uuid still has to come back `ssh:`; the spelling
// is what the gate keys on, so a `runtime:` id appearing here is the breakage that matters.
sshProviders.set('a2478221-1d5c-4603-b8bf-b6b728eac9df', { listProcesses } as never)
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
runtime: null
} as unknown as PtyRuntimeControllerDeps)
expect(hostIds).toContain('ssh:a2478221-1d5c-4603-b8bf-b6b728eac9df')
expect(new Set(hostIds.map((hostId) => parseExecutionHostId(hostId)?.kind))).toEqual(
new Set(['local', 'ssh'])
)
})
it('drops a provider that threw rather than reporting its host as queried', async () => {
sshProviders.set('box-live', { listProcesses: vi.fn(async () => []) } as never)
sshProviders.set('box-down', {
listProcesses: vi.fn(async () => {
throw new Error('relay unavailable')
})
} as never)
const { hostIds } = await listProcessesWithHostScopeFromRuntimeController({
runtime: { markPtyLivenessUnverifiable: vi.fn() }
} as unknown as PtyRuntimeControllerDeps)
expect(hostIds).toContain('ssh:box-live')
expect(hostIds).not.toContain('ssh:box-down')
})
})
@@ -115,6 +115,7 @@ export const gitWorktreeModuleMock = () => ({
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesMock,
listWorktreesSharedStrict: listWorktreesMock,
listWorktreesSharedStrictAllowingTrueEmpty: listWorktreesMock,
describeCreatedWorktree: describeCreatedWorktreeMock,
parseWorktreeList: parseWorktreeListMock,
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
+1
View File
@@ -74,6 +74,7 @@ vi.mock('../git/worktree', () => ({
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesMock,
listWorktreesSharedStrict: listWorktreesMock,
listWorktreesSharedStrictAllowingTrueEmpty: listWorktreesMock,
describeCreatedWorktree: vi.fn().mockResolvedValue(undefined),
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
addWorktree: addWorktreeMock,
@@ -25,9 +25,12 @@ import {
type DetectedWorktreeMetadataPrune,
type DetectedWorktreeSideEffectToken
} from './detected-worktree-scan-cache'
import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics'
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
describeWorktreeScanFailure,
loggedWorktreeListFailures,
warnOnce
} from './worktree-listing-diagnostics'
import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta'
export async function listDetectedWorktreesForCapturedRepo(
store: Store,
@@ -40,9 +43,7 @@ export async function listDetectedWorktreesForCapturedRepo(
providerAbort?.signal.aborted
? ({ providerAbortStatus: providerAbort.status() } as const)
: undefined
const allMeta = isFolderRepo(repo)
? undefined
: readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
const allMeta = isFolderRepo(repo) ? undefined : readAllWorktreeMetaForRepo(store, repo)
// Why: only the disconnected fallbacks read this, so keep parseWorktreeId over the whole host snapshot
// off the connected path entirely.
let cachedSshWorktreeMetaIndex: SshWorktreeMetaIndex | undefined
@@ -160,16 +161,25 @@ export async function listDetectedWorktreesForCapturedRepo(
`[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
err
)
// Why: retention alone leaves inert rows with no explanation; the cause rides with the listing.
const unavailableReason = describeWorktreeScanFailure(err)
if (repo.connectionId) {
const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex())
return {
repoId: repo.id,
authoritative: false,
source: 'metadata-fallback',
worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees)
worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees),
unavailableReason
}
}
return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] }
return {
repoId: repo.id,
authoritative: false,
source: 'metadata-fallback',
worktrees: [],
unavailableReason
}
}
}
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types'
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
ipcMain: { handle: vi.fn(), removeHandler: vi.fn() },
app: { getPath: () => '/tmp/orca-test' }
}))
vi.mock('../../../git/runner', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
gitExecFileAsync: gitExecFileAsyncMock
}))
const { listDetectedWorktreesForCapturedRepo } = await import('./detected-provider-listing')
const { __resetDetectedWorktreeScanCacheForTests } = await import('./detected-worktree-scan-cache')
const { _resetWorktreeScanCacheForTests } = await import('../../../git/worktree-scan-cache')
const { isRegisteredWorktreePath, invalidateAuthorizedRootsCache } =
await import('../../registered-worktree-roots-cache')
const REPO_PATH = '/workspace/repo'
const repo = {
id: 'repo-1',
path: REPO_PATH,
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
} as Repo
const removeWorktreeLineage = vi.fn()
function createStore() {
return {
getRepo: () => repo,
getRepos: () => [repo],
getProjects: () => [],
getSettings: () => ({}),
getAllWorktreeMeta: () => ({}),
getProjectHostSetups: () => [],
getWorktreeMeta: () => undefined,
setWorktreeMeta: vi.fn(),
getAllWorktreeLineage: () => ({}),
getAllWorkspaceLineage: () => ({}),
removeWorktreeLineage,
captureNativeLocalWorktreeMetadataScanExpectation: () => undefined
} as never
}
/** The field failure: wsl.exe exits 0xFFFFFFFF, says nothing on stderr, and git never ran. */
function wslHostFailure(): Error {
return Object.assign(new Error('Command failed: wsl.exe -d kali-linux --exec sh -lc ...'), {
code: 4294967295,
stdout: 'Error code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\r\n',
stderr: ''
})
}
async function listDetected(): Promise<DetectedWorktreeListResult> {
const result = await listDetectedWorktreesForCapturedRepo(createStore(), repo, () => true)
return result as DetectedWorktreeListResult
}
describe('detected worktree listing authority', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
removeWorktreeLineage.mockReset()
__resetDetectedWorktreeScanCacheForTests()
_resetWorktreeScanCacheForTests()
invalidateAuthorizedRootsCache()
})
it('reports a failed scan as non-authoritative and prunes nothing', async () => {
gitExecFileAsyncMock.mockRejectedValue(wslHostFailure())
const result = await listDetected()
expect(result.authoritative).toBe(false)
expect(result.source).toBe('metadata-fallback')
expect(result.worktrees).toEqual([])
// Why: the retained rows must carry the cause, or the user sees inert worktrees with no explanation.
expect(result.unavailableReason).toContain('Command failed: wsl.exe')
// The destructive halves of a fresh scan must not run against a listing that failed.
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false)
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
it('surfaces the annotated wsl.exe diagnostic as the unavailable reason', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(
new Error(
'wsl.exe host failure (distro "kali-linux"): There is no distribution with the supplied name.\r\nError code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND\nCommand failed: wsl.exe -d kali-linux --exec sh -lc ...'
),
{ code: 4294967295, stdout: '', stderr: '' }
)
)
const result = await listDetected()
expect(result.authoritative).toBe(false)
expect(result.unavailableReason).toBe(
'wsl.exe host failure (distro "kali-linux"): There is no distribution with the supplied name. Error code: Wsl/Service/WSL_E_DISTRO_NOT_FOUND'
)
})
// Why (measured on a real Windows host): under WSL the spawn cwd is the interop directory, so a
// deleted guest repo fails as `bash: cd` exit 1 — not ENOENT — and must stay retained, not pruned.
it('retains a WSL repo whose guest directory is gone, and says why', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('bash: line 1: cd: /home/neil/repo: No such file or directory'), {
code: 1,
stdout: '',
stderr: 'bash: line 1: cd: /home/neil/repo: No such file or directory\n'
})
)
const result = await listDetected()
expect(result.authoritative).toBe(false)
expect(result.unavailableReason).toContain('No such file or directory')
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false)
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
it('keeps an empty listing authoritative when the path is not a Git repo', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('Command failed: git worktree list'), {
code: 128,
stderr: 'fatal: not a git repository (or any of the parent directories): .git\n'
})
)
const result = await listDetected()
expect(result.authoritative).toBe(true)
expect(result.source).toBe('git')
expect(result.worktrees).toEqual([])
expect(result.unavailableReason).toBeUndefined()
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(true)
})
it('keeps an empty listing authoritative when the repo path is gone', async () => {
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT', stderr: '' })
)
const result = await listDetected()
expect(result.authoritative).toBe(true)
expect(result.source).toBe('git')
expect(result.worktrees).toEqual([])
})
it('stays authoritative for a healthy scan', async () => {
gitExecFileAsyncMock.mockResolvedValue({
stdout: `worktree ${REPO_PATH}\u0000HEAD abc\u0000branch refs/heads/main\u0000\u0000`,
stderr: ''
})
const result = await listDetected()
expect(result.authoritative).toBe(true)
expect(result.worktrees.map((worktree) => worktree.path)).toEqual([REPO_PATH])
expect(isRegisteredWorktreePath(REPO_PATH)).toBe(true)
})
})
@@ -3,7 +3,7 @@ import type { Store } from '../../../persistence/loading-store/store'
import type { Repo } from '../../../../shared/repo-types'
import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { listRepoWorktrees } from '../../../repo-worktrees'
import { listRepoWorktreesForDetectedScan } from '../../../repo-worktrees'
import {
getRegisteredWorktreeRootsRevision,
registerWorktreeRootsForRepo
@@ -111,7 +111,7 @@ export async function listDetectedGitWorktrees(
const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
if (repo.connectionId || isFolderRepo(repo)) {
return {
gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions),
gitWorktrees: await listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions),
fresh: true
}
}
@@ -144,7 +144,7 @@ export async function listDetectedGitWorktrees(
: undefined
const scan: DetectedWorktreeScan = {
invalidated: false,
promise: listRepoWorktrees(repo, localWorktreeGitOptions),
promise: listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions),
sideEffectToken: { generation, authorizedRootsRevision },
hygieneDue,
...(metadataPruneExpectation
@@ -10,7 +10,9 @@ const { listRepoWorktreesMock, pruneLineageMock, pruneMetadataMock, registerWork
registerWorktreeRootsMock: vi.fn()
}))
vi.mock('../../../repo-worktrees', () => ({ listRepoWorktrees: listRepoWorktreesMock }))
vi.mock('../../../repo-worktrees', () => ({
listRepoWorktreesForDetectedScan: listRepoWorktreesMock
}))
vi.mock('../../../project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: () => ({})
}))
@@ -23,7 +23,10 @@ import {
warnOnce
} from './worktree-listing-diagnostics'
import type { WorktreeIpcContext } from '../worktree-ipc-context'
import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta'
import {
readAllWorktreeMetaForHost,
readAllWorktreeMetaForRepo
} from '../../../persistence/host-qualified-worktree-meta'
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
const WORKTREE_LIST_ALL_CONCURRENCY = 8
@@ -174,9 +177,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
if (!repo) {
return []
}
const allMeta = repo.connectionId
? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
: undefined
const allMeta = repo.connectionId ? readAllWorktreeMetaForRepo(store, repo) : undefined
const sshWorktreeMetaIndex = repo.connectionId
? createSshWorktreeMetaIndex(Object.entries(allMeta ?? {}))
: new Map()
@@ -226,7 +227,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
})
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo))
const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo)
return buildDetectedGitWorktrees(store, repo, gitWorktrees, metadata)
.filter((worktree) => worktree.visible)
.map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata))
@@ -9,7 +9,7 @@ import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../sh
import type { Store } from '../../../persistence/loading-store/store'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
readWorktreeMetaForHost,
readWorktreeMetaForRepo,
writeWorktreeMetaForHost
} from '../../../persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
@@ -159,7 +159,7 @@ export function buildDetectedGitWorktrees(
const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined
const metaById = allMeta ?? (legacyMeta ? { [worktreeId]: legacyMeta } : {})
const meta =
readWorktreeMetaForHost(store, worktreeId, getRepoExecutionHostId(repo)) ??
readWorktreeMetaForRepo(store, worktreeId, repo) ??
getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
const detected = toDetectedWorktree({
@@ -4,7 +4,7 @@ import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-lookup'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
readWorktreeMetaForHost,
readWorktreeMetaForRepo,
writeWorktreeMetaForHost
} from '../../../persistence/host-qualified-worktree-meta'
import { getRepoOwnedWorktreeMeta } from '../../../worktree-metadata-ownership'
@@ -44,7 +44,7 @@ export function resolveWorktreeMetaWithDiscoveryBackfill(
// Why: the locator-keyed row is only a stand-in for a missing snapshot, so don't read it when we have one.
const legacyMeta = allMeta === undefined ? store.getWorktreeMeta?.(worktreeId) : undefined
const existing =
readWorktreeMetaForHost(store, worktreeId, executionHostId) ??
readWorktreeMetaForRepo(store, worktreeId, repo) ??
getRepoOwnedWorktreeMeta(
repo,
worktreeId,
@@ -14,3 +14,23 @@ export function warnOnce(keySet: Set<string>, key: string, message: string, erro
console.warn(message)
}
}
const SCAN_FAILURE_REASON_MAX_CHARS = 240
/**
* The cause a retained-but-unscannable repo shows the user. The first two lines carry the
* classifier's summary plus its `Wsl/Service/WSL_E_*` code; everything after is the raw command.
*/
export function describeWorktreeScanFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
const summary = message
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.slice(0, 2)
.join(' ')
const reason = summary.length > 0 ? summary : 'Worktree scan failed with no diagnostic.'
return reason.length > SCAN_FAILURE_REASON_MAX_CHARS
? `${reason.slice(0, SCAN_FAILURE_REASON_MAX_CHARS - 1)}…`
: reason
}
@@ -1,110 +0,0 @@
// Content-addressed store for the remainder of a bounded payload.
//
// Blobs are named by their sha256, so writing the same output twice costs one
// file and re-import is idempotent. They live beside the journal (host-side
// per-workspace state, never inside the user's working tree) and share the
// epoch's retention: compaction prunes every blob no retained row references.
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write'
export const JOURNAL_BLOB_DIR = 'blobs'
const DIGEST_PATTERN = /^[0-9a-f]{64}$/
/** A digest arrives back from a row on disk, so it is untrusted by the time it
* reaches the filesystem: anything but a bare sha256 could escape the store. */
function blobPath(journalDir: string, digest: string): string | null {
return DIGEST_PATTERN.test(digest) ? join(journalDir, JOURNAL_BLOB_DIR, digest) : null
}
/** Persist `payload` under its digest. Returns the digest so the caller can
* stamp it on the row it is about to append. */
export async function putJournalBlob(
journalDir: string,
digest: string,
payload: string
): Promise<string> {
const target = blobPath(journalDir, digest)
if (!target) {
throw new Error('refusing to write a journal blob under a name that is not a sha256 digest')
}
// Content addressing makes a rewrite pointless: identical digest, identical bytes.
if (await pathExists(target)) {
return digest
}
await mkdir(join(journalDir, JOURNAL_BLOB_DIR), { recursive: true })
await writeFileDurable(durableWriteTempPath(target), target, payload)
return digest
}
export async function readJournalBlob(journalDir: string, digest: string): Promise<string | null> {
const source = blobPath(journalDir, digest)
if (!source) {
return null
}
try {
return await readFile(source, 'utf-8')
} catch {
return null
}
}
/** Remove a blob written speculatively for a row that was rejected. */
export async function removeJournalBlob(journalDir: string, digest: string): Promise<void> {
const target = blobPath(journalDir, digest)
if (target) {
await rm(target, { force: true })
}
}
/** Drop every blob outside `retained`. Called from compaction, under the
* current lease fence, after the snapshot is durable — so a crash mid-prune
* leaves extra blobs rather than dangling references. */
export async function pruneJournalBlobs(
journalDir: string,
retained: ReadonlySet<string>
): Promise<number> {
let removed = 0
let names: string[]
try {
names = await readdir(join(journalDir, JOURNAL_BLOB_DIR))
} catch {
return 0
}
for (const name of names) {
if (retained.has(name)) {
continue
}
await rm(join(journalDir, JOURNAL_BLOB_DIR, name), { force: true }).catch(() => {})
removed += 1
}
return removed
}
async function pathExists(path: string): Promise<boolean> {
try {
await stat(path)
return true
} catch {
return false
}
}
export async function journalBlobFileSize(
journalDir: string,
digest: string
): Promise<number | null> {
const target = blobPath(journalDir, digest)
if (!target) {
return null
}
try {
return (await stat(target)).size
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
}
@@ -0,0 +1,68 @@
// Where a journal goes when its close REJECTS.
//
// `AgentSessionJournal.close()` is deliberately retryable: a rejection does not
// release the handle, and a second call is a real second attempt. Every caller
// that did `close().catch(() => undefined)` and then threw or overwrote its map
// entry defeated that contract — the store became unreachable with its SQLite
// connection still open. On POSIX that is a silent leak; on Windows the open
// handle blocks renaming or removing the journal directory outright.
//
// So a rejected close hands the journal here instead of dropping it, and host
// teardown retries everything this holds. Retention is bounded by construction:
// an entry leaves the set the moment its close fulfils, and a journal can be
// retained only once because the set is keyed by identity.
/** Everything the registry needs; `AgentSessionJournal` satisfies it. */
export type RetryableJournalClose = {
close: () => Promise<void>
readonly directory: string
}
export class JournalCloseRetryRegistry {
private readonly retained = new Set<RetryableJournalClose>()
/** Directories still held by a journal whose close has not fulfilled. */
get pendingDirectories(): string[] {
return [...this.retained].map((journal) => journal.directory)
}
/**
* Close it. Returns true when the handle is actually released; on a rejection
* the journal is RETAINED for `retryAll` and the rejection is returned rather
* than thrown, because every caller of this is already unwinding a different
* failure it must not lose.
*/
async closeOrRetain(
journal: RetryableJournalClose
): Promise<{ closed: boolean; error?: unknown }> {
try {
await journal.close()
this.retained.delete(journal)
return { closed: true }
} catch (error) {
this.retained.add(journal)
return { closed: false, error }
}
}
/** Retry every retained close. Ones that fulfil are dropped; ones that reject
* stay retained and their rejections are returned for the caller to report. */
async retryAll(): Promise<unknown[]> {
const entries = [...this.retained]
const failures: unknown[] = []
for (const journal of entries) {
const result = await this.closeOrRetain(journal)
if (!result.closed) {
failures.push(result.error)
}
}
return failures
}
}
/**
* Process-wide, because ownership of these handles is process-wide: the attach
* path, the recovery wrapper and runtime teardown are separate call trees that
* must all be able to reach the same orphan.
*/
export const agentSessionJournalCloseRetries = new JournalCloseRetryRegistry()
@@ -1,206 +0,0 @@
// Retention and compaction.
//
// The snapshot carries the retained tail with it, so publishing both is ONE
// atomic write and there is no window where the folded state exists without the
// rows a reconnecting client still needs. Truncating the log afterwards is
// idempotent: a crash before it leaves the log a superset of the tail.
//
// The retained tail must cover the longest reconnect window Orca supports, or a
// client that was merely asleep gets a full snapshot reload instead of a resume.
import { blobDigestsInBody, renderJournalState, type JournalReducerState } from './journal-reducer'
import { pruneJournalBlobs } from './journal-blob-store'
import {
rewriteJournalLog,
writeJournalSnapshotFile,
type JournalSnapshotFile
} from './journal-log-file'
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
import type { JournalRow } from './journal-row-schema'
import { AgentSessionJournalError } from './journal-write-guards'
import { assertJournalPhysicalCapacity, journalDirectoryBytes } from './journal-physical-quota'
export type JournalCompactionPolicy = {
/** Always keep at least this many rows, however old they are. */
minTailRows: number
/** Keep every row observed within this window. */
retainTailMs: number
/**
* `window` honours `retainTailMs` outright. `budget-pressure` lets it yield:
* the alternative is refusing the user's writes until the window ages out,
* and the tail is only a resume optimization — compaction folds every shed
* row into the snapshot before truncating the log, so a client that loses
* its resume point reloads instead of losing conversation. Defaults to
* `window`.
*/
retention?: 'window' | 'budget-pressure'
}
/** Two hours of tail comfortably covers a phone that slept through a commute,
* which is the longest reconnect Orca resumes rather than reloads. */
export const DEFAULT_JOURNAL_COMPACTION_POLICY: JournalCompactionPolicy = {
minTailRows: 512,
retainTailMs: 2 * 60 * 60 * 1000
}
export type JournalCompactionResult = {
tailRows: JournalRow[]
compactedThrough: number
oldestSequence: number
}
export async function compactJournal(input: {
journalDir: string
/** Parent quota root when compacting an in-directory staging journal. */
physicalQuotaRoot?: string
state: JournalReducerState
tailRows: readonly JournalRow[]
policy?: JournalCompactionPolicy
now: number
maxSessionBytes: number
sessionId?: string
}): Promise<JournalCompactionResult> {
const policy = input.policy ?? DEFAULT_JOURNAL_COMPACTION_POLICY
const retained = retainTail(input.tailRows, policy, input.now)
const rendered = renderJournalState(input.state)
const compactedThrough = input.state.lastSequence
const snapshot: JournalSnapshotFile = {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
epoch: input.state.epoch,
compactedThrough,
highestFence: input.state.highestFence,
items: rendered.items,
submissions: rendered.submissions,
receipts: [...input.state.receipts.values()].map((receipt) => ({
clientMessageId: receipt.clientMessageId,
providerItemId: receipt.providerItemId,
epoch: receipt.cursor.epoch,
sequence: receipt.cursor.sequence,
acceptedAt: receipt.acceptedAt
})),
aliases: [...input.state.aliases.entries()].map(([providerItemId, itemId]) => ({
providerItemId,
itemId
})),
tombstones: [...input.state.tombstones.entries()].map(([itemId, revision]) => ({
itemId,
revision
})),
appliedSettlementIds: [...input.state.appliedSettlementIds],
tail: retained
}
const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshot), 'utf8')
if (snapshotBytes > input.maxSessionBytes) {
throw new AgentSessionJournalError(
'journal_bound_exceeded',
`agent-session journal snapshot reached its ${input.maxSessionBytes}-byte bound`
)
}
const sessionId = input.sessionId ?? input.state.sessionId
const quotaRoot = input.physicalQuotaRoot ?? input.journalDir
const retainedLogBytes = retained.reduce(
(total, row) => total + Buffer.byteLength(JSON.stringify(row), 'utf8') + 1,
0
)
// Durable writes keep the old final alongside the new temp until rename.
// Reserve the complete compaction peak up front so a later copy cannot leave
// a half-published snapshot/log pair when the quota is tight.
await assertJournalPhysicalCapacity({
journalDir: quotaRoot,
sessionId,
maxBytes: input.maxSessionBytes,
peakAdditionalBytes: snapshotBytes + retainedLogBytes
})
await writeJournalSnapshotFile(input.journalDir, snapshot)
await rewriteJournalLog(input.journalDir, retained)
// Blobs are pruned last: a crash before this leaks bytes, whereas pruning
// first would strand a snapshot pointing at a payload that no longer exists.
// Recompute from exactly what the durable snapshot and retained log carry;
// this preserves reused/pre-existing blobs while allowing stale payloads to
// be pruned safely after both files are published.
const retainedDigests = new Set<string>()
for (const item of snapshot.items) {
blobDigestsInBody(item.body, retainedDigests)
}
for (const row of retained) {
if (row.kind === 'item') {
blobDigestsInBody(row.body, retainedDigests)
} else if (row.kind === 'lifecycle-batch') {
for (const mutation of row.mutations) {
if (mutation.kind === 'item') {
blobDigestsInBody(mutation.body, retainedDigests)
}
}
}
}
await pruneJournalBlobs(input.journalDir, retainedDigests)
if ((await journalDirectoryBytes(quotaRoot)) > input.maxSessionBytes) {
throw new AgentSessionJournalError(
'journal_bound_exceeded',
`agent-session journal for ${sessionId} exceeds its physical bound after compaction`
)
}
return {
tailRows: retained,
compactedThrough,
oldestSequence: retained[0]?.seq ?? compactedThrough + 1
}
}
function retainTail(
rows: readonly JournalRow[],
policy: JournalCompactionPolicy,
now: number
): JournalRow[] {
if (rows.length <= policy.minTailRows) {
return [...rows]
}
const floor = now - policy.retainTailMs
const byAge = rows.findIndex((row) => row.ts >= floor)
const byCount = rows.length - policy.minTailRows
const start = byAge === -1 ? byCount : Math.min(byAge, byCount)
if (policy.retention !== 'budget-pressure') {
return rows.slice(start)
}
// Halve rather than empty: the newer half keeps live clients resuming, and
// shedding at least one row guarantees the append that triggered this makes
// progress instead of latching the session read-only.
return rows.slice(Math.max(start, Math.ceil(rows.length / 2)))
}
/** Only when the retention window would actually drop rows: inside it,
* compaction rewrites an identical log, and doing that per append is a full
* state serialization on the hot path. */
export function journalTailIsReadyToCompact(
tailRows: readonly JournalRow[],
policy: JournalCompactionPolicy,
now: number
): boolean {
if (tailRows.length <= policy.minTailRows * 2) {
return false
}
return (tailRows[0]?.ts ?? now) < now - policy.retainTailMs
}
/** The policy an append falls back to when the size bound would otherwise
* refuse it: both floors that normally protect the tail step aside. */
export function budgetPressurePolicy(policy: JournalCompactionPolicy): JournalCompactionPolicy {
return { ...policy, minTailRows: 0, retention: 'budget-pressure' }
}
/** Budget pressure may need to shed rows before the ordinary batching threshold.
* Pass a `budget-pressure` policy, or a tail wholly inside the retention
* window answers false and the size bound refuses every append until it ages
* out — two hours of a session the user cannot write to. */
export function journalTailCanShedRows(
tailRows: readonly JournalRow[],
policy: JournalCompactionPolicy,
now: number
): boolean {
return retainTail(tailRows, policy, now).length < tailRows.length
}
@@ -1,102 +0,0 @@
// Corruption never deletes history. A journal that cannot be read end to end
// keeps its intact prefix live and moves the unreadable remainder aside, so the
// bytes stay on disk for inspection instead of being rebuilt into an empty epoch.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
JOURNAL_SNAPSHOT_FILE,
quarantineJournalRemainder,
readJournalLog,
rewriteJournalLog
} from './journal-log-file'
import type { JournalRow } from './journal-row-schema'
import { assertJournalPhysicalCapacity } from './journal-physical-quota'
/** Keep the readable prefix and set the unreadable suffix aside. */
export async function quarantineCorruptSuffix(
journalDir: string,
retainedRows: readonly JournalRow[],
remainder: string | undefined,
quota?: { sessionId: string; maxBytes: number }
): Promise<void> {
if (remainder) {
if (quota) {
await assertJournalPhysicalCapacity({
journalDir,
...quota,
peakAdditionalBytes: Buffer.byteLength(remainder, 'utf8')
})
}
await quarantineJournalRemainder(journalDir, remainder)
}
if (quota) {
const retainedBytes = retainedRows.reduce(
(total, row) => total + Buffer.byteLength(JSON.stringify(row), 'utf8') + 1,
0
)
await assertJournalPhysicalCapacity({
journalDir,
...quota,
peakAdditionalBytes: retainedBytes
})
}
await rewriteJournalLog(journalDir, retainedRows)
}
/** Copy everything aside before a read-only journal is rebuilt under a newer
* schema: those rows are unreadable to THIS build, not worthless. The
* snapshot is preserved as raw bytes — a future-version snapshot does not
* parse under this build's schema, and its bytes must survive verbatim. */
export async function quarantineUnreadableSchema(
journalDir: string,
quota?: { sessionId: string; maxBytes: number }
): Promise<void> {
const snapshot = await readSnapshotBytes(journalDir)
const log = await readJournalLog(journalDir)
const preserved = [
snapshot ?? '',
log.rows.map((row) => JSON.stringify(row)).join('\n'),
log.remainder ?? ''
]
.filter(Boolean)
.join('\n')
if (preserved) {
if (quota) {
await assertJournalPhysicalCapacity({
journalDir,
...quota,
peakAdditionalBytes: Buffer.byteLength(preserved, 'utf8')
})
}
await quarantineJournalRemainder(journalDir, preserved)
}
}
async function readSnapshotBytes(journalDir: string): Promise<string | null> {
try {
return await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
}
/** The disclosure row for lines that failed to parse. Skipped lines are lost
* rows; counting them silently is the drop this exists to prevent. */
export function malformedRowsDisclosure(count: number): {
identity: { provider: 'orca'; clientMessageId: string }
body: { kind: 'status'; text: string }
} {
const plural = count === 1 ? '' : 's'
return {
// One stable identity, so a reopen upserts the same row instead of adding one.
identity: { provider: 'orca', clientMessageId: 'journal-malformed-lines' },
body: {
kind: 'status',
text: `${count} journal line${plural} could not be read and ${count === 1 ? 'was' : 'were'} skipped`
}
}
}
@@ -0,0 +1,265 @@
// A repair drops what it cannot replay, and says so.
//
// Two things make a suffix unreplayable: a row this build cannot parse, and a
// sequence gap that makes every later row unanchored. The rejected suffix is
// DELETED; the load reports `corrupt`, and recovery rebuilds the epoch from
// provider history. Every case here asserts the same two halves: the live epoch
// holds only the replayable prefix, AND the epoch stays anchored so nothing
// replays a repaired journal as a clean timeline.
import { 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 type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type Database from '../../sqlite/sync-database'
import { openJournalDatabase } from './journal-database'
import { journalDatabaseFile } from './journal-paths'
import { parseJournalRow, type JournalRow } from './journal-row-schema'
import { loadJournal } from './journal-open'
import type { openAgentSessionJournal } from './journal-store-factory'
import { createTrackedJournalOpener } from './journal-store-test-open'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
let root: string
let clock = 1_000
const journals = createTrackedJournalOpener()
function tick(): number {
clock += 1
return clock
}
function item(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
function body(value: string): AgentJournalItemBody {
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
}
function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
return journals.open({
identity: IDENTITY,
journalDir: root,
now: tick,
mintEpoch: () => `epoch-${clock}`,
...overrides
})
}
async function withJournalDatabase(run: (db: Database.Database) => void): Promise<void> {
const opened = openJournalDatabase(journalDatabaseFile(root))
try {
run(opened.db)
} finally {
opened.db.close()
}
}
/** The row replay anchors on, parsed exactly as replay parses it. */
function firstLiveRow(): Promise<JournalRow | null> {
let row: JournalRow | null = null
return withJournalDatabase((db) => {
const stored = db.prepare('SELECT row_json FROM journal_rows ORDER BY seq LIMIT 1').get() as
| { row_json: string }
| undefined
const parsed = stored ? parseJournalRow(stored.row_json) : null
row = parsed?.ok ? parsed.row : null
}).then(() => row)
}
function liveSequences(): Promise<number[]> {
let sequences: number[] = []
return withJournalDatabase((db) => {
sequences = (
db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() as { seq: number }[]
).map((row) => row.seq)
}).then(() => sequences)
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-repair-'))
clock = 1_000
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
describe('a malformed row', () => {
it('keeps the readable prefix live and drops the rest of the epoch', async () => {
const journal = await open()
await journal.appendItem(item(0), body('readable'), { fence: 1 })
await journal.appendItem(item(1), body('unreadable'), { fence: 1 })
await journal.appendItem(item(2), body('after the fault'), { fence: 1 })
await journal.close()
await withJournalDatabase((db) => {
db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3)
})
const reopened = await open()
expect(reopened.repair.malformedRows).toBe(1)
// 1..2 is the surviving prefix; 3 is the disclosure the repair appends.
expect(await liveSequences()).toEqual([1, 2, 3])
})
it('discloses the line it could not read', async () => {
const journal = await open()
await journal.appendItem(item(0), body('readable'), { fence: 1 })
await journal.appendItem(item(1), body('later'), { fence: 1 })
await journal.close()
await withJournalDatabase((db) => {
db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2)
})
const reopened = await open()
const disclosure = reopened
.snapshot()
.items.map((entry) => entry.body)
.find((entry) => entry.kind === 'status')
expect(disclosure).toMatchObject({ kind: 'status' })
expect(disclosure && 'text' in disclosure ? disclosure.text : '').toContain(
'1 journal line could not be read'
)
})
})
describe('a sequence gap', () => {
it('drops every row after the hole and reports the epoch corrupt', async () => {
const journal = await open()
for (let ordinal = 0; ordinal < 5; ordinal += 1) {
await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 })
}
await journal.close()
// Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves
// 5 and 6 valid but unanchored.
await withJournalDatabase((db) => {
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4)
})
const reopened = await open()
expect(await liveSequences()).toEqual([1, 2, 3])
expect(reopened.repair).toEqual({ malformedRows: 0 })
expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0'), body('m1')])
})
// The prefix survives, so there is no emptied epoch to re-anchor and — a gap
// costing no malformed row — no disclosure either. Without a durable marker
// the next probe reads a contiguous anchored prefix and calls it clean, and
// the rows the repair deleted are never asked for again.
it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => {
const journal = await open()
for (let ordinal = 0; ordinal < 5; ordinal += 1) {
await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 })
}
await journal.close()
await withJournalDatabase((db) => {
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4)
})
const repaired = await open()
await repaired.close()
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
// Same policy the emptied-epoch repair takes: a session that writes into the
// epoch owns it, and a later import must not replace rows the user has seen.
const writable = await open()
await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 })
await writable.close()
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false })
})
// The disclosure is the repair talking about itself, not the session writing:
// counting it as content would retire the marker the instant it was raised.
it('is not settled by the repair disclosure it appends for a malformed row', async () => {
const journal = await open()
for (let ordinal = 0; ordinal < 3; ordinal += 1) {
await journal.appendItem(item(ordinal), body(`m${ordinal}`), { fence: 1 })
}
await journal.close()
await withJournalDatabase((db) => {
db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 3)
})
const repaired = await open()
expect(repaired.repair.malformedRows).toBe(1)
await repaired.close()
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
})
})
describe('a missing epoch row', () => {
// Sequence 1 is the anchor for the whole epoch. Validating from the first row
// that HAPPENS to remain declares the leftovers contiguous, and replay then
// renders a repaired journal as a clean timeline.
it('rejects the whole surviving range rather than declaring it contiguous', async () => {
const journal = await open()
await journal.appendItem(item(0), body('anchor'), { fence: 1 })
await journal.appendSubmission({
clientMessageId: 'client-message-1',
payloadFingerprint: 'fingerprint-1',
body: {
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'the user typed this' }]
},
fence: 1
})
await journal.resolveDispatch({
clientMessageId: 'client-message-1',
state: 'accepted',
providerIdentity: item(1),
fence: 1
})
await journal.close()
await withJournalDatabase((db) => {
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1)
})
const reopened = await open()
expect(reopened.repair).toEqual({ malformedRows: 0 })
expect(reopened.snapshot().items).toEqual([])
// The epoch cannot be left row-less. An ordinary append would then take
// sequence 1, and replay would call that non-epoch row a clean timeline.
expect(await liveSequences()).toEqual([1])
const anchor = await firstLiveRow()
expect(anchor).toMatchObject({ kind: 'epoch', reason: 'unreconcilable_prefix' })
})
// The repair epoch is a placeholder for history it could not rebuild. Left
// clean it would end automatic recovery: the provider transcript is never
// consulted again and the dropped rows never come back.
it('keeps asking for provider history until the epoch has content of its own', async () => {
const journal = await open()
await journal.appendItem(item(0), body('anchor'), { fence: 1 })
await journal.close()
await withJournalDatabase((db) => {
db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1)
})
const repaired = await open()
await repaired.close()
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
// A session that writes into the epoch owns it: its own rows are not a
// repair placeholder, and a later import must not replace them.
const writable = await open()
await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 })
await writable.close()
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false })
})
})
@@ -21,7 +21,7 @@ import {
type ProviderHistoryItem,
type ProviderHistoryWindow
} from './journal-submission-reconciler'
import { openAgentSessionJournal } from './journal-store-factory'
import { createTrackedJournalOpener } from './journal-store-test-open'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
@@ -52,8 +52,10 @@ function userMessage(text: string): AgentJournalMessageItem {
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
}
const journals = createTrackedJournalOpener()
async function open() {
return openAgentSessionJournal({
return journals.open({
identity: IDENTITY,
journalDir: root,
now: tick,
@@ -89,6 +91,7 @@ beforeEach(async () => {
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
@@ -195,14 +198,8 @@ describe('crash between provider accept and journal commit', () => {
).toEqual([])
})
it('keeps the receipt after the row that minted it was compacted away', async () => {
const journal = await openAgentSessionJournal({
identity: IDENTITY,
journalDir: root,
now: tick,
mintEpoch: () => `epoch-${clock}`,
compaction: { minTailRows: 1, retainTailMs: 0 }
})
it('keeps the receipt across a reopen', async () => {
const journal = await open()
await journal.appendSubmission({
clientMessageId: 'cm_1',
payloadFingerprint: digestPayload('kept'),
@@ -215,7 +212,7 @@ describe('crash between provider accept and journal commit', () => {
providerIdentity: ACCEPTED_IDENTITY,
fence: 1
})
await journal.compact()
await journal.close()
const reopened = await open()
expect(reopened.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(ACCEPTED_IDENTITY))
@@ -77,7 +77,7 @@ export function findSequenceGap(
export function readJournalSince(
source: {
state: { epoch: string; lastSequence: number; oldestSequence: number }
tailRows: readonly JournalRow[]
rowsAfter: (afterSequence: number) => JournalRow[]
readOnly: boolean
},
cursor: AgentJournalCursor,
@@ -92,7 +92,7 @@ export function readJournalSince(
}
return {
ok: true,
rows: source.tailRows.filter((row) => row.seq > resume.afterSequence),
rows: source.rowsAfter(resume.afterSequence),
cursor: currentCursor()
}
}
@@ -0,0 +1,37 @@
// Table shape for one session's journal database.
//
// `journal_rows` is the append-only log; `journal_sessions` is the derived
// projection, upserted in the SAME transaction as every row insert so the live
// epoch and the rows that belong to it can never disagree. `journal_repairs`
// carries at most one row per session: the standing demand for a rebuild a
// partial repair leaves behind (see journal-repair-marker.ts).
/** DB shape version, carried in `PRAGMA user_version`. Independent of the row
* body version (`JournalRow.v`): a newer build can change either alone.
* v2 added `journal_repairs`; a build without it would replay a partially
* repaired journal as clean, so it must latch read-only rather than write. */
export const JOURNAL_DB_SCHEMA_VERSION = 2
export function createJournalTablesSql(): string {
return `
CREATE TABLE IF NOT EXISTS journal_rows (
session_id TEXT NOT NULL,
epoch TEXT NOT NULL,
seq INTEGER NOT NULL,
ts INTEGER NOT NULL,
row_json TEXT NOT NULL,
PRIMARY KEY (session_id, epoch, seq)
);
CREATE TABLE IF NOT EXISTS journal_sessions (
session_id TEXT PRIMARY KEY,
epoch TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS journal_repairs (
session_id TEXT PRIMARY KEY,
epoch TEXT NOT NULL,
content_from INTEGER NOT NULL,
repaired_at INTEGER NOT NULL
);
`
}
@@ -0,0 +1,201 @@
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Database from '../../sqlite/sync-database'
import {
JOURNAL_BUSY_TIMEOUT_MS,
journalPragmaNumber,
openJournalDatabase
} from './journal-database'
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { journalDatabaseFile } from './journal-paths'
import {
deleteAllJournalRows,
deleteJournalRowSuffix,
insertJournalRow,
readJournalEpochRows,
readJournalRowsAfter,
readJournalSessionEpoch,
upsertJournalSessionRow
} from './journal-row-table'
import type { JournalRow } from './journal-row-schema'
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
let root: string
let dbPath: string
function epochRow(seq: number, epoch = 'epoch-1'): JournalRow {
return {
kind: 'epoch',
reason: 'session_created',
providerHandle: { kind: 'codex', threadId: 'thread-1' },
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
epoch,
seq,
fence: 0,
ts: 1_700_000_000_000 + seq
}
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-db-'))
dbPath = journalDatabaseFile(root)
})
afterEach(async () => {
vi.restoreAllMocks()
await rm(root, { recursive: true, force: true })
})
describe('journal database open', () => {
it('creates both tables and reads back every load-bearing pragma', () => {
const opened = openJournalDatabase(dbPath)
try {
const tables = opened.db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all()
.map((entry) => (entry as { name: string }).name)
expect(tables).toContain('journal_rows')
expect(tables).toContain('journal_sessions')
expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal')
expect(journalPragmaNumber(opened.db, 'synchronous')).toBe(2)
expect(journalPragmaNumber(opened.db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS)
expect(journalPragmaNumber(opened.db, 'foreign_keys')).toBe(1)
expect(journalPragmaNumber(opened.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION)
expect(opened.readOnly).toBe(false)
} finally {
opened.db.close()
}
})
it('latches read-only on a future user_version without touching the file', async () => {
const seeded = openJournalDatabase(dbPath)
upsertJournalSessionRow(seeded.db, 'session-1', 'epoch-1', 1)
insertJournalRow(seeded.db, 'session-1', epochRow(1))
seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`)
seeded.db.close()
const before = await stat(dbPath)
const latched = openJournalDatabase(dbPath)
try {
expect(latched.readOnly).toBe(true)
expect(journalPragmaNumber(latched.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION + 5)
expect(readJournalEpochRows(latched.db, 'session-1', 'epoch-1')).toHaveLength(1)
expect(() => latched.db.exec("INSERT INTO journal_sessions VALUES ('x', 'y', 1)")).toThrow()
} finally {
latched.db.close()
}
expect((await stat(dbPath)).size).toBe(before.size)
expect(journalPragmaNumber(openJournalDatabase(dbPath).db, 'user_version')).toBe(
JOURNAL_DB_SCHEMA_VERSION + 5
)
})
// Site 1: the raw connection is owned by the open call until it returns.
it('closes the raw connection when schema setup throws', async () => {
const failing = join(root, 'nested', 'journal.db')
expect(() => openJournalDatabase(failing)).toThrow()
await expect(stat(`${failing}-wal`)).rejects.toThrow()
await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined()
root = await mkdtemp(join(tmpdir(), 'orca-journal-db-'))
})
})
describe('journal row statements', () => {
it('serves replay, resume, discard and suffix truncation from the primary key', () => {
const opened = openJournalDatabase(dbPath)
try {
const { db } = opened
db.exec('BEGIN IMMEDIATE')
for (let seq = 1; seq <= 5; seq += 1) {
insertJournalRow(db, 'session-1', epochRow(seq))
}
insertJournalRow(db, 'session-1', epochRow(1, 'epoch-old'))
upsertJournalSessionRow(db, 'session-1', 'epoch-1', 42)
db.exec('COMMIT')
expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1')
expect(readJournalSessionEpoch(db, 'absent')).toBeNull()
expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([
1, 2, 3, 4, 5
])
expect(readJournalRowsAfter(db, 'session-1', 'epoch-1', 3).map((row) => row.seq)).toEqual([
4, 5
])
// The rejected suffix leaves `journal_rows`, scoped to its own epoch.
expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2)
expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([
1, 2, 3
])
expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(1)
deleteAllJournalRows(db)
expect(readJournalEpochRows(db, 'session-1', 'epoch-1')).toHaveLength(0)
expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(0)
expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1')
} finally {
opened.db.close()
}
})
it('refuses a duplicate sequence inside one epoch', () => {
const opened = openJournalDatabase(dbPath)
try {
insertJournalRow(opened.db, 'session-1', epochRow(1))
expect(() => insertJournalRow(opened.db, 'session-1', epochRow(1))).toThrow()
insertJournalRow(opened.db, 'session-1', epochRow(1, 'epoch-2'))
} finally {
opened.db.close()
}
})
it('upserts the session projection in place', () => {
const opened = openJournalDatabase(dbPath)
try {
upsertJournalSessionRow(opened.db, 'session-1', 'epoch-1', 1)
upsertJournalSessionRow(opened.db, 'session-1', 'epoch-2', 2)
expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-2')
expect(
opened.db.prepare('SELECT count(*) AS total FROM journal_sessions').get()
).toMatchObject({ total: 1 })
} finally {
opened.db.close()
}
})
})
describe('schema creation', () => {
// Creating the tables outside the migration transaction left a v2-shaped
// database still reporting version 0, which an older build does not latch
// read-only: it stamps its own version on and writes through v1 SQL.
it('publishes no table until the version bump commits with it', () => {
const original = Database.prototype.pragma
const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function (
this: Database.Database,
sql: string,
options?: { simple?: boolean }
) {
if (sql.startsWith('user_version =')) {
throw new Error('crash before the version is published')
}
return original.call(this, sql, options)
})
expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published')
pragma.mockRestore()
const inspected = new Database(dbPath)
try {
expect(inspected.pragma('user_version', { simple: true })).toBe(0)
expect(
inspected
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journal_rows'")
.get()
).toBeUndefined()
} finally {
inspected.close()
}
})
})
@@ -0,0 +1,80 @@
// Opening one session's journal database.
//
// `PRAGMA user_version` is read FIRST, on a connection that has set no
// persistent pragma and run no DDL: a future-schema database must be left
// byte-identical, and `journal_mode = WAL` writes the file header.
import Database from '../../sqlite/sync-database'
import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files'
import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
export const JOURNAL_BUSY_TIMEOUT_MS = 5000
export type OpenJournalDatabase = {
db: Database.Database
/** A newer `user_version` was met: this build reads and never writes. */
readOnly: boolean
}
export function journalPragmaNumber(db: Database.Database, name: string): number {
return Number(db.pragma(name, { simple: true }) ?? 0)
}
export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
const probe = new Database(dbPath)
let stored: number
try {
stored = journalPragmaNumber(probe, 'user_version')
} catch (error) {
probe.close()
throw error
}
if (stored > JOURNAL_DB_SCHEMA_VERSION) {
probe.close()
return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true }
}
let transferred = false
try {
configureJournalPragmas(probe)
createJournalSchema(probe, stored)
hardenSqliteDatabaseFiles(dbPath)
const opened = { db: probe, readOnly: false }
transferred = true
return opened
} finally {
if (!transferred) {
probe.close()
}
}
}
function configureJournalPragmas(db: Database.Database): void {
db.pragma('journal_mode = WAL')
db.pragma(`busy_timeout = ${JOURNAL_BUSY_TIMEOUT_MS}`)
db.pragma('foreign_keys = ON')
// Why FULL rather than the house NORMAL: the write-ahead submission row must
// survive a power loss before the adapter dispatches anything, and NORMAL in
// WAL mode does not fsync at commit.
db.pragma('synchronous = FULL')
}
/**
* Table creation and the `user_version` bump are ONE transaction. Creating the
* tables first left a shaped database still reporting version 0, which an older
* build does not latch read-only: it stamped its own version on and wrote
* through SQL for a schema it did not have.
*/
function createJournalSchema(db: Database.Database, stored: number): void {
if (stored >= JOURNAL_DB_SCHEMA_VERSION) {
return
}
db.exec('BEGIN IMMEDIATE')
try {
db.exec(createJournalTablesSql())
db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`)
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
}
@@ -2,28 +2,21 @@ import type {
AgentJournalCursor,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { JournalCompactionPolicy } from './journal-compaction'
import { quarantineUnreadableSchema } from './journal-corruption-quarantine'
import type Database from '../../sqlite/sync-database'
import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement'
import { publishNewEpoch } from './journal-epoch-rollover'
import type { JournalLoad } from './journal-open'
import type { AgentJournalEpochReason } from './journal-row-schema'
import {
assertJournalFence,
assertJournalWritable,
type JournalAppendBudget
} from './journal-write-guards'
import { assertJournalFence, assertJournalWritable } from './journal-write-guards'
export class JournalEpochController {
constructor(
private readonly deps: {
identity: AgentSessionJournalIdentity
journalDir: string
budget: JournalAppendBudget
compaction: JournalCompactionPolicy
now: () => number
mintEpoch: () => string
serialize: <T>(run: () => Promise<T>) => Promise<T>
database: () => { db: Database.Database }
readOnly: () => boolean
setReadOnly: (readOnly: boolean) => void
highestFence: () => number
@@ -32,33 +25,33 @@ export class JournalEpochController {
}
) {}
async start(reason: AgentJournalEpochReason, fence: number): Promise<void> {
this.deps.adopt(
await publishNewEpoch({
journalDir: this.deps.journalDir,
sessionId: this.deps.identity.sessionId,
providerHandle: this.deps.identity.providerHandle,
epoch: this.deps.mintEpoch(),
reason,
fence,
now: this.deps.now(),
maxSessionBytes: this.deps.budget.maxSessionBytes
})
)
start(reason: AgentJournalEpochReason, fence: number): void {
publishNewEpoch({
db: this.deps.database().db,
sessionId: this.deps.identity.sessionId,
providerHandle: this.deps.identity.providerHandle,
epoch: this.deps.mintEpoch(),
reason,
fence,
now: this.deps.now(),
onPublished: this.deps.adopt
})
}
async roll(reason: AgentJournalEpochReason, fence: number): Promise<AgentJournalCursor> {
if (reason !== 'schema_unreadable') {
/**
* Every reason takes the same writable guard. A latched store refuses a roll
* like any other write, and `schema_unreadable` has no production caller.
*
* Serialized like every other write, so the discard cannot land between an
* admitted append's sequence assignment and its commit.
*/
roll(reason: AgentJournalEpochReason, fence: number): Promise<AgentJournalCursor> {
return this.deps.serialize(async () => {
assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId)
} else if (this.deps.readOnly()) {
await quarantineUnreadableSchema(this.deps.journalDir, {
sessionId: this.deps.identity.sessionId,
maxBytes: this.deps.budget.maxSessionBytes
})
}
await this.start(reason, fence)
this.deps.setReadOnly(false)
return this.deps.cursor()
this.start(reason, fence)
this.deps.setReadOnly(false)
return this.deps.cursor()
})
}
replace(
@@ -69,17 +62,15 @@ export class JournalEpochController {
return this.deps.serialize(async () => {
assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId)
assertJournalFence(fence, this.deps.highestFence())
await replaceJournalEpoch({
journalDir: this.deps.journalDir,
replaceJournalEpoch({
db: this.deps.database().db,
identity: this.deps.identity,
reason,
fence,
items,
budget: this.deps.budget.fork(),
compaction: this.deps.compaction,
now: this.deps.now,
mintEpoch: this.deps.mintEpoch,
onSnapshotPublished: this.deps.adopt
onPublished: this.deps.adopt
})
return this.deps.cursor()
})
@@ -1,18 +1,19 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises'
// Republishing an epoch is ONE transaction.
import { 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 type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { DEFAULT_JOURNAL_COMPACTION_POLICY } from './journal-compaction'
import { openJournalDatabase, type OpenJournalDatabase } from './journal-database'
import { replaceJournalEpoch } from './journal-epoch-replacement'
import { putJournalBlob, readJournalBlob } from './journal-blob-store'
import { boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { journalDirectoryBytes } from './journal-physical-quota'
import { JournalAppendBudget } from './journal-write-guards'
import { openAgentSessionJournal } from './journal-store-factory'
import type { JournalLoad } from './journal-open'
import { journalDatabaseFile } from './journal-paths'
import { readJournalEpochRows, readJournalSessionEpoch } from './journal-row-table'
import { createTrackedJournalOpener } from './journal-store-test-open'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
@@ -24,150 +25,79 @@ const IDENTITY: AgentSessionJournalIdentity = {
let root: string
let clock = 1_000
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-'))
clock = 1_000
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
let database: OpenJournalDatabase
const journals = createTrackedJournalOpener()
function now(): number {
clock += 1
return clock
}
function toolBody(output: ReturnType<typeof boundPayload>): AgentJournalItemBody {
return {
kind: 'tool-call',
name: 'shell',
input: {},
state: 'completed',
output
}
function item(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
describe('journal epoch replacement', () => {
it('publishes one observable replacement and prunes stale root blobs afterward', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 8 }
const stalePayload = 'stale'.repeat(1_000)
const retainedPayload = 'retained'.repeat(1_000)
const stale = boundPayload(stalePayload, limits)
const retained = boundPayload(retainedPayload, limits)
const published: unknown[] = []
await putJournalBlob(root, stale.digest, stalePayload)
function replace(input: {
items: Parameters<typeof replaceJournalEpoch>[0]['items']
onPublished?: (loaded: JournalLoad) => void
}): void {
replaceJournalEpoch({
db: database.db,
identity: IDENTITY,
reason: 'legacy_import',
fence: 1,
items: input.items,
now,
mintEpoch: () => `epoch-${clock}`,
onPublished: input.onPublished ?? (() => undefined)
})
}
await replaceJournalEpoch({
journalDir: root,
identity: IDENTITY,
reason: 'handle_forked',
fence: 2,
items: [
{
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
body: toolBody(retained),
blobs: [{ digest: retained.digest, payload: retainedPayload }]
}
],
budget: new JournalAppendBudget(IDENTITY.sessionId, {
...limits,
maxSessionBytes: 512 * 1024
}),
compaction: DEFAULT_JOURNAL_COMPACTION_POLICY,
now,
mintEpoch: () => 'epoch-new',
onSnapshotPublished: (loaded) => published.push(loaded)
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-'))
clock = 1_000
database = openJournalDatabase(journalDatabaseFile(root))
})
afterEach(async () => {
try {
database.db.close()
} catch {
// Already closed by the case.
}
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
describe('journal epoch replacement', () => {
it('publishes one observable replacement', () => {
const published: JournalLoad[] = []
replace({
items: [{ identity: item(1), body: { kind: 'status', text: 'republished' } }],
onPublished: (loaded) => published.push(loaded)
})
expect(published).toHaveLength(1)
expect(await readJournalBlob(root, stale.digest)).toBeNull()
expect(await readJournalBlob(root, retained.digest)).toBe(retainedPayload)
expect((published[0] as { sizeBytes: number }).sizeBytes).toBe(
await journalDirectoryBytes(root)
)
const epoch = readJournalSessionEpoch(database.db, IDENTITY.sessionId)
expect(epoch).toBe(published[0]?.state.epoch)
expect(readJournalEpochRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2)
})
it('keeps root blobs and reports no publication when replacement never becomes authoritative', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 8, maxSessionBytes: 6_000 }
const stalePayload = 'stale'.repeat(500)
const stale = boundPayload(stalePayload, limits)
const published: unknown[] = []
await putJournalBlob(root, stale.digest, stalePayload)
it('discards every superseded row in the same transaction', async () => {
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 })
await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 })
const before = journal.epoch
await expect(
replaceJournalEpoch({
journalDir: root,
identity: IDENTITY,
reason: 'handle_forked',
fence: 2,
items: [
{
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
body: {
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'x'.repeat(10_000) }]
}
}
],
budget: new JournalAppendBudget(IDENTITY.sessionId, limits),
compaction: DEFAULT_JOURNAL_COMPACTION_POLICY,
now,
mintEpoch: () => 'epoch-new',
onSnapshotPublished: (loaded) => published.push(loaded)
})
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
await journal.replaceEpochItems('legacy_import', 1, [
{ identity: item(9), body: { kind: 'status', text: 'republished' } }
])
expect(published).toHaveLength(0)
expect(await readJournalBlob(root, stale.digest)).toBe(stalePayload)
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
})
it('charges replacement blobs cumulatively and rolls back staging on quota refusal', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 8, maxSessionBytes: 7_000 }
const journal = await openAgentSessionJournal({
identity: IDENTITY,
journalDir: root,
limits,
autoCompact: false,
now,
mintEpoch: () => `epoch-${clock}`
})
const existingPayload = 'existing'.repeat(250)
const existing = boundPayload(existingPayload, limits)
await journal.appendItemWithBlobs(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
toolBody(existing),
[{ digest: existing.digest, payload: existingPayload }],
{ fence: 1 }
)
const replacementPayload = 'replacement'.repeat(200)
const replacement = boundPayload(replacementPayload, limits)
const secondPayload = 'second'.repeat(200)
const second = boundPayload(secondPayload, limits)
await expect(
journal.replaceEpochItems('handle_forked', 2, [
{
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 },
body: toolBody(replacement),
blobs: [{ digest: replacement.digest, payload: replacementPayload }]
},
{
identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 2 },
body: toolBody(second),
blobs: [{ digest: second.digest, payload: secondPayload }]
}
])
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(journal.epoch).toMatch(/^epoch-/)
expect(await readJournalBlob(root, existing.digest)).toBe(existingPayload)
expect(await readJournalBlob(root, replacement.digest)).toBeNull()
expect(await readJournalBlob(root, second.digest)).toBeNull()
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
expect(await journalDirectoryBytes(root)).toBeLessThanOrEqual(limits.maxSessionBytes)
expect(journal.epoch).not.toBe(before)
expect(readJournalEpochRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0)
expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([
{ kind: 'status', text: 'republished' }
])
})
})
@@ -1,298 +1,85 @@
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { copyFileDurable } from '../../durable-file-write'
// Republishing a live item set into a fresh epoch.
//
// One transaction: discard every row, insert the epoch row plus the replacement
// items, move the session projection, and retire any repair marker — this
// republished history is exactly what the marker was holding out for.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { compactJournal, type JournalCompactionPolicy } from './journal-compaction'
import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE, appendJournalRows } from './journal-log-file'
import {
applyJournalRow,
blobDigestsInBody,
createJournalReducerState,
referencedBlobDigests,
type JournalReducerState
} from './journal-reducer'
import { buildJournalItemRow, journalRowBase } from './journal-row-builders'
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
import { journalRowByteLength } from './journal-row-schema'
import { assertJournalFence, type JournalAppendBudget } from './journal-write-guards'
import type Database from '../../sqlite/sync-database'
import type { JournalLoad } from './journal-open'
import { assertJournalPhysicalCapacity, journalDirectoryBytes } from './journal-physical-quota'
import { clearJournalRepairMarker } from './journal-repair-marker'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import { buildJournalItemRow, journalRowBase } from './journal-row-builders'
import {
JOURNAL_BLOB_DIR,
journalBlobFileSize,
putJournalBlob,
pruneJournalBlobs,
removeJournalBlob
} from './journal-blob-store'
deleteAllJournalRows,
insertJournalRow,
upsertJournalSessionRow
} from './journal-row-table'
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
import { assertJournalFence } from './journal-write-guards'
export type JournalReplacementItem = {
identity: AgentJournalItemIdentity
body: AgentJournalItemBody
blobs?: readonly { digest: string; payload: string }[]
observedAt?: number
}
export async function replaceJournalEpoch(input: {
journalDir: string
export function replaceJournalEpoch(input: {
db: Database.Database
identity: AgentSessionJournalIdentity
reason: AgentJournalEpochReason
fence: number
items: readonly JournalReplacementItem[]
budget: JournalAppendBudget
compaction: JournalCompactionPolicy
now: () => number
mintEpoch: () => string
onSnapshotPublished: (loaded: JournalLoad) => void
}): Promise<void> {
const stagingDir = await mkdtemp(join(input.journalDir, '.epoch-replacement-'))
const stagedBlobDigests = new Set<string>()
const publishedBlobDigests: string[] = []
let snapshotPublished = false
let adoptionReported = false
let publishedLoad: JournalLoad | null = null
/** Called the instant the transaction commits, before any fallible follow-up. */
onPublished: (loaded: JournalLoad) => void
}): void {
const epoch = input.mintEpoch()
const state = createJournalReducerState(input.identity.sessionId, epoch)
const epochRow: JournalRow = {
kind: 'epoch',
reason: input.reason,
providerHandle: input.identity.providerHandle,
...journalRowBase(epoch, 1, input.fence, input.now())
}
const rows: JournalRow[] = [epochRow]
applyJournalRow(state, epochRow)
for (const item of input.items) {
const row = buildJournalItemRow({
state,
identity: item.identity,
body: item.body,
seq: state.lastSequence + 1,
fence: input.fence,
ts: item.observedAt ?? input.now()
})
assertJournalFence(row.fence, state.highestFence)
applyJournalRow(state, row)
rows.push(row)
}
input.db.exec('BEGIN IMMEDIATE')
try {
const epoch = input.mintEpoch()
const state = createJournalReducerState(input.identity.sessionId, epoch)
const epochRow: JournalRow = {
kind: 'epoch',
reason: input.reason,
providerHandle: input.identity.providerHandle,
...journalRowBase(epoch, 1, input.fence, input.now())
}
const rows: JournalRow[] = [epochRow]
applyJournalRow(state, epochRow)
let sizeBytes = journalRowByteLength(epochRow)
await assertStagingCapacity(input, sizeBytes)
await appendJournalRows(stagingDir, [epochRow])
for (const item of input.items) {
sizeBytes += await stageReplacementBlobs({
journalDir: input.journalDir,
stagingDir,
identity: input.identity,
budget: input.budget,
stagedBlobDigests,
blobs: item.blobs ?? []
})
const appendTime = input.now()
const row = buildJournalItemRow({
state,
identity: item.identity,
body: item.body,
seq: state.lastSequence + 1,
fence: input.fence,
ts: item.observedAt ?? appendTime
})
assertJournalFence(row.fence, state.highestFence)
input.budget.assert(row, appendTime, sizeBytes)
await assertStagingCapacity(input, journalRowByteLength(row))
await appendJournalRows(stagingDir, [row])
applyJournalRow(state, row)
rows.push(row)
sizeBytes += journalRowByteLength(row)
}
const compacted = await compactJournal({
journalDir: stagingDir,
physicalQuotaRoot: input.journalDir,
state,
tailRows: rows,
policy: input.compaction,
now: input.now(),
maxSessionBytes: input.budget.maxSessionBytes
})
// All destination publishes use durable temp files while the staging
// source and existing finals remain present. Reserve the whole publication
// peak before touching the live epoch so a later file cannot fail halfway
// through replacement.
const stagedSnapshotBytes = (await stat(join(stagingDir, JOURNAL_SNAPSHOT_FILE))).size
const stagedLogBytes = (await stat(join(stagingDir, JOURNAL_LOG_FILE))).size
let stagedPublishBytes = stagedSnapshotBytes + stagedLogBytes
for (const digest of stagedBlobDigests) {
stagedPublishBytes += (await stat(join(stagingDir, JOURNAL_BLOB_DIR, digest))).size
}
await assertJournalPhysicalCapacity({
journalDir: input.journalDir,
sessionId: input.identity.sessionId,
maxBytes: input.budget.maxSessionBytes,
peakAdditionalBytes: stagedPublishBytes
})
for (const digest of stagedBlobDigests) {
if (
await publishPreparedBlob(
stagingDir,
input.journalDir,
digest,
input.identity.sessionId,
input.budget.maxSessionBytes
)
) {
publishedBlobDigests.push(digest)
}
}
await publishPreparedFile(
stagingDir,
input.journalDir,
JOURNAL_SNAPSHOT_FILE,
input.identity.sessionId,
input.budget.maxSessionBytes
)
snapshotPublished = true
state.oldestSequence = compacted.oldestSequence
publishedLoad = {
state,
tailRows: compacted.tailRows,
compactedThrough: compacted.compactedThrough,
readOnly: false,
corrupt: false,
malformedRows: 0,
sizeBytes: 0
}
await publishPreparedFile(
stagingDir,
input.journalDir,
JOURNAL_LOG_FILE,
input.identity.sessionId,
input.budget.maxSessionBytes
)
await pruneJournalBlobs(
input.journalDir,
replacementRetainedBlobDigests(state, compacted.tailRows)
)
} finally {
if (!snapshotPublished) {
for (const digest of publishedBlobDigests) {
await removeJournalBlob(input.journalDir, digest)
}
}
await rm(stagingDir, { recursive: true, force: true })
if (snapshotPublished && publishedLoad && !adoptionReported) {
adoptionReported = true
input.onSnapshotPublished({
...publishedLoad,
sizeBytes: await journalDirectoryBytes(input.journalDir)
})
deleteAllJournalRows(input.db)
clearJournalRepairMarker(input.db, input.identity.sessionId)
for (const row of rows) {
insertJournalRow(input.db, input.identity.sessionId, row)
}
upsertJournalSessionRow(input.db, input.identity.sessionId, epoch, epochRow.ts)
input.db.exec('COMMIT')
} catch (error) {
input.db.exec('ROLLBACK')
throw error
}
}
function replacementRetainedBlobDigests(
state: JournalReducerState,
tailRows: readonly JournalRow[]
): Set<string> {
const retained = referencedBlobDigests(state)
for (const row of tailRows) {
if (row.kind === 'item') {
blobDigestsInBody(row.body, retained)
} else if (row.kind === 'lifecycle-batch') {
for (const mutation of row.mutations) {
if (mutation.kind === 'item') {
blobDigestsInBody(mutation.body, retained)
}
}
}
}
return retained
}
async function stageReplacementBlobs(input: {
journalDir: string
stagingDir: string
identity: AgentSessionJournalIdentity
budget: JournalAppendBudget
stagedBlobDigests: Set<string>
blobs: readonly { digest: string; payload: string }[]
}): Promise<number> {
const toStage: { digest: string; payload: string; bytes: number }[] = []
const unique = new Map(input.blobs.map((blob) => [blob.digest, blob]))
for (const blob of unique.values()) {
if (input.stagedBlobDigests.has(blob.digest)) {
continue
}
if ((await journalBlobFileSize(input.journalDir, blob.digest)) !== null) {
continue
}
const bytes = Buffer.byteLength(blob.payload, 'utf8')
toStage.push({ ...blob, bytes })
}
// Reserve all new payloads together. The staging directory lives under the
// journal root, so the capacity check includes existing session bytes and
// every other .epoch-replacement-* directory already present.
const stagedBytes = toStage.reduce((total, blob) => total + blob.bytes, 0)
await assertStagingCapacity(input, stagedBytes)
for (const blob of toStage) {
await putJournalBlob(input.stagingDir, blob.digest, blob.payload)
input.stagedBlobDigests.add(blob.digest)
}
return stagedBytes
}
function assertStagingCapacity(
input: {
journalDir: string
identity: AgentSessionJournalIdentity
budget: JournalAppendBudget
},
additionalBytes: number
): Promise<number> {
return assertJournalPhysicalCapacity({
journalDir: input.journalDir,
sessionId: input.identity.sessionId,
maxBytes: input.budget.maxSessionBytes,
peakAdditionalBytes: additionalBytes
})
}
async function publishPreparedFile(
stagingDir: string,
journalDir: string,
fileName: string,
sessionId: string,
maxBytes: number
): Promise<void> {
await assertJournalPhysicalCapacity({
journalDir,
sessionId,
maxBytes,
peakAdditionalBytes: (await stat(join(stagingDir, fileName))).size
})
const copied = await copyFileDurable(join(stagingDir, fileName), join(journalDir, fileName))
if (!copied) {
throw new Error(`prepared journal file disappeared before publish: ${fileName}`)
}
}
async function publishPreparedBlob(
stagingDir: string,
journalDir: string,
digest: string,
sessionId: string,
maxBytes: number
): Promise<boolean> {
if ((await journalBlobFileSize(journalDir, digest)) !== null) {
return false
}
const size = await journalBlobFileSize(stagingDir, digest)
if (size === null) {
throw new Error(`prepared journal blob disappeared before publish: ${digest}`)
}
await assertJournalPhysicalCapacity({
journalDir,
sessionId,
maxBytes,
peakAdditionalBytes: size
})
await mkdir(join(journalDir, JOURNAL_BLOB_DIR), { recursive: true })
const copied = await copyFileDurable(
join(stagingDir, JOURNAL_BLOB_DIR, digest),
join(journalDir, JOURNAL_BLOB_DIR, digest)
)
if (!copied) {
throw new Error(`prepared journal blob disappeared before publish: ${digest}`)
}
return true
// COMMIT landed: on disk the superseded rows are gone and this epoch is the
// live one. The caller adopts that immediately, or a later failure leaves the
// live store writing into an epoch whose rows were just deleted.
state.oldestSequence = 1
input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 })
}
@@ -1,29 +1,34 @@
// Opening a new epoch.
//
// The snapshot is what names the live epoch, so it is published BEFORE the log
// is reset. A crash mid-rollover therefore leaves stale-epoch rows behind the
// new snapshot, which `loadJournal` drops — the reverse order would leave a
// journal whose log no longer matches any epoch anyone can name.
// One transaction: discard every row of the superseded epoch, insert the new
// epoch row at sequence 1, move the session projection onto it, and retire any
// repair marker the superseded epoch was carrying. Superseded rows are DELETED
// rather than retained — nothing would ever shed them.
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types'
import { compactJournal } from './journal-compaction'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
import { journalRowByteLength } from './journal-row-schema'
import type Database from '../../sqlite/sync-database'
import type { JournalLoad } from './journal-open'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { clearJournalRepairMarker } from './journal-repair-marker'
import { applyJournalRow, createJournalReducerState } from './journal-reducer'
import {
deleteAllJournalRows,
insertJournalRow,
upsertJournalSessionRow
} from './journal-row-table'
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
export async function publishNewEpoch(input: {
journalDir: string
export function publishNewEpoch(input: {
db: Database.Database
sessionId: string
providerHandle: AgentSessionProviderHandle
epoch: string
reason: AgentJournalEpochReason
fence: number
now: number
maxSessionBytes?: number
}): Promise<JournalLoad> {
/** Called the instant the transaction commits, before any fallible follow-up. */
onPublished: (loaded: JournalLoad) => void
}): void {
const row: JournalRow = {
kind: 'epoch',
reason: input.reason,
@@ -34,25 +39,24 @@ export async function publishNewEpoch(input: {
fence: input.fence,
ts: input.now
}
input.db.exec('BEGIN IMMEDIATE')
try {
deleteAllJournalRows(input.db)
clearJournalRepairMarker(input.db, input.sessionId)
insertJournalRow(input.db, input.sessionId, row)
upsertJournalSessionRow(input.db, input.sessionId, input.epoch, input.now)
input.db.exec('COMMIT')
} catch (error) {
input.db.exec('ROLLBACK')
throw error
}
// COMMIT landed: on disk the superseded prefix is gone and this epoch is the
// live one. The caller adopts that immediately, or a later failure leaves the
// store writing into an epoch that no longer exists.
const state = createJournalReducerState(input.sessionId, input.epoch)
await compactJournal({
journalDir: input.journalDir,
state,
tailRows: [row],
policy: { minTailRows: 1, retainTailMs: Number.POSITIVE_INFINITY },
now: input.now,
maxSessionBytes: input.maxSessionBytes ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes,
sessionId: input.sessionId
})
applyJournalRow(state, row)
state.oldestSequence = 1
return {
state,
tailRows: [row],
compactedThrough: 0,
readOnly: false,
corrupt: false,
malformedRows: 0,
sizeBytes: journalRowByteLength(row)
}
input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 })
}
@@ -0,0 +1,161 @@
// Every path that can open a SQLite connection releases it.
//
// Asserting that the happy path closes cleanly proves nothing: these sites are
// reached only when something has already gone wrong. On POSIX a leak is
// SILENT — the unlink succeeds — so each case asserts BOTH that the sidecars
// are gone and that the directory renames and removes, which is the half that
// actually fails on Windows.
import { access, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import Database from '../../sqlite/sync-database'
import { openJournalDatabase } from './journal-database'
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { loadJournal } from './journal-open'
import { journalDatabaseFile } from './journal-paths'
import { createTrackedJournalOpener } from './journal-store-test-open'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
let base: string
let root: string
const journals = createTrackedJournalOpener()
function item(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
function runningTool(): AgentJournalItemBody {
return { kind: 'tool-call', name: 'command', input: {}, state: 'running' }
}
async function exists(path: string): Promise<boolean> {
return access(path)
.then(() => true)
.catch(() => false)
}
/** The platform-independent proof: an open handle blocks both of these on
* Windows, where every leak in this file actually shows up. */
async function expectNothingHoldsTheDirectory(): Promise<void> {
const dbPath = journalDatabaseFile(root)
expect(await exists(`${dbPath}-wal`)).toBe(false)
expect(await exists(`${dbPath}-shm`)).toBe(false)
const moved = `${root}-recovered-vtest`
await rename(root, moved)
await rm(moved, { recursive: true })
root = join(base, `journal-${Math.random().toString(36).slice(2)}`)
}
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), 'orca-journal-handles-'))
root = join(base, 'journal')
})
afterEach(async () => {
await journals.closeAll()
await rm(base, { recursive: true, force: true })
})
describe('the standalone probe owns its own connection', () => {
it('leaves no handle behind after fifty repeated loads', async () => {
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.appendItem(item(1), { kind: 'message', role: 'user', blocks: [] }, { fence: 1 })
await journal.close()
for (let attempt = 0; attempt < 50; attempt += 1) {
const loaded = await loadJournal(root, IDENTITY.sessionId)
expect(loaded?.readOnly).toBe(false)
}
await expectNothingHoldsTheDirectory()
})
it('leaves no handle behind after fifty loads of a latched future schema', async () => {
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.close()
const seeded = openJournalDatabase(journalDatabaseFile(root))
seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 3}`)
seeded.db.close()
for (let attempt = 0; attempt < 50; attempt += 1) {
// The latched open closes the probe connection and returns the read-only
// reopen, so probing repeatedly leaves nothing on a file we must not touch.
expect((await loadJournal(root, IDENTITY.sessionId))?.readOnly).toBe(true)
}
// A read-only connection cannot remove the sidecars it materialized, so only
// the rename/remove half is expected to hold here.
const moved = `${root}-recovered-vtest`
await rename(root, moved)
await rm(moved, { recursive: true })
root = join(base, 'journal-after-latched')
})
it('returns null for a session with no journal, without creating one', async () => {
expect(await loadJournal(root, IDENTITY.sessionId)).toBeNull()
expect(await exists(journalDatabaseFile(root))).toBe(false)
})
})
describe('failure paths inside the open call', () => {
// Site 1: the raw connection is owned by `openJournalDatabase` until it returns.
it('closes the raw connection when the version read cannot run', async () => {
await journals.open({ identity: IDENTITY, journalDir: root }).then((journal) => journal.close())
await writeFile(journalDatabaseFile(root), 'this is not a database', 'utf8')
expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow()
await expectNothingHoldsTheDirectory()
})
it('closes the raw connection when the migration cannot start', async () => {
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.close()
const blocker = openJournalDatabase(journalDatabaseFile(root))
// Roll the stored version back so the migration runs, then hold the write
// lock it needs: the throw lands after the connection already exists.
blocker.db.pragma('user_version = 0')
blocker.db.exec('BEGIN IMMEDIATE')
blocker.db.exec("INSERT INTO journal_sessions VALUES ('other', 'e', 1)")
try {
expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow()
} finally {
blocker.db.exec('ROLLBACK')
blocker.db.close()
}
await expectNothingHoldsTheDirectory()
}, 60_000)
// Sites 2 and 3: `open()` closes its own connection on any throw after the
// connection exists, which is what lets the factory need no `finally`.
it('leaves nothing open when a post-connection step of open() throws', async () => {
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.appendItem(item(1), runningTool(), { fence: 1 })
await journal.close()
// Replay runs after the connection is open, so a read it cannot serve
// throws with the handle already held.
const exec = vi.spyOn(Database.prototype, 'prepare').mockImplementation(() => {
throw new Error('replay cannot read this journal')
})
try {
await expect(journals.open({ identity: IDENTITY, journalDir: root })).rejects.toThrow(
'replay cannot read this journal'
)
} finally {
exec.mockRestore()
}
await expectNothingHoldsTheDirectory()
})
})
@@ -5,23 +5,16 @@ import type {
} from '../../../shared/agent-session-journal-types'
import { journalItemRowBuilder } from './journal-row-builders'
import type { JournalReducerState } from './journal-reducer'
import type { AgentSessionJournal } from './journal-store'
import type { JournalAppendResult } from './journal-store-contracts'
import type { JournalRow } from './journal-row-schema'
import { appendToolOutputFallback } from './journal-tool-output-fallback'
type ItemAppendOptions = { fence: number; observedAt?: number; recovered?: true }
type JournalBlob = { digest: string; payload: string }
export class JournalItemAppender {
constructor(
private readonly deps: {
journal: () => AgentSessionJournal
state: () => JournalReducerState
enqueue: (
build: (seq: number, ts: number) => JournalRow,
blobs?: readonly JournalBlob[]
) => Promise<JournalRow>
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise<JournalRow>
}
) {}
@@ -33,37 +26,10 @@ export class JournalItemAppender {
const itemId = agentJournalItemKey(identity)
return this.deps
.enqueue(journalItemRowBuilder(this.deps.state, identity, body, options))
.then((row) => itemAppendResult(row, itemId))
}
appendWithBlobs(
identity: AgentJournalItemIdentity,
body: AgentJournalItemBody,
blobs: readonly JournalBlob[],
options: ItemAppendOptions
): Promise<JournalAppendResult> {
const itemId = agentJournalItemKey(identity)
return this.deps
.enqueue(journalItemRowBuilder(this.deps.state, identity, body, options), blobs)
.then((row) => itemAppendResult(row, itemId))
.catch((error: unknown) =>
appendToolOutputFallback({
journal: this.deps.journal(),
error,
identity,
body,
blobs,
itemId,
fence: options.fence
})
)
}
}
function itemAppendResult(row: JournalRow, itemId: string): JournalAppendResult {
return {
cursor: { epoch: row.epoch, sequence: row.seq },
itemId,
revision: (row as Extract<JournalRow, { kind: 'item' }>).revision
.then((row) => ({
cursor: { epoch: row.epoch, sequence: row.seq },
itemId,
revision: (row as Extract<JournalRow, { kind: 'item' }>).revision
}))
}
}
@@ -2,19 +2,21 @@
// results by identity read off the same raw lines. Fixtures are shaped like the
// files the providers actually write.
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
import { JOURNAL_BLOB_DIR, readJournalBlob } from './journal-blob-store'
import type {
AgentSessionJournalIdentity,
AgentSessionProviderHandle
} from '../../../shared/agent-session-journal-types'
import { createLegacyIdentityTracker } from './journal-legacy-identity'
import {
appendLegacyTranscriptMessages,
importLegacyTranscriptIntoJournal
} from './journal-legacy-import'
import { boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { openAgentSessionJournal } from './journal-store-factory'
import type { AgentSessionJournal } from './journal-store'
@@ -29,21 +31,29 @@ function tick(): number {
return clock
}
function identity(agent: 'claude' | 'codex', sessionId: string): AgentSessionJournalIdentity {
type ImportAgent = 'claude' | 'codex' | 'grok' | 'omp'
function providerHandle(agent: ImportAgent, sessionId: string): AgentSessionProviderHandle {
if (agent === 'claude') {
return { kind: 'claude', sessionId, leafUuid: null }
}
return agent === 'codex'
? { kind: 'codex', threadId: sessionId }
: { kind: 'opaque', agent, value: sessionId }
}
function identity(agent: ImportAgent, sessionId: string): AgentSessionJournalIdentity {
return {
sessionId,
workspaceId: 'ws-1',
hostId: 'host-1',
agent,
providerHandle:
agent === 'claude'
? { kind: 'claude', sessionId, leafUuid: null }
: { kind: 'codex', threadId: sessionId }
providerHandle: providerHandle(agent, sessionId)
}
}
async function open(
agent: 'claude' | 'codex',
agent: ImportAgent,
sessionId: string,
overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}
): Promise<AgentSessionJournal> {
@@ -387,7 +397,7 @@ describe('codex import', () => {
})
describe('payload bounds on import', () => {
it('marks a clipped tool result and parks the remainder in the blob store', async () => {
it('marks a clipped tool result and discards the remainder', async () => {
const output = 'y'.repeat(64 * 1024)
const filePath = await writeFixture('claude-big.jsonl', [
{
@@ -421,167 +431,13 @@ describe('payload bounds on import', () => {
expect(body.output.truncated).toBe(true)
expect(body.output.byteLength).toBe(64 * 1024)
expect(body.output.head).toHaveLength(1_024)
expect(await readJournalBlob(root, body.output.digest)).toBe(output)
})
it('deduplicates staged blobs while importing a replacement epoch', async () => {
const journalDir = join(root, 'dedupe-journal')
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
inlineHeadBytes: 512,
maxSessionBytes: 512 * 1024
}
const output = 'd'.repeat(32 * 1024)
const bounded = boundPayload(output, limits)
const toolResultLine = (uuid: string) => ({
parentUuid: null,
isSidechain: false,
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: `toolu_${uuid}`, content: output }]
},
uuid,
timestamp: '2026-08-05T10:00:09.000Z',
sessionId: CLAUDE_SESSION
})
const filePath = await writeFixture('claude-duplicate-blobs.jsonl', [
toolResultLine('aa11bb22-cc33-4d44-8e55-6f7788990011'),
toolResultLine('bb22cc33-dd44-4e55-8f66-778899001122')
])
const journal = await open('claude', CLAUDE_SESSION, {
journalDir,
limits,
autoCompact: false
})
await importLegacyTranscriptIntoJournal({
journal,
agent: 'claude',
sessionId: CLAUDE_SESSION,
fence: 1,
options: { filePath, limits }
})
expect(await readJournalBlob(journalDir, bounded.digest)).toBe(output)
expect(await readdir(join(journalDir, JOURNAL_BLOB_DIR))).toEqual([bounded.digest])
expect(
journal
.snapshot()
.items.map((item) => (item.body.kind === 'tool-call' ? item.body.output?.digest : null))
).toEqual([bounded.digest, bounded.digest])
})
it('prunes root-level blobs made stale by a later legacy import', async () => {
const journalDir = join(root, 'prune-journal')
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
inlineHeadBytes: 512,
maxSessionBytes: 512 * 1024
}
const output = 's'.repeat(32 * 1024)
const bounded = boundPayload(output, limits)
const first = await writeFixture('claude-stale-blob.jsonl', [
{
parentUuid: null,
isSidechain: false,
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_stale', content: output }]
},
uuid: 'aa11bb22-cc33-4d44-8e55-6f7788990011',
timestamp: '2026-08-05T10:00:09.000Z',
sessionId: CLAUDE_SESSION
}
])
const second = await writeFixture('claude-without-blob.jsonl', [
{
parentUuid: null,
isSidechain: false,
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'replacement' }] },
uuid: 'cc33dd44-ee55-4666-8777-889900112233',
timestamp: '2026-08-05T10:00:10.000Z',
sessionId: CLAUDE_SESSION
}
])
const journal = await open('claude', CLAUDE_SESSION, {
journalDir,
limits,
autoCompact: false
})
await importLegacyTranscriptIntoJournal({
journal,
agent: 'claude',
sessionId: CLAUDE_SESSION,
fence: 1,
options: { filePath: first, limits }
})
expect(await readJournalBlob(journalDir, bounded.digest)).toBe(output)
await importLegacyTranscriptIntoJournal({
journal,
agent: 'claude',
sessionId: CLAUDE_SESSION,
fence: 2,
options: { filePath: second, limits }
})
expect(await readJournalBlob(journalDir, bounded.digest)).toBeNull()
expect(journal.snapshot().items[0]?.body).toMatchObject({
kind: 'message',
blocks: [{ type: 'text', text: 'replacement' }]
})
})
it('uses managed catch-up appends when a tool-result blob exceeds quota', async () => {
const journalDir = join(root, 'catchup-journal')
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
inlineHeadBytes: 128,
maxSessionBytes: 8_000
}
const journal = await open('codex', CODEX_SESSION, {
journalDir,
limits,
autoCompact: false
})
const output = 'z'.repeat(12_000)
const bounded = boundPayload(output, limits)
await expect(
appendLegacyTranscriptMessages({
journal,
agent: 'codex',
sessionId: CODEX_SESSION,
fence: 1,
messages: [
{
id: 'catchup-tool-output',
role: 'tool',
blocks: [{ type: 'tool-result', output }],
timestamp: 1_800_000_000_000,
source: 'transcript'
}
]
})
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(await readJournalBlob(journalDir, bounded.digest)).toBeNull()
expect(journal.snapshot().items).toEqual([])
})
})
describe('import failures', () => {
it('rejects a legacy source above the fixed 16 MiB import cap before decoding', async () => {
const journalDir = join(root, 'oversized-source-journal')
const journal = await open('claude', CLAUDE_SESSION, {
journalDir,
limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 256 * 1024 * 1024 },
autoCompact: false
})
const journal = await open('claude', CLAUDE_SESSION, { journalDir })
const filePath = join(root, 'oversized-source.jsonl')
await writeFile(filePath, 'x'.repeat(16 * 1024 * 1024 + 1), 'utf8')
const epoch = journal.epoch
@@ -602,115 +458,10 @@ describe('import failures', () => {
expect(journal.snapshot().items).toEqual([])
})
it('keeps the live epoch intact when a staged rebuild runs out of budget', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 2_000 }
const journal = await open('codex', CODEX_SESSION, { limits })
await appendLegacyTranscriptMessages({
journal,
agent: 'codex',
sessionId: CODEX_SESSION,
fence: 1,
messages: [
{
id: 'durable-prefix',
role: 'assistant',
blocks: [{ type: 'text', text: 'keep me' }],
timestamp: 1_800_000_000_000,
source: 'transcript'
}
]
})
const filePath = await writeFixture('oversized-rollout.jsonl', [
CODEX_LINES[0],
CODEX_LINES[1],
CODEX_LINES[2],
{
type: 'event_msg',
timestamp: '2026-08-05T10:00:03.000Z',
payload: { type: 'agent_message', message: 'x'.repeat(2_000) }
}
])
const epoch = journal.epoch
const snapshotPath = join(root, 'snapshot.json')
const logPath = join(root, 'log.jsonl')
const before = {
snapshot: await readFile(snapshotPath, 'utf-8'),
log: await readFile(logPath, 'utf-8')
}
await expect(
importLegacyTranscriptIntoJournal({
journal,
agent: 'codex',
sessionId: CODEX_SESSION,
fence: 1,
options: { filePath, limits }
})
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(journal.epoch).toBe(epoch)
expect(await readFile(snapshotPath, 'utf-8')).toBe(before.snapshot)
expect(await readFile(logPath, 'utf-8')).toBe(before.log)
expect(journal.snapshot().items[0]?.body).toMatchObject({
kind: 'message',
blocks: [{ type: 'text', text: 'keep me' }]
})
})
it('cleans staged replacement blobs when legacy import exceeds physical quota', async () => {
const journalDir = join(root, 'replacement-journal')
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
inlineHeadBytes: 128,
maxSessionBytes: 8_000
}
const journal = await open('claude', CLAUDE_SESSION, {
journalDir,
limits,
autoCompact: false
})
const output = 'q'.repeat(12_000)
const bounded = boundPayload(output, limits)
const filePath = await writeFixture('oversized-tool-result.jsonl', [
{
parentUuid: null,
isSidechain: false,
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_oversized', content: output }]
},
uuid: 'ba11ad00-1111-4222-8333-444455556666',
timestamp: '2026-08-05T10:00:09.000Z',
sessionId: CLAUDE_SESSION
}
])
const epoch = journal.epoch
await expect(
importLegacyTranscriptIntoJournal({
journal,
agent: 'claude',
sessionId: CLAUDE_SESSION,
fence: 1,
options: { filePath, limits }
})
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(journal.epoch).toBe(epoch)
expect(await readJournalBlob(journalDir, bounded.digest)).toBeNull()
expect((await readdir(journalDir)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(
false
)
})
it('bounds oversized legacy tool-call input before journal publication', async () => {
const journalDir = join(root, 'bounded-tool-input-journal')
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 }
const journal = await open('claude', CLAUDE_SESSION, {
journalDir,
limits,
autoCompact: false
})
const journal = await open('claude', CLAUDE_SESSION, { journalDir })
const filePath = await writeFixture('oversized-tool-input.jsonl', [
{
parentUuid: null,
@@ -768,6 +519,39 @@ describe('import failures', () => {
expect(journal.epoch).toBe(before)
})
// A transcript with no decodable messages recovers nothing. Publishing an
// empty replacement would roll the epoch and drop whatever the journal held —
// including a repair's own anchor and disclosure.
it('leaves the epoch untouched when the transcript decodes to no messages', async () => {
const journal = await open('codex', CODEX_SESSION)
await journal.appendItem(
{ provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal: 1 },
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'kept' }] },
{ fence: 1 }
)
const before = journal.epoch
const metadataOnly = await writeFixture('metadata-only.jsonl', [
{
type: 'session_meta',
timestamp: '2026-08-05T10:00:00.000Z',
payload: { id: CODEX_SESSION, session_id: CODEX_SESSION, cwd: '/Users/dev/project' }
}
])
const result = await importLegacyTranscriptIntoJournal({
journal,
agent: 'codex',
sessionId: CODEX_SESSION,
fence: 1,
options: { filePath: metadataOnly }
})
expect(result).toMatchObject({ ok: true, imported: 0, replaced: false })
expect(journal.epoch).toBe(before)
expect(journal.snapshot().items).toHaveLength(1)
await journal.close()
})
it('rejects an agent with no transcript decoder', async () => {
const journal = await open('claude', CLAUDE_SESSION)
const result = await importLegacyTranscriptIntoJournal({
@@ -780,3 +564,132 @@ describe('import failures', () => {
expect(result).toMatchObject({ ok: false })
})
})
// A tool call is only the SOLE block of its message when the provider wrote it
// that way. Claude interleaves it with narration, Grok hangs `tool_calls` off a
// row that also has text, and omp's execution cells always pair the invocation
// with its output — so the multi-block path carries untrusted tool input too.
describe('multi-block legacy messages', () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 }
const oversized = 'x'.repeat(10_000)
/** The tool-call block of the first imported multi-block message. */
function importedToolCallBlock(journal: AgentSessionJournal): unknown {
for (const entry of journal.snapshot().items) {
if (entry.body.kind !== 'message') {
continue
}
const block = entry.body.blocks.find((candidate) => candidate.type === 'tool-call')
if (block) {
return block.input
}
}
return null
}
it('bounds a Claude tool call that shares its message with narration', async () => {
const journal = await open('claude', CLAUDE_SESSION, {
journalDir: join(root, 'claude-mixed-journal')
})
const filePath = await writeFixture('claude-mixed.jsonl', [
{
parentUuid: null,
isSidechain: false,
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Editing the file.' },
{
type: 'tool_use',
id: 'toolu_mixed',
name: 'Edit',
input: { file_path: 'a.ts', patch: oversized }
}
]
},
uuid: 'dd22be00-1111-4222-8333-444455556666',
timestamp: '2026-08-05T10:00:09.000Z',
sessionId: CLAUDE_SESSION
}
])
const result = await importLegacyTranscriptIntoJournal({
journal,
agent: 'claude',
sessionId: CLAUDE_SESSION,
fence: 1,
options: { filePath, limits }
})
expect(result.ok).toBe(true)
expect(importedToolCallBlock(journal)).toMatchObject({
truncated: true,
byteLength: expect.any(Number),
digest: expect.stringMatching(/^[0-9a-f]{64}$/),
head: expect.any(String)
})
expect(JSON.stringify(journal.snapshot().items)).not.toContain('x'.repeat(1_000))
await journal.close()
})
it('bounds a Grok tool call that shares its row with assistant text', async () => {
const journal = await open('grok', CODEX_SESSION, {
journalDir: join(root, 'grok-mixed-journal')
})
const filePath = await writeFixture('grok-mixed.jsonl', [
{
type: 'assistant',
id: 'asst-mixed',
timestamp: '2026-08-05T10:00:09.000Z',
content: [{ type: 'text', text: 'Searching.' }],
tool_calls: [{ id: 'c1', name: 'grep', arguments: JSON.stringify({ pattern: oversized }) }]
}
])
const result = await importLegacyTranscriptIntoJournal({
journal,
agent: 'grok',
sessionId: CODEX_SESSION,
fence: 1,
options: { filePath, limits }
})
expect(result.ok).toBe(true)
expect(importedToolCallBlock(journal)).toMatchObject({ truncated: true })
expect(JSON.stringify(journal.snapshot().items)).not.toContain('x'.repeat(1_000))
await journal.close()
})
it('bounds an omp execution cell, whose invocation always ships with its output', async () => {
const journal = await open('omp', CODEX_SESSION, {
journalDir: join(root, 'omp-mixed-journal')
})
const filePath = await writeFixture('omp-mixed.jsonl', [
{
type: 'message',
id: 'omp-mixed-1',
timestamp: '2026-08-05T10:00:09.000Z',
message: {
role: 'bashExecution',
command: `echo ${oversized}`,
output: 'done',
exitCode: 0
}
}
])
const result = await importLegacyTranscriptIntoJournal({
journal,
agent: 'omp',
sessionId: CODEX_SESSION,
fence: 1,
options: { filePath, limits }
})
expect(result.ok).toBe(true)
expect(importedToolCallBlock(journal)).toMatchObject({ truncated: true })
expect(JSON.stringify(journal.snapshot().items)).not.toContain('x'.repeat(1_000))
await journal.close()
})
})
@@ -49,7 +49,14 @@ export type LegacyImportOptions = ResolveSessionFileOptions & {
const MAX_LEGACY_IMPORT_SOURCE_BYTES = 16 * 1024 * 1024
export type LegacyImportResult =
| { ok: true; epoch: string; cursor: AgentJournalCursor; imported: number }
| {
ok: true
epoch: string
cursor: AgentJournalCursor
imported: number
/** False when the transcript held no messages and the epoch was left as it stood. */
replaced: boolean
}
| { ok: false; error: string }
export async function appendLegacyTranscriptMessages(input: {
@@ -61,16 +68,14 @@ export async function appendLegacyTranscriptMessages(input: {
}): Promise<number> {
let appended = 0
for (const message of input.messages) {
const mapped = legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
await input.journal.appendItemWithBlobs(
await input.journal.appendItem(
{
provider: 'legacy',
agent: input.agent,
sessionId: input.sessionId,
recordId: message.id
},
mapped.body,
mapped.blobs,
legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS),
{ fence: input.fence, observedAt: message.timestamp ?? undefined }
)
appended += 1
@@ -134,16 +139,28 @@ export async function importLegacyTranscriptIntoJournal(input: {
if (!identity) {
continue
}
const mapped = legacyItemBody(message, limits)
replacement.push({
identity,
body: mapped.body,
blobs: mapped.blobs,
body: legacyItemBody(message, limits),
observedAt: message.timestamp ?? undefined
})
}
// A transcript that decodes to nothing reconstructs nothing, and an empty
// replacement is not a harmless no-op: it would delete the repair's anchor and
// its disclosure, leaving nothing to ask for the history again. The epoch
// stands so a later read can still rebuild it.
if (replacement.length === 0) {
const current = input.journal.cursor()
return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false }
}
const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, replacement)
return { ok: true, epoch: cursor.epoch, cursor, imported: decoded.messages.length }
return {
ok: true,
epoch: cursor.epoch,
cursor,
imported: decoded.messages.length,
replaced: true
}
}
const TRANSCRIPT_DECODERS = {
@@ -199,11 +216,6 @@ async function decodeWithIdentities(input: {
return { messages, identities }
}
type MappedLegacyItem = {
body: AgentJournalItemBody
blobs: { digest: string; payload: string }[]
}
/**
* A message whose only content is a tool invocation becomes a tool-call item so
* the reducer renders it as one. Everything else stays a message item with its
@@ -212,49 +224,40 @@ type MappedLegacyItem = {
function legacyItemBody(
message: NativeChatMessage,
limits: JournalPayloadLimits
): MappedLegacyItem {
): AgentJournalItemBody {
const only = message.blocks.length === 1 ? message.blocks[0] : undefined
if (only?.type === 'tool-call') {
// Legacy transcripts are untrusted and can contain arbitrarily large tool
// arguments. Keep them on the same bounded path as live events before the
// replacement epoch is published.
return {
// Legacy transcripts are untrusted and can contain arbitrarily large
// tool arguments. Keep them on the same bounded path as live events
// before the replacement epoch is staged or published.
body: {
kind: 'tool-call',
name: only.name,
input: boundToolInput(only.input, limits),
state: 'completed'
},
blobs: []
kind: 'tool-call',
name: only.name,
input: boundToolInput(only.input, limits),
state: 'completed'
}
}
if (only?.type === 'tool-result') {
const output = boundPayload(only.output, limits)
return {
body: {
kind: 'tool-call',
name: 'tool-result',
input: null,
state: only.isError ? 'failed' : 'completed',
output
},
blobs: output.truncated ? [{ digest: output.digest, payload: only.output }] : []
kind: 'tool-call',
name: 'tool-result',
input: null,
state: only.isError ? 'failed' : 'completed',
output: boundPayload(only.output, limits)
}
}
return {
body: {
kind: 'message',
role: message.role,
blocks: message.blocks.map((block) => boundBlock(block, limits))
},
blobs: []
kind: 'message',
role: message.role,
blocks: message.blocks.map((block) => boundBlock(block, limits))
}
}
/** Inline block text keeps only a bounded head plus an explicit marker. No blob
* is written: the marker carries the digest and byte length, and the source
* transcript remains the full copy — a blob here would be unreferenced by the
* render model and pruned at the next compaction. */
/** Every block that can carry untrusted bulk is bounded here, tool calls
* included: a provider decoder is free to put one alongside narration, and the
* sole-block path above never sees those. The remainder is discarded rather
* than stored elsewhere — the marker keeps its digest and byte length, and the
* source transcript remains the full copy. */
function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): NativeChatBlock {
if (block.type === 'text') {
return { ...block, text: boundInlineText(block.text, limits).text }
@@ -262,5 +265,8 @@ function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): Nativ
if (block.type === 'tool-result') {
return { ...block, output: boundInlineText(block.output, limits).text }
}
if (block.type === 'tool-call') {
return { ...block, input: boundToolInput(block.input, limits) }
}
return block
}
@@ -1,160 +0,0 @@
import type {
AgentJournalItemBody,
AgentJournalSnapshot
} from '../../../shared/agent-session-journal-types'
import {
dispatchReservationId,
JournalLifecycleCapacity,
lifecycleReservationIdForItem,
requiresTerminalSettlement,
terminalReservationBytes,
type JournalLifecycleReservation
} from './journal-lifecycle-capacity'
import type { JournalRow } from './journal-row-schema'
import { journalRowByteLength } from './journal-row-schema'
import { AgentSessionJournalError } from './journal-write-guards'
export type JournalLifecycleRowAdmission = {
releaseAfter: string[]
protectedBytes: number
lifecycleCovered: boolean
proposedCapacity: JournalLifecycleCapacity
}
export class JournalLifecycleAdmission {
private readonly capacity = new JournalLifecycleCapacity()
constructor(
private readonly sessionId: string,
private readonly maxBytes: number,
private readonly canonicalItemId: (itemId: string) => string,
private readonly maxAppendSlots = Number.MAX_SAFE_INTEGER
) {}
get state(): { reservedBytes: number; reservedAppendSlots: number } {
return {
reservedBytes: this.capacity.reservedBytes,
reservedAppendSlots: this.capacity.reservedAppendSlots
}
}
rebuild(snapshot: AgentJournalSnapshot, currentPhysicalBytes: number): void {
if (
!this.capacity.rebuild(snapshot, this.maxBytes, currentPhysicalBytes, this.maxAppendSlots)
) {
throw this.capacityError('cannot rebuild lifecycle capacity')
}
}
reserve(token: JournalLifecycleReservation, currentPhysicalBytes: number): boolean {
return this.capacity.reserve(token, currentPhysicalBytes, this.maxBytes, this.maxAppendSlots)
}
transfer(fromId: string, toId: string): boolean {
return this.capacity.transfer(fromId, toId)
}
release(id: string): void {
this.capacity.release(id)
}
prepare(row: JournalRow, currentPhysicalBytes: number): JournalLifecycleRowAdmission {
const proposedCapacity = this.capacity.clone()
this.ensureActionable(row, currentPhysicalBytes, proposedCapacity)
const releaseAfter = this.reservationsSettledBy(row, proposedCapacity)
const releasedBytes = releaseAfter.reduce(
(total, id) => total + (proposedCapacity.token(id)?.bytes ?? 0),
0
)
return {
releaseAfter,
protectedBytes: proposedCapacity.reservedBytes - releasedBytes,
lifecycleCovered: proposedCapacity.covers(releaseAfter, journalRowByteLength(row), 1),
proposedCapacity
}
}
commit(admission: JournalLifecycleRowAdmission): void {
this.capacity.replaceFrom(admission.proposedCapacity)
for (const id of admission.releaseAfter) {
this.capacity.release(id)
}
}
private ensureActionable(
row: JournalRow,
currentPhysicalBytes: number,
capacity: JournalLifecycleCapacity
): void {
if (row.kind === 'item') {
this.ensureActionableItem(row.itemId, row.body, currentPhysicalBytes, capacity)
return
}
if (row.kind !== 'lifecycle-batch') {
return
}
for (const mutation of row.mutations) {
if (mutation.kind === 'item') {
this.ensureActionableItem(mutation.itemId, mutation.body, currentPhysicalBytes, capacity)
}
}
}
private ensureActionableItem(
itemId: string,
body: AgentJournalItemBody,
currentPhysicalBytes: number,
capacity: JournalLifecycleCapacity
): void {
if (!requiresTerminalSettlement(body)) {
return
}
const id = lifecycleReservationIdForItem(this.canonicalItemId(itemId))
if (body.kind === 'status' && body.turnLifecycle?.state === 'running' && !capacity.has(id)) {
capacity.claimFirst('tentative-turn:', id)
}
if (
!capacity.reserve(
{ id, bytes: terminalReservationBytes(body), appendSlots: 1 },
currentPhysicalBytes,
this.maxBytes,
this.maxAppendSlots
)
) {
throw this.capacityError('cannot reserve terminal capacity')
}
}
private reservationsSettledBy(row: JournalRow, capacity: JournalLifecycleCapacity): string[] {
if (row.kind === 'dispatch') {
const id = dispatchReservationId(row.clientMessageId)
return capacity.has(id) ? [id] : []
}
const itemIds =
row.kind === 'item'
? requiresTerminalSettlement(row.body)
? []
: [row.itemId]
: row.kind === 'tombstone'
? [row.itemId]
: row.kind === 'lifecycle-batch'
? row.mutations.flatMap((mutation) =>
mutation.kind === 'item' && requiresTerminalSettlement(mutation.body)
? []
: [mutation.itemId]
)
: []
return [
...new Set(
itemIds.map((itemId) => lifecycleReservationIdForItem(this.canonicalItemId(itemId)))
)
].filter((id) => capacity.has(id))
}
private capacityError(detail: string): AgentSessionJournalError {
return new AgentSessionJournalError(
'journal_bound_exceeded',
`agent-session journal for ${this.sessionId} ${detail}`
)
}
}
@@ -1,30 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types'
import { JournalLifecycleCapacity } from './journal-lifecycle-capacity'
describe('JournalLifecycleCapacity', () => {
it('enforces append-slot limits for both rebuilt submission reservations', () => {
const snapshot: AgentJournalSnapshot = {
sessionId: 'session-1',
cursor: { epoch: 'epoch-1', sequence: 1 },
items: [],
submissions: [
{
clientMessageId: 'message-1',
fence: 0,
payloadFingerprint: 'fingerprint',
dispatchState: 'pending',
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
}
]
}
const capacity = new JournalLifecycleCapacity()
expect(capacity.rebuild(snapshot, Number.MAX_SAFE_INTEGER, 0, 1)).toBe(false)
expect(capacity.reservedAppendSlots).toBe(1)
})
})
@@ -1,193 +0,0 @@
import type {
AgentJournalItemBody,
AgentJournalSnapshot
} from '../../../shared/agent-session-journal-types'
export type JournalLifecycleReservation = {
id: string
bytes: number
appendSlots: number
}
export const JOURNAL_TURN_TERMINAL_RESERVATION_BYTES = 128 * 1024
export const JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES = 64 * 1024
export const JOURNAL_DISPATCH_RESERVATION_BYTES = 32 * 1024
export class JournalLifecycleCapacity {
private readonly reservations = new Map<string, JournalLifecycleReservation>()
get reservedBytes(): number {
return [...this.reservations.values()].reduce((total, token) => total + token.bytes, 0)
}
get reservedAppendSlots(): number {
return [...this.reservations.values()].reduce((total, token) => total + token.appendSlots, 0)
}
has(id: string): boolean {
return this.reservations.has(id)
}
token(id: string): JournalLifecycleReservation | null {
return this.reservations.get(id) ?? null
}
clone(): JournalLifecycleCapacity {
const copy = new JournalLifecycleCapacity()
for (const token of this.reservations.values()) {
copy.reservations.set(token.id, { ...token })
}
return copy
}
replaceFrom(source: JournalLifecycleCapacity): void {
this.reservations.clear()
for (const token of source.reservations.values()) {
this.reservations.set(token.id, { ...token })
}
}
reserve(
token: JournalLifecycleReservation,
currentPhysicalBytes: number,
maxBytes: number,
maxAppendSlots = Number.MAX_SAFE_INTEGER
): boolean {
if (this.reservations.has(token.id)) {
return true
}
if (currentPhysicalBytes + this.reservedBytes + token.bytes > maxBytes) {
return false
}
if (this.reservedAppendSlots + token.appendSlots > maxAppendSlots) {
return false
}
this.reservations.set(token.id, token)
return true
}
transfer(fromId: string, toId: string): boolean {
const existing = this.reservations.get(fromId)
if (!existing) {
return false
}
this.reservations.delete(fromId)
this.reservations.set(toId, { ...existing, id: toId })
return true
}
claimFirst(prefix: string, toId: string): boolean {
const fromId = [...this.reservations.keys()].find((id) => id.startsWith(prefix))
return fromId ? this.transfer(fromId, toId) : false
}
release(id: string): void {
this.reservations.delete(id)
}
covers(ids: readonly string[], bytes: number, appendSlots: number): boolean {
const tokens = ids.flatMap((id) => {
const token = this.reservations.get(id)
return token ? [token] : []
})
return (
tokens.length > 0 &&
tokens.reduce((total, token) => total + token.bytes, 0) >= bytes &&
tokens.reduce((total, token) => total + token.appendSlots, 0) >= appendSlots
)
}
rebuild(
snapshot: AgentJournalSnapshot,
maxBytes: number,
currentPhysicalBytes: number,
maxAppendSlots = Number.MAX_SAFE_INTEGER
): boolean {
this.reservations.clear()
for (const item of snapshot.items) {
if (!requiresTerminalSettlement(item.body)) {
continue
}
if (
!this.reserve(
{
id: lifecycleReservationIdForItem(item.itemId),
bytes: terminalReservationBytes(item.body),
appendSlots: 1
},
currentPhysicalBytes,
maxBytes,
maxAppendSlots
)
) {
return false
}
}
for (const submission of snapshot.submissions) {
if (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') {
continue
}
// A write-ahead submission owns both its dispatch attempt and the
// terminal turn settlement. Rebuild both reservations after restart;
// restoring only the tentative turn token would let a new send consume
// the dispatch headroom still owed to this unresolved submission.
if (
!this.reserve(
{
id: dispatchReservationId(submission.clientMessageId),
bytes: JOURNAL_DISPATCH_RESERVATION_BYTES,
appendSlots: 1
},
currentPhysicalBytes,
maxBytes,
maxAppendSlots
)
) {
return false
}
if (
!this.reserve(
{
id: tentativeTurnReservationId(submission.clientMessageId),
bytes: JOURNAL_TURN_TERMINAL_RESERVATION_BYTES,
appendSlots: 1
},
currentPhysicalBytes,
maxBytes,
maxAppendSlots
)
) {
return false
}
}
return true
}
}
export function lifecycleReservationIdForItem(itemId: string): string {
return `item:${itemId}`
}
export function dispatchReservationId(clientMessageId: string): string {
return `dispatch:${clientMessageId}`
}
export function tentativeTurnReservationId(clientMessageId: string): string {
return `tentative-turn:${clientMessageId}`
}
export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean {
if (body.kind === 'tool-call') {
return body.state === 'running'
}
if (body.kind === 'approval' || body.kind === 'question') {
return body.resolution.state === 'pending'
}
return body.kind === 'status' && body.turnLifecycle?.state === 'running'
}
export function terminalReservationBytes(body: AgentJournalItemBody): number {
return body.kind === 'status' && body.turnLifecycle?.state === 'running'
? JOURNAL_TURN_TERMINAL_RESERVATION_BYTES
: JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES
}
@@ -1,374 +0,0 @@
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-factory'
import {
projectStructuredAgentSessionStatus,
projectStructuredItemsToNativeChat
} from '../../../shared/structured-agent-session-projection'
type FakeDirectoryHandle = { sync: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> }
let openDirectoryHook: ((path: unknown, flags: unknown) => FakeDirectoryHandle | undefined) | null =
null
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof FsPromises>()
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
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<void> {
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('accepts every canonical item kind and a fully-formed submission', async () => {
const snapshot = validSnapshot()
const payload = { head: 'x', byteLength: 4, digest: 'd'.repeat(64), truncated: true }
snapshot.items = [
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
{
kind: 'tool-call',
name: 'Read',
input: { path: 'a' },
state: 'completed',
output: payload
},
{ kind: 'diff', path: 'a.ts', patch: payload },
{
kind: 'approval',
title: 'Run?',
detail: null,
options: [{ id: 'a', label: 'Yes' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
{
kind: 'question',
question: 'Deploy?',
options: [{ id: 'a', label: 'Yes' }],
freeTextQuestionId: 'q-free',
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 5 }
},
{
kind: 'status',
text: 'working',
turnLifecycle: { turnId: 'turn-1', state: 'running' },
providerFrame: { provider: 'codex', kind: 'raw', payload }
}
].map((body, index) => ({
itemId: `codex:thread-1:turn-1:${index + 1}`,
revision: 1,
body: body as JournalSnapshotFile['items'][number]['body'],
sequence: index + 1,
observedAt: 1_000,
...(index === 0 ? { recovered: true as const } : {})
}))
snapshot.compactedThrough = snapshot.items.length
snapshot.submissions = [
{
clientMessageId: 'm-1',
fence: 1,
payloadFingerprint: 'a'.repeat(64),
dispatchState: 'accepted',
providerItemId: 'codex:thread-1:turn-1:1',
reason: null,
submittedAt: 1_000,
resolvedAt: 1_001
}
]
await writeSnapshot(snapshot)
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 JSON-valid nested item corruption instead of admitting it', async () => {
// A resolved question with `options: null` used to pass shallow admission and
// then throw `TypeError` in the shared projection's `options.map`.
const poisonedQuestion = validSnapshot()
poisonedQuestion.items = [
{
itemId: 'codex:thread-1:turn-1:1',
revision: 1,
body: {
kind: 'question',
question: 'Deploy?',
options: null,
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 }
},
sequence: 2,
observedAt: 1_000
}
] as unknown as JournalSnapshotFile['items']
await writeSnapshot(poisonedQuestion)
expect((await readJournalSnapshot(root)).status).toBe('invalid')
// Pending-prompt surfaces read `resolution.state` before anything else.
const nullResolution = validSnapshot()
nullResolution.items = [
{
itemId: 'codex:thread-1:turn-1:1',
revision: 1,
body: { kind: 'question', question: 'Deploy?', options: [], resolution: null },
sequence: 2,
observedAt: 1_000
}
] as unknown as JournalSnapshotFile['items']
await writeSnapshot(nullResolution)
expect((await readJournalSnapshot(root)).status).toBe('invalid')
})
it('rejects a JSON-valid nested corruption in the retained tail', async () => {
const poisonedTail = validSnapshot()
poisonedTail.tail = [
{
v: 1,
epoch: 'epoch-A',
seq: 3,
fence: 1,
ts: 1_000,
kind: 'item',
itemId: 'codex:thread-1:turn-1:3',
revision: 1,
body: {
kind: 'question',
question: 'Deploy?',
options: null,
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 }
}
}
] as unknown as JournalSnapshotFile['tail']
await writeSnapshot(poisonedTail)
expect((await readJournalSnapshot(root)).status).toBe('invalid')
})
it('rejects a submission that only carries a client message id', async () => {
const shallowSubmission = validSnapshot()
shallowSubmission.submissions = [
{ clientMessageId: 'm-1' }
] as unknown as JournalSnapshotFile['submissions']
await writeSnapshot(shallowSubmission)
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('future-version snapshot classification', () => {
it('classifies a future version before shape validation so unknown bodies stay unreadable', async () => {
// The version can only advance because bodies changed, so a future snapshot
// legitimately carries kinds this build cannot parse. That is the
// schema-unreadable contract, not corruption.
const future = validSnapshot() as unknown as Record<string, unknown>
future.v = 99
future.items = [
{
itemId: 'codex:thread-1:turn-1:1',
revision: 1,
body: { kind: 'future-render-kind', payload: { anything: true } },
sequence: 2,
observedAt: 1_000
}
]
await writeSnapshot(future)
expect((await readJournalSnapshot(root)).status).toBe('unreadable')
})
it('classifies a future version as unreadable even when its shapes still parse today', async () => {
await writeSnapshot({ ...validSnapshot(), v: 99 })
expect((await readJournalSnapshot(root)).status).toBe('unreadable')
})
it('treats a non-integer or sub-1 version as invalid, matching row admission', async () => {
for (const v of [0, 1.5]) {
await writeSnapshot({ ...validSnapshot(), v })
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('reopen after a persisted JSON-valid poisoned question', () => {
it('quarantines the snapshot so reopen-to-render cannot throw in projection', async () => {
const poisoned = validSnapshot()
poisoned.items = [
{
itemId: 'codex:thread-1:turn-1:1',
revision: 1,
body: {
kind: 'question',
question: 'Deploy?',
options: null,
resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 }
},
sequence: 2,
observedAt: 1_000
}
] as unknown as JournalSnapshotFile['items']
await writeSnapshot(poisoned)
const journal = await openAgentSessionJournal({
identity: {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: root
})
// The poisoned item must land in quarantine, not in the reopened state:
// pre-fix it was admitted and the render path below threw
// `TypeError: Cannot read properties of null (reading 'map')`.
const entries = await readdir(root)
expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true)
const items = journal.snapshot().items
expect(() => projectStructuredItemsToNativeChat(items)).not.toThrow()
expect(() => projectStructuredAgentSessionStatus(items)).not.toThrow()
expect(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<typeof vi.fn>): 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)
})
})
@@ -1,365 +0,0 @@
// On-disk layout for one session's journal.
//
// <journal dir>/log.jsonl append-only rows, fsynced before the caller is told the write landed
// <journal dir>/snapshot.json folded state at a compaction boundary PLUS the retained tail
// <journal dir>/blobs/<sha256> bounded-payload remainders
//
// The snapshot carries its own tail so compaction is one atomic write. A crash
// 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, stat, type FileHandle } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { durableWriteTempPath, renameDurable, writeFileDurable } from '../../durable-file-write'
import {
AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
type AgentJournalRenderItem,
type AgentJournalSubmission
} from '../../../shared/agent-session-journal-types'
import {
isAdmissibleAgentJournalRenderItem,
isAdmissibleAgentJournalSubmission
} from '../../../shared/agent-session-journal-schemas'
import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema'
import { assertJournalPhysicalCapacity } from './journal-physical-quota'
export const JOURNAL_LOG_FILE = 'log.jsonl'
export const JOURNAL_SNAPSHOT_FILE = 'snapshot.json'
export type JournalSnapshotFile = {
v: number
epoch: string
/** Highest sequence folded into `items`; the tail starts after it. */
compactedThrough: number
/** Fence monotonicity survives compaction and restart. */
highestFence: number
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
/** Receipts outlive the rows that minted them: a client reconnecting after
* compaction must still get the same answer instead of re-sending. */
receipts: {
clientMessageId: string
providerItemId: string
epoch: string
sequence: number
acceptedAt: number
}[]
/** Provider item id → submission slot, preserved so a post-compaction echo
* still reconciles into the bubble it belongs to. */
aliases: { providerItemId: string; itemId: string }[]
tombstones: { itemId: string; revision: number }[]
/** Bounded by compaction retention; used to deduplicate a replayed settlement. */
appliedSettlementIds?: string[]
tail: JournalRow[]
}
export type JournalReadResult = {
rows: JournalRow[]
/** True when a line used a schema version this build cannot read. Reading
* STOPS there — the row must not be skipped — and the host degrades to
* read-only: no writes, no compaction, no deletion. */
unreadable: boolean
/** Lines that failed to parse for reasons other than schema version. */
malformed: number
/** Raw suffix beginning at the first malformed line, if any. */
remainder?: string
/** Distinguishes an absent/empty log from bytes that could not name an epoch. */
hasBytes: boolean
}
export type JournalSnapshotReadResult =
| { status: 'missing' }
| { status: 'valid'; snapshot: JournalSnapshotFile }
| { status: 'invalid' }
/** A future schema version: unreadable by this build, not corrupt. The file
* stays authoritative in place and the caller degrades to read-only. */
| { status: 'unreadable' }
const NEWLINE_BYTE = 0x0a
export async function ensureJournalDir(journalDir: string): Promise<void> {
await mkdir(journalDir, { recursive: true })
}
export async function readJournalSnapshot(journalDir: string): Promise<JournalSnapshotReadResult> {
try {
const raw = await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8')
const parsed: unknown = JSON.parse(raw)
const version = snapshotSchemaVersion(parsed)
if (version === null) {
return { status: 'invalid' }
}
// Version is classified BEFORE shape validation, matching row admission: a
// version only advances because bodies changed, so a valid newer snapshot
// carries kinds this build cannot parse — unreadable, never corruption.
if (version > AGENT_SESSION_JOURNAL_SCHEMA_VERSION) {
return { status: 'unreadable' }
}
return isJournalSnapshotFile(parsed)
? { status: 'valid', snapshot: parsed }
: { status: 'invalid' }
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { status: 'missing' }
}
if (error instanceof SyntaxError) {
return { status: 'invalid' }
}
throw error
}
}
export async function quarantineInvalidJournalSnapshot(
journalDir: string,
quota?: { sessionId: string; maxBytes: number }
): Promise<string> {
// Rename is normally same-filesystem and size-neutral, but admission must
// happen before retaining evidence so a full journal never creates an
// unbounded quarantine artifact (or relies on a copy fallback).
if (quota) {
const source = join(journalDir, JOURNAL_SNAPSHOT_FILE)
// Account for the complete source bytes: rename is usually neutral, but a
// cross-device/filesystem fallback may briefly retain both inodes.
const sourceBytes = await stat(source)
.then((info) => info.size)
.catch((error) => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return 0
}
throw error
})
await assertJournalPhysicalCapacity({
journalDir,
...quota,
peakAdditionalBytes: sourceBytes
})
}
const source = join(journalDir, JOURNAL_SNAPSHOT_FILE)
const target = join(journalDir, `quarantine-snapshot-${Date.now()}-${randomUUID()}.json`)
await renameDurable(source, target)
return target
}
export async function writeJournalSnapshotFile(
journalDir: string,
snapshot: JournalSnapshotFile
): Promise<void> {
const target = join(journalDir, JOURNAL_SNAPSHOT_FILE)
await writeFileDurable(durableWriteTempPath(target), target, JSON.stringify(snapshot))
}
export async function readJournalLog(journalDir: string): Promise<JournalReadResult> {
let raw: string
try {
raw = await readFile(join(journalDir, JOURNAL_LOG_FILE), 'utf-8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { rows: [], unreadable: false, malformed: 0, hasBytes: false }
}
throw error
}
const rows: JournalRow[] = []
let unreadable = false
let malformed = 0
const lines = raw.split('\n')
let offset = 0
for (const line of lines) {
if (!line.trim()) {
offset += line.length + 1
continue
}
const parsed = parseJournalRow(line)
if (parsed.ok) {
rows.push(parsed.row)
offset += line.length + 1
continue
}
if (parsed.unreadable) {
unreadable = true
return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 }
}
malformed += 1
return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 }
}
return { rows, unreadable, malformed, hasBytes: raw.length > 0 }
}
/** Row admission requires an integer version of at least 1; a snapshot whose
* version cannot even be read is malformed, not a schema statement. */
function snapshotSchemaVersion(value: unknown): number | null {
const snapshot = recordOf(value)
const version = snapshot?.v
return typeof version === 'number' && Number.isInteger(version) && version >= 1 ? version : null
}
function isJournalSnapshotFile(value: unknown): value is JournalSnapshotFile {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const snapshot = value as Record<string, unknown>
return (
typeof snapshot.v === 'number' &&
typeof snapshot.epoch === 'string' &&
snapshot.epoch.length > 0 &&
Number.isInteger(snapshot.compactedThrough) &&
(snapshot.compactedThrough as number) >= 0 &&
Number.isInteger(snapshot.highestFence) &&
// Deep discriminated admission: a JSON-valid item with a corrupt nested
// shape (e.g. a question whose options are null) must land this snapshot
// in quarantine rather than throw later in projection or prompt render.
arrayOf(snapshot.items, isAdmissibleAgentJournalRenderItem) &&
arrayOf(snapshot.submissions, isAdmissibleAgentJournalSubmission) &&
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)) &&
(snapshot.appliedSettlementIds === undefined ||
arrayOf(snapshot.appliedSettlementIds, (entry) => typeof entry === 'string')) &&
arrayOf(snapshot.tail, (row) => parseJournalRow(JSON.stringify(row)).ok)
)
}
function arrayOf(value: unknown, predicate: (entry: unknown) => boolean): value is unknown[] {
return Array.isArray(value) && value.every(predicate)
}
function recordOf(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function isTombstone(value: unknown): boolean {
const tombstone = recordOf(value)
return Boolean(
tombstone && typeof tombstone.itemId === 'string' && Number.isInteger(tombstone.revision)
)
}
function isReceipt(value: unknown): boolean {
const receipt = recordOf(value)
return Boolean(
receipt &&
typeof receipt.clientMessageId === 'string' &&
typeof receipt.providerItemId === 'string' &&
typeof receipt.epoch === 'string' &&
typeof receipt.sequence === 'number' &&
typeof receipt.acceptedAt === 'number'
)
}
function isAlias(value: unknown): boolean {
const alias = recordOf(value)
return Boolean(
alias && typeof alias.providerItemId === 'string' && typeof alias.itemId === 'string'
)
}
/**
* Append rows and fsync before returning. The caller treats a resolved promise
* as "this row survives a power loss" — the write-ahead submission row depends
* on exactly that, so this must never be relaxed to a buffered write.
*/
export async function appendJournalRows(
journalDir: string,
rows: readonly JournalRow[]
): Promise<void> {
if (rows.length === 0) {
return
}
const path = join(journalDir, JOURNAL_LOG_FILE)
// A process death can leave a final JSON fragment without its newline. Never
// concatenate a new durable row onto that fragment: truncate the torn tail
// first, then fsync the repair before acknowledging this append.
try {
await repairJournalLogTail(path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
// The append below creates a missing log.
}
const payload = `${rows.map(serializeJournalRow).join('\n')}\n`
await appendFile(path, payload, 'utf-8')
const handle = await open(path, 'r+')
try {
await handle.sync()
} finally {
await handle.close()
}
let directory: FileHandle | undefined
try {
directory = await open(journalDir, 'r')
await directory.sync()
} 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)
}
}
/** Repair only a torn final row. The normal append path reads one byte; scanning
* backward is reserved for the crash-recovery case and never rereads the log. */
async function repairJournalLogTail(path: string): Promise<void> {
const handle = await open(path, 'r+')
try {
const { size } = await handle.stat()
if (size === 0) {
return
}
const lastByte = Buffer.alloc(1)
await handle.read(lastByte, 0, 1, size - 1)
if (lastByte[0] === NEWLINE_BYTE) {
return
}
const scanChunkBytes = 64 * 1024
let scanEnd = size
let boundary = -1
while (scanEnd > 0 && boundary === -1) {
const scanStart = Math.max(0, scanEnd - scanChunkBytes)
const chunk = Buffer.alloc(scanEnd - scanStart)
await handle.read(chunk, 0, chunk.length, scanStart)
const newline = chunk.lastIndexOf(NEWLINE_BYTE)
if (newline !== -1) {
boundary = scanStart + newline
}
scanEnd = scanStart
}
const lineStart = boundary + 1
const finalLine = Buffer.alloc(size - lineStart)
await handle.read(finalLine, 0, finalLine.length, lineStart)
// A whole row that merely lost its newline is kept; a real fragment goes.
const complete = parseJournalRow(finalLine.toString('utf-8')).ok
await (complete ? handle.write('\n', size) : handle.truncate(lineStart))
await handle.sync()
} finally {
await handle.close()
}
}
export async function quarantineJournalRemainder(
journalDir: string,
remainder: string
): Promise<string> {
const path = join(journalDir, `quarantine-${Date.now()}-${randomUUID()}.jsonl`)
await writeFileDurable(durableWriteTempPath(path), path, remainder)
return path
}
/** Replace the log with exactly the retained tail. Runs only after the snapshot
* carrying that tail is durable, so a crash here loses nothing. */
export async function rewriteJournalLog(
journalDir: string,
rows: readonly JournalRow[]
): Promise<void> {
const target = join(journalDir, JOURNAL_LOG_FILE)
const payload = rows.length ? `${rows.map(serializeJournalRow).join('\n')}\n` : ''
await writeFileDurable(durableWriteTempPath(target), target, payload)
}
@@ -1,194 +1,205 @@
// Loading a journal from disk: snapshot + log → folded state.
// Loading a journal: the session projection names the live epoch, and that
// epoch's rows are folded through the reducer in sequence order.
//
// The snapshot is authoritative for the current epoch. Log rows belonging to a
// superseded epoch are dropped rather than merged — a crash between publishing
// a rollover snapshot and rewriting the log is the ordinary way that happens.
// A gap in the surviving sequence is corruption, and the caller rolls the epoch
// There is no snapshot to anchor to and no superseded-epoch rows to drop — a
// roll deletes them in the same transaction that publishes the new epoch. A gap
// in the surviving sequence is corruption, and the caller rolls the epoch
// rather than rendering a partial timeline.
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
import { existsSync } from 'node:fs'
import type Database from '../../sqlite/sync-database'
import { findSequenceGap } from './journal-cursor'
import {
quarantineInvalidJournalSnapshot,
readJournalLog,
readJournalSnapshot,
type JournalSnapshotFile
} from './journal-log-file'
import { openJournalDatabase } from './journal-database'
import { journalDatabaseFile } from './journal-paths'
import {
applyJournalRow,
createJournalReducerState,
rememberAppliedSettlementId,
type JournalReducerState
} from './journal-reducer'
import { journalRowByteLength, type JournalRow } from './journal-row-schema'
import {
readJournalEpochRows,
readJournalRowsAfter,
readJournalSessionEpoch
} from './journal-row-table'
import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure'
import { pendingJournalRepairSequence } from './journal-repair-marker'
import { parseJournalRow, type JournalRow } from './journal-row-schema'
/** Every epoch row is sequence 1, and no compaction moves that floor. */
const FIRST_JOURNAL_SEQUENCE = 1
export type JournalLoad = {
state: JournalReducerState
/** Rows still individually replayable, oldest first. */
tailRows: JournalRow[]
/** Highest sequence folded into the snapshot; the tail starts after it. */
compactedThrough: number
/** A future schema version was met: no writes, no compaction, no deletion. */
/** A future schema version was met: no writes, no deletion. */
readOnly: boolean
/** Set when the surviving prefix is unusable and the caller must roll the epoch. */
corrupt: boolean
/** Log lines skipped because they failed to parse (schema-version rows are
/** Rows skipped because their body failed to parse (future-version rows are
* `readOnly`, never counted here). The store discloses these in the timeline. */
malformedRows: number
sizeBytes: number
/** Raw unreadable suffix retained for quarantine instead of deletion. */
quarantineRemainder?: string
/** Directory-internal: the first sequence of an unusable suffix. The store
* deletes from here before it accepts a write; a probe leaves it alone. */
truncateFrom?: number
}
/** Returns null when no journal exists yet for this session. */
export async function loadJournal(
journalDir: string,
sessionId: string,
quota?: { maxBytes: number }
): Promise<JournalLoad | null> {
const snapshotRead = await readJournalSnapshot(journalDir)
if (snapshotRead.status === 'unreadable') {
// Written by a newer schema: not corrupt, so never quarantined. The file
// stays authoritative in place, and this build must not write, compact,
// delete, or render a partial timeline from rows it cannot anchor to the
// snapshot it cannot read.
/**
* Replay on a connection this function does NOT own. Returns null when the
* session has no journal yet.
*/
export function replayJournal(
db: Database.Database,
readOnly: boolean,
sessionId: string
): JournalLoad | null {
if (readOnly) {
return emptyReadOnlyLoad(sessionId)
}
if (snapshotRead.status === 'invalid') {
await quarantineInvalidJournalSnapshot(
journalDir,
quota ? { sessionId, maxBytes: quota.maxBytes } : undefined
)
}
const snapshot = snapshotRead.status === 'valid' ? snapshotRead.snapshot : null
const log = await readJournalLog(journalDir)
const epoch = resolveEpoch(snapshot, log.rows)
const epoch = readJournalSessionEpoch(db, sessionId)
if (!epoch) {
return snapshotRead.status === 'invalid' || log.hasBytes ? emptyReadOnlyLoad(sessionId) : null
return null
}
const state = createJournalReducerState(sessionId, epoch)
const stored = readJournalEpochRows(db, sessionId, epoch)
// A partial repair keeps its prefix, so the surviving rows look contiguous and
// anchored however much of the timeline it deleted. Its marker is what still
// says otherwise, naming the sequence past which the epoch would be its own
// history again.
const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch)
const rows: JournalRow[] = []
let malformedRows = 0
let latched = false
let truncateFrom: number | undefined
for (const entry of stored) {
const parsed = parseJournalRow(entry.rowJson)
if (parsed.ok) {
rows.push(parsed.row)
continue
}
// Reading STOPS at the first row this build cannot represent. A future
// version latches read-only; anything else is one skipped row, disclosed.
truncateFrom = entry.seq
if (parsed.unreadable) {
latched = true
} else {
malformedRows = 1
}
break
}
const compactedThrough = snapshot?.epoch === epoch ? snapshot.compactedThrough : 0
const state = seedState(sessionId, epoch, snapshot?.epoch === epoch ? snapshot : null)
const liveRows = log.rows.filter((row) => row.epoch === epoch)
let tailRows = unionBySequence(snapshot?.epoch === epoch ? snapshot.tail : [], liveRows, epoch)
const oldest = tailRows[0]?.seq ?? compactedThrough + 1
// Anchored at 1, never at the first row that HAPPENS to remain: nothing trims
// a prefix, so a missing epoch row is a hole like any other and everything
// behind it is unanchored. Validating from `rows[0].seq` would call the
// leftovers contiguous and leave them out of the repair that runs before
// provider history replaces the epoch.
const gap = findSequenceGap(
tailRows.map((row) => row.seq),
oldest
rows.map((row) => row.seq),
FIRST_JOURNAL_SEQUENCE
)
// A hole below the snapshot boundary is unrecoverable too: the snapshot only
// covers `compactedThrough`, so a tail that starts above it lost rows.
let corrupt = Boolean(gap) || oldest > compactedThrough + 1 || log.malformed > 0
let quarantineRemainder = log.remainder
if (gap) {
const firstBad = tailRows.findIndex((row, index) => {
const expected = (tailRows[0]?.seq ?? compactedThrough + 1) + index
return row.seq !== expected
})
const firstBad = rows.findIndex((row, index) => row.seq !== FIRST_JOURNAL_SEQUENCE + index)
if (firstBad !== -1) {
const suffix = tailRows.slice(firstBad)
tailRows = tailRows.slice(0, firstBad)
quarantineRemainder ??= `${suffix.map((row) => JSON.stringify(row)).join('\n')}\n`
truncateFrom = rows[firstBad]?.seq ?? truncateFrom
rows.length = firstBad
}
}
for (const row of tailRows) {
if (row.seq > compactedThrough) {
applyJournalRow(state, row)
}
// Contiguity from 1 is not the whole invariant: sequence 1 has to BE the epoch
// row. An ordinary row there is an epoch nothing anchors, and replaying it as
// clean is how a repaired journal silently adopts a timeline whose real
// history was never rebuilt.
if (rows.length > 0 && rows[0]?.kind !== 'epoch') {
truncateFrom = rows[0]?.seq ?? truncateFrom
rows.length = 0
}
state.oldestSequence = oldest
state.lastSequence = Math.max(state.lastSequence, compactedThrough)
for (const row of rows) {
applyJournalRow(state, row)
}
state.oldestSequence = FIRST_JOURNAL_SEQUENCE
// A latched journal reduces to nothing by design; only a writable one can be
// held to the anchor.
const unanchored = !latched && rows[0]?.kind !== 'epoch'
return {
state,
tailRows,
compactedThrough,
// A future-version snapshot never reaches here: it is classified
// unreadable above, so `valid` implies a version this build can write.
readOnly: log.unreadable,
corrupt,
malformedRows: log.malformed,
sizeBytes: tailRows.reduce((total, row) => total + journalRowByteLength(row), 0),
quarantineRemainder
readOnly: latched,
corrupt:
Boolean(gap) ||
malformedRows > 0 ||
unanchored ||
(repairedFrom !== null && awaitsRebuild(rows, repairedFrom)) ||
awaitsProviderHistory(rows),
malformedRows,
...(truncateFrom !== undefined && !latched ? { truncateFrom } : {})
}
}
/**
* The epoch a total repair published, still holding nothing but its own anchor
* and disclosure. The rows it dropped were never reconstructed, so provider
* history has to be retried rather than this being called a clean timeline.
*/
function awaitsProviderHistory(rows: readonly JournalRow[]): boolean {
const anchor = rows[0]
if (anchor?.kind !== 'epoch' || anchor.reason !== 'unreconcilable_prefix') {
return false
}
// The anchor sits at sequence 1, so content of the epoch's own starts at 2.
return awaitsRebuild(rows, FIRST_JOURNAL_SEQUENCE + 1)
}
/**
* True while everything at or above `contentFrom` is the repair's own
* bookkeeping: the deleted history was never rebuilt, so the provider has to be
* asked again. The moment the session writes content of its own past that
* sequence the epoch IS its own history, and the retry stops rather than a
* later import replacing rows the user has since seen.
*/
function awaitsRebuild(rows: readonly JournalRow[], contentFrom: number): boolean {
return rows.every(
(row) =>
row.seq < contentFrom ||
(row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID)
)
}
/** Rows after a cursor, in sequence order. Stops at the first row this build
* cannot parse, exactly as replay does. */
export function readJournalRowsAfterCursor(
db: Database.Database,
sessionId: string,
epoch: string,
afterSequence: number
): JournalRow[] {
const rows: JournalRow[] = []
for (const stored of readJournalRowsAfter(db, sessionId, epoch, afterSequence)) {
const parsed = parseJournalRow(stored.rowJson)
if (!parsed.ok) {
break
}
rows.push(parsed.row)
}
return rows
}
/** Standalone probe. Opens its own connection and closes it before returning,
* so a caller holding only the returned value holds no handle. */
export function loadJournal(journalDir: string, sessionId: string): JournalLoad | null {
const dbPath = journalDatabaseFile(journalDir)
if (!existsSync(dbPath)) {
return null
}
const opened = openJournalDatabase(dbPath)
try {
return replayJournal(opened.db, opened.readOnly, sessionId)
} finally {
opened.db.close()
}
}
function emptyReadOnlyLoad(sessionId: string): JournalLoad {
const state = createJournalReducerState(sessionId, '')
return {
state,
tailRows: [],
compactedThrough: 0,
state: createJournalReducerState(sessionId, ''),
readOnly: true,
corrupt: false,
malformedRows: 0,
sizeBytes: 0
malformedRows: 0
}
}
/** The snapshot names the live epoch; without one, the newest valid row does. */
function resolveEpoch(snapshot: JournalSnapshotFile | null, rows: JournalRow[]): string | null {
if (snapshot?.epoch) {
return snapshot.epoch
}
return rows.at(-1)?.epoch ?? null
}
function seedState(
sessionId: string,
epoch: string,
snapshot: JournalSnapshotFile | null
): JournalReducerState {
const state = createJournalReducerState(sessionId, epoch)
if (!snapshot) {
return state
}
for (const item of snapshot.items) {
state.items.set(item.itemId, item)
}
for (const submission of snapshot.submissions) {
state.submissions.set(submission.clientMessageId, { ...submission } as AgentJournalSubmission)
}
for (const receipt of snapshot.receipts) {
state.receipts.set(receipt.clientMessageId, {
clientMessageId: receipt.clientMessageId,
providerItemId: receipt.providerItemId,
cursor: { epoch: receipt.epoch, sequence: receipt.sequence },
acceptedAt: receipt.acceptedAt
})
}
for (const alias of snapshot.aliases) {
state.aliases.set(alias.providerItemId, alias.itemId)
}
for (const tombstone of snapshot.tombstones ?? []) {
state.tombstones.set(tombstone.itemId, tombstone.revision)
}
for (const settlementId of snapshot.appliedSettlementIds ?? []) {
rememberAppliedSettlementId(state, settlementId)
}
state.highestFence = snapshot.highestFence ?? 0
state.lastSequence = snapshot.compactedThrough
state.oldestSequence = snapshot.compactedThrough + 1
return state
}
/** Merge the snapshot's retained tail with the live log, preferring the log's
* copy of any sequence both hold, and dropping rows from a superseded epoch. */
function unionBySequence(
retained: readonly JournalRow[],
live: readonly JournalRow[],
epoch: string
): JournalRow[] {
const bySequence = new Map<number, JournalRow>()
for (const row of retained) {
if (row.epoch === epoch) {
bySequence.set(row.seq, row)
}
}
for (const row of live) {
bySequence.set(row.seq, row)
}
return [...bySequence.values()].sort((a, b) => a.seq - b.seq)
}
@@ -40,3 +40,10 @@ export function journalDirectoryFor(
export function defaultJournalRoot(): Promise<string> {
return Promise.resolve(getAppEnvironment().getPath('userData'))
}
export const JOURNAL_DATABASE_FILE = 'journal.db'
/** The session's SQLite database, inside the directory `journalDirectoryFor` names. */
export function journalDatabaseFile(journalDir: string): string {
return join(journalDir, JOURNAL_DATABASE_FILE)
}
@@ -1,11 +1,9 @@
// Payload bounds for tool output and diffs.
//
// A looping agent must not be able to fill the host disk, and a 40 MB tool
// result must not be inlined into a row that every reconnecting client
// replays. A bounded payload keeps a head plus the original byte length and
// digest; the remainder lives in the content-addressed blob store under the
// same retention as its epoch. Crossing a bound is always marked — never a
// silent drop.
// A 40 MB tool result must not be inlined into a row that every reconnecting
// client replays. A bounded payload keeps a head plus the original byte length
// and digest; the remainder is discarded. Crossing a bound is always marked —
// never a silent drop.
import { createHash } from 'node:crypto'
import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-journal-types'
@@ -13,18 +11,10 @@ import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-j
export type JournalPayloadLimits = {
/** Bytes of the payload kept inline on the row. */
inlineHeadBytes: number
/** Total bytes of journal rows one session may hold before appends are refused. */
maxSessionBytes: number
/** Appends allowed inside `appendWindowMs`, bounding a runaway agent's rate. */
maxAppendsPerWindow: number
appendWindowMs: number
}
export const DEFAULT_JOURNAL_PAYLOAD_LIMITS: JournalPayloadLimits = {
inlineHeadBytes: 16 * 1024,
maxSessionBytes: 256 * 1024 * 1024,
maxAppendsPerWindow: 5000,
appendWindowMs: 60_000
inlineHeadBytes: 16 * 1024
}
/** Marker appended to a clipped inline string so the UI never presents a
@@ -38,10 +28,8 @@ export function digestPayload(payload: string): string {
return createHash('sha256').update(payload, 'utf8').digest('hex')
}
/**
* Clip `payload` to the inline head. `truncated` means the remainder must be
* written to the blob store under `digest` before the row is appended.
*/
/** Clip `payload` to the inline head. `truncated` means the remainder was
* discarded; `digest` and `byteLength` describe the original. */
export function boundPayload(
payload: string,
limits: JournalPayloadLimits
@@ -60,7 +48,7 @@ export function boundPayload(
}
/** Bound a plain string that must stay a string (a tool-result block's output),
* keeping the explicit marker inline. Returns the blob payload to persist. */
* keeping the explicit marker inline. */
export function boundInlineText(
payload: string,
limits: JournalPayloadLimits
@@ -75,7 +63,7 @@ export function boundInlineText(
}
}
/** Keep arbitrary tool input JSON bounded before lifecycle admission. */
/** Keep arbitrary tool input JSON bounded before it reaches a row. */
export function boundToolInput(input: unknown, limits: JournalPayloadLimits): unknown {
let encoded: string
try {
@@ -1,125 +0,0 @@
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { JOURNAL_SNAPSHOT_FILE } from './journal-log-file'
import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { journalDirectoryBytes } from './journal-physical-quota'
import { openAgentSessionJournal } from './journal-store-factory'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
let root: string
function item(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
function body(value: string): AgentJournalItemBody {
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-quota-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
describe('journal physical quota peaks', () => {
it('refuses an epoch replacement whose staging peak exceeds the quota', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 8_000 }
const journal = await openAgentSessionJournal({
identity: IDENTITY,
journalDir: root,
limits,
autoCompact: false
})
await journal.appendItem(item(1), body('old'.repeat(500)), { fence: 1 })
const epoch = journal.epoch
await expect(
journal.replaceEpochItems('handle_forked', 2, [
{ identity: item(2), body: body('replacement'.repeat(250)) }
])
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(journal.epoch).toBe(epoch)
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
expect(await journalDirectoryBytes(root)).toBeLessThanOrEqual(limits.maxSessionBytes)
})
it('refuses schema quarantine when its peak copy would exceed the quota', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 7_000 }
await openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE)
const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record<string, unknown>
snapshot.v = 99
snapshot.items = [{ body: { kind: 'future', payload: 'x'.repeat(4_000) } }]
await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8')
const reopened = await openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
await expect(reopened.rollEpoch('schema_unreadable', 2)).rejects.toMatchObject({
code: 'journal_bound_exceeded'
})
expect(reopened.isReadOnly).toBe(true)
expect((await readdir(root)).some((name) => name.startsWith('quarantine-'))).toBe(false)
expect(await journalDirectoryBytes(root)).toBeLessThanOrEqual(limits.maxSessionBytes)
})
it('does not rename an invalid snapshot when the directory is already full', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 2_000 }
await openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE)
await writeFile(snapshotPath, '{"invalid":', 'utf8')
const current = await journalDirectoryBytes(root)
await writeFile(
join(root, 'quota-filler'),
'x'.repeat(Math.max(0, limits.maxSessionBytes - current)),
'utf8'
)
await expect(
openAgentSessionJournal({ identity: IDENTITY, journalDir: root, limits })
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(await readFile(snapshotPath, 'utf8')).toBe('{"invalid":')
expect((await readdir(root)).some((name) => name.startsWith('quarantine-snapshot-'))).toBe(
false
)
})
it('counts pre-existing durable-write temps while staging an epoch replacement', async () => {
const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 8_000 }
const journal = await openAgentSessionJournal({
identity: IDENTITY,
journalDir: root,
limits,
autoCompact: false
})
await journal.appendItem(item(1), body('old'), { fence: 1 })
// Simulate a temp left by a crash. Replacement must refuse before writing
// its epoch row or creating a staging blob beside this file.
await writeFile(join(root, 'snapshot.json.crashed-write.tmp'), 'x'.repeat(7_500), 'utf8')
const epoch = journal.epoch
await expect(
journal.replaceEpochItems('handle_forked', 2, [{ identity: item(2), body: body('new') }])
).rejects.toMatchObject({ code: 'journal_bound_exceeded' })
expect(journal.epoch).toBe(epoch)
expect((await readdir(root)).some((name) => name.startsWith('.epoch-replacement-'))).toBe(false)
})
})
@@ -1,41 +0,0 @@
import { lstat, readdir } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { AgentSessionJournalError } from './journal-write-guards'
/** Counts every physical file owned by one session, including blobs, durable
* write temps, and retained quarantine evidence. Symlinks are charged as files
* but never followed outside the journal directory. */
export async function journalDirectoryBytes(directory: string): Promise<number> {
let entries: Dirent<string>[]
try {
entries = await readdir(directory, { withFileTypes: true, encoding: 'utf8' })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return 0
}
throw error
}
let total = 0
for (const entry of entries) {
const path = join(directory, entry.name)
total += entry.isDirectory() ? await journalDirectoryBytes(path) : (await lstat(path)).size
}
return total
}
export async function assertJournalPhysicalCapacity(input: {
journalDir: string
sessionId: string
maxBytes: number
peakAdditionalBytes?: number
}): Promise<number> {
const current = await journalDirectoryBytes(input.journalDir)
if (current + (input.peakAdditionalBytes ?? 0) > input.maxBytes) {
throw new AgentSessionJournalError(
'journal_bound_exceeded',
`agent-session journal for ${input.sessionId} reached its ${input.maxBytes}-byte physical bound`
)
}
return current
}
@@ -12,10 +12,7 @@ import {
export const MAX_JOURNAL_PROMPT_OPTIONS = 64
const JOURNAL_PROMPT_OPTION_LIMITS = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
inlineHeadBytes: 1024
}
const JOURNAL_PROMPT_OPTION_LIMITS = { inlineHeadBytes: 1024 }
const JOURNAL_PROMPT_ID_MAX_BYTES = 1024
export function cancelledJournalPromptBody(
@@ -78,11 +75,6 @@ function boundPromptIdentifier(value: string): string {
if (Buffer.byteLength(value, 'utf8') <= JOURNAL_PROMPT_ID_MAX_BYTES) {
return value
}
const bounded = boundPayload(value, {
inlineHeadBytes: JOURNAL_PROMPT_ID_MAX_BYTES - 33,
maxSessionBytes: Number.MAX_SAFE_INTEGER,
maxAppendsPerWindow: Number.MAX_SAFE_INTEGER,
appendWindowMs: Number.MAX_SAFE_INTEGER
})
const bounded = boundPayload(value, { inlineHeadBytes: JOURNAL_PROMPT_ID_MAX_BYTES - 33 })
return `${bounded.head}#${bounded.digest.slice(0, 32)}`
}
@@ -11,7 +11,6 @@ import {
applyJournalRow,
createJournalReducerState,
MAX_JOURNAL_APPLIED_SETTLEMENT_IDS,
referencedBlobDigests,
renderJournalState,
type JournalReducerState
} from './journal-reducer'
@@ -379,58 +378,6 @@ describe('lifecycle settlement deduplication', () => {
})
})
describe('blob retention', () => {
it('reports the digests live rows still reference', () => {
const state = fold([
{
kind: 'item',
itemId: 'tool',
revision: 1,
body: {
kind: 'tool-call',
name: 'bash',
input: {},
state: 'completed',
output: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true }
},
...base(1)
},
{
kind: 'item',
itemId: 'inline',
revision: 1,
body: {
kind: 'tool-call',
name: 'bash',
input: {},
state: 'completed',
output: { head: 'y', byteLength: 1, digest: 'digest-b', truncated: false }
},
...base(2)
}
])
expect([...referencedBlobDigests(state)]).toEqual(['digest-a'])
})
it('stops referencing a digest once its item is tombstoned', () => {
const state = fold([
{
kind: 'item',
itemId: 'tool',
revision: 1,
body: {
kind: 'diff',
path: 'a.ts',
patch: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true }
},
...base(1)
},
{ kind: 'tombstone', itemId: 'tool', revision: 2, ...base(2) }
])
expect(referencedBlobDigests(state).size).toBe(0)
})
})
describe('malformed persisted item keys', () => {
it('degrades a malformed-percent item id to an opaque key instead of throwing', () => {
// A user-message body drives identity resolution through the key parser;
@@ -9,7 +9,6 @@
import type {
AgentJournalAcceptanceReceipt,
AgentJournalItemBody,
AgentJournalRenderItem,
AgentJournalSnapshot,
AgentJournalSubmission
@@ -272,26 +271,3 @@ export function renderJournalState(state: JournalReducerState): AgentJournalSnap
submissions: [...state.submissions.values()].sort((a, b) => a.submittedAt - b.submittedAt)
}
}
/** Blob digests one body points at. A retained row can outlive its render item
* (a tombstone drops the item), so compaction reads rows through this too. */
export function blobDigestsInBody(body: AgentJournalItemBody, into: Set<string>): void {
if (body.kind === 'tool-call' && body.output?.truncated) {
into.add(body.output.digest)
}
if (body.kind === 'diff' && body.patch.truncated) {
into.add(body.patch.digest)
}
if (body.kind === 'status' && body.providerFrame?.payload.truncated) {
into.add(body.providerFrame.payload.digest)
}
}
/** Digests referenced by live rows, so compaction knows which blobs to keep. */
export function referencedBlobDigests(state: JournalReducerState): Set<string> {
const digests = new Set<string>()
for (const item of state.items.values()) {
blobDigestsInBody(item.body, digests)
}
return digests
}
@@ -0,0 +1,32 @@
// What a repair tells the user it did.
//
// The identity is a constant because replay reads it back: an epoch holding
// nothing but its anchor and this row is a repair that has not been
// reconstructed yet, not a timeline.
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
/** One stable identity, so a reopen upserts the same row instead of adding one. */
export const JOURNAL_REPAIR_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = {
provider: 'orca',
clientMessageId: 'journal-malformed-lines'
}
export const JOURNAL_REPAIR_DISCLOSURE_ITEM_ID = agentJournalItemKey(
JOURNAL_REPAIR_DISCLOSURE_IDENTITY
)
export type JournalRepairDisclosure = {
identity: AgentJournalItemIdentity
body: { kind: 'status'; text: string }
}
/** Disclosed when a repair skipped a row it could not read. */
export function journalRepairDisclosure(input: { malformedRows: number }): JournalRepairDisclosure {
const lines = `${input.malformedRows} journal line${input.malformedRows === 1 ? '' : 's'}`
return {
identity: JOURNAL_REPAIR_DISCLOSURE_IDENTITY,
body: { kind: 'status', text: `${lines} could not be read` }
}
}
@@ -0,0 +1,69 @@
// The standing demand for a rebuild that a repair leaves behind.
//
// A repair that empties the epoch republishes an `unreconcilable_prefix` anchor,
// and replay reads that back as history still owed. A repair that KEEPS a prefix
// has no such anchor to publish and — for a plain sequence gap — no malformed
// row to disclose either, so nothing on disk would record that the deleted
// suffix was never reconstructed. This marker is that record, written in the
// SAME transaction as the deletion: a crash between the two would otherwise
// leave the rows gone with nothing left asking for them back.
//
// It records the first sequence at which the epoch would hold content of its
// own again, because it retires under exactly the rule the emptied-epoch anchor
// takes: a fresh epoch carries the rebuild, and a session that writes past that
// sequence owns the epoch and stops the retry.
import type Database from '../../sqlite/sync-database'
import { deleteJournalRowSuffix } from './journal-row-table'
const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?'
const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at`
const DELETE_REPAIR = 'DELETE FROM journal_repairs WHERE session_id = ?'
/**
* The sequence a pending repair on THIS epoch left free, or null when none is
* pending. Epoch-scoped: a marker raised on an epoch that has since been
* superseded says nothing about the live one.
*/
export function pendingJournalRepairSequence(
db: Database.Database,
sessionId: string,
epoch: string
): number | null {
const row = db.prepare(SELECT_REPAIR).get(sessionId) as
| { epoch?: string; content_from?: number }
| undefined
return row?.epoch === epoch ? (row.content_from ?? null) : null
}
/** Retires the marker. Called from inside the epoch transactions, whose new
* epoch is the rebuilt history the marker was holding out for. */
export function clearJournalRepairMarker(db: Database.Database, sessionId: string): void {
db.prepare(DELETE_REPAIR).run(sessionId)
}
/** Drop the rejected suffix and record that it is owed, atomically. */
export function deleteJournalRepairedSuffix(input: {
db: Database.Database
sessionId: string
epoch: string
/** First sequence of the rejected suffix. */
fromSeq: number
/** First sequence left free once the suffix is gone. */
contentFrom: number
now: number
}): number {
input.db.exec('BEGIN IMMEDIATE')
try {
const deleted = deleteJournalRowSuffix(input.db, input.sessionId, input.epoch, input.fromSeq)
input.db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now)
input.db.exec('COMMIT')
return deleted
} catch (error) {
input.db.exec('ROLLBACK')
throw error
}
}
@@ -0,0 +1,92 @@
// Every statement the journal issues against `journal_rows` / `journal_sessions`.
//
// Each one is a prefix or range scan on the `(session_id, epoch, seq)` primary
// key; there is no secondary index, and no `max(seq)` tip query — replay folds
// the epoch to obtain `lastSequence`, so nothing needs the tip from SQL.
// Columns are always named: `SELECT *` is uncacheable and can drop a column.
import type Database from '../../sqlite/sync-database'
import { serializeJournalRow, type JournalRow } from './journal-row-schema'
export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string }
const SELECT_SESSION = 'SELECT epoch FROM journal_sessions WHERE session_id = ?'
const UPSERT_SESSION = `INSERT INTO journal_sessions (session_id, epoch, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, updated_at = excluded.updated_at`
const INSERT_ROW =
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
const SELECT_EPOCH_ROWS = `SELECT epoch, seq, ts, row_json FROM journal_rows
WHERE session_id = ? AND epoch = ? ORDER BY seq ASC`
const SELECT_ROWS_AFTER = `SELECT epoch, seq, ts, row_json FROM journal_rows
WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC`
const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?'
export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null {
const row = db.prepare(SELECT_SESSION).get(sessionId) as { epoch?: string } | undefined
return row?.epoch ?? null
}
export function upsertJournalSessionRow(
db: Database.Database,
sessionId: string,
epoch: string,
updatedAt: number
): void {
db.prepare(UPSERT_SESSION).run(sessionId, epoch, updatedAt)
}
export function insertJournalRow(
db: Database.Database,
sessionId: string,
row: JournalRow
): number {
const rowJson = serializeJournalRow(row)
db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson)
return Buffer.byteLength(rowJson, 'utf8')
}
export function readJournalEpochRows(
db: Database.Database,
sessionId: string,
epoch: string
): JournalStoredRow[] {
return toStoredRows(db.prepare(SELECT_EPOCH_ROWS).all(sessionId, epoch))
}
export function readJournalRowsAfter(
db: Database.Database,
sessionId: string,
epoch: string,
afterSeq: number
): JournalStoredRow[] {
return toStoredRows(db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq))
}
/**
* Unqualified on purpose. One database per session means every row here belongs
* to this session, and the unqualified form takes SQLite's truncate
* optimization: measured at 0.26% of the database in WAL bytes where the
* `WHERE session_id = ?` form rewrote every emptied leaf at up to 99%.
*/
export function deleteAllJournalRows(db: Database.Database): void {
db.exec('DELETE FROM journal_rows')
}
/** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */
export function deleteJournalRowSuffix(
db: Database.Database,
sessionId: string,
epoch: string,
fromSeq: number
): number {
const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq)
return Number(deleted.changes ?? 0)
}
function toStoredRows(rows: readonly unknown[]): JournalStoredRow[] {
return rows.map((entry) => {
const record = entry as { epoch: string; seq: number; ts: number; row_json: string }
return { epoch: record.epoch, seq: record.seq, ts: record.ts, rowJson: record.row_json }
})
}
@@ -1,362 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
import { readJournalBlob } from './journal-blob-store'
import { appendJournalRows } from './journal-log-file'
import { JournalLifecycleAdmission } from './journal-lifecycle-admission'
import { JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES } from './journal-lifecycle-capacity'
import { loadJournal } from './journal-open'
import { boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
import { journalRowByteLength, type JournalRow } from './journal-row-schema'
import { JournalRowWriter } from './journal-row-writer'
import { JournalAppendBudget } from './journal-write-guards'
const SESSION_ID = 'session-1'
function row(seq: number, ts: number): JournalRow {
return {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
epoch: 'epoch-1',
seq,
fence: 0,
ts,
kind: 'item',
itemId: 'item-1',
revision: 1,
body: { kind: 'status', text: 'ambiguous append' }
}
}
function rowWithBlob(seq: number, ts: number, output: ReturnType<typeof boundPayload>): JournalRow {
return {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
epoch: 'epoch-1',
seq,
fence: 0,
ts,
kind: 'item',
itemId: 'item-with-blob',
revision: 1,
body: {
kind: 'tool-call',
name: 'shell',
input: {},
state: 'completed',
output
}
}
}
function runningToolRow(seq: number, ts: number, itemId = 'running-tool'): JournalRow {
return {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
epoch: 'epoch-1',
seq,
fence: 0,
ts,
kind: 'item',
itemId,
revision: 1,
body: runningToolBody()
}
}
function runningToolBody(): AgentJournalItemBody {
return { kind: 'tool-call', name: 'shell', input: {}, state: 'running' }
}
describe('journal row writer read-only latch', () => {
let root: string
let readOnly = false
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-'))
readOnly = false
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('enforces the lifecycle append rate and allows a retry after the window', () => {
const appendWindowMs = 100
const budget = new JournalAppendBudget(SESSION_ID, {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
maxAppendsPerWindow: 1,
appendWindowMs
})
budget.assertLifecycle(row(1, 1), 0)
expect(() => budget.assertLifecycle(row(2, 1), 0)).toThrow(
expect.objectContaining({ code: 'journal_rate_exceeded' })
)
expect(() => budget.assertLifecycle(row(2, appendWindowMs + 1), 0)).not.toThrow()
})
it('refuses lifecycle reservations once aggregate append capacity is saturated', () => {
const admission = new JournalLifecycleAdmission(SESSION_ID, 1_000_000, (itemId) => itemId, 2)
expect(admission.reserve({ id: 'first', bytes: 1, appendSlots: 1 }, 0)).toBe(true)
expect(admission.reserve({ id: 'second', bytes: 1, appendSlots: 1 }, 0)).toBe(true)
expect(admission.reserve({ id: 'third', bytes: 1, appendSlots: 1 }, 0)).toBe(false)
})
function writerHarness(
overrides: {
limits?: typeof DEFAULT_JOURNAL_PAYLOAD_LIMITS
physicalBytes?: number
appendRows?: (journalDir: string, rows: readonly JournalRow[]) => Promise<void>
commit?: (row: JournalRow, physicalBytes: number) => void
} = {}
) {
const limits = overrides.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS
const lifecycleAdmission = new JournalLifecycleAdmission(
SESSION_ID,
limits.maxSessionBytes,
(itemId) => itemId
)
let physicalBytes = overrides.physicalBytes ?? 0
let nextSequence = 1
const committedRows: JournalRow[] = []
const writer = new JournalRowWriter({
journalDir: root,
sessionId: SESSION_ID,
budget: new JournalAppendBudget(SESSION_ID, limits),
lifecycleAdmission,
autoCompact: false,
compaction: { minTailRows: 0, retainTailMs: 0 },
now: () => 1,
serialize: (run) => run(),
readOnly: () => readOnly,
setReadOnly: (value) => {
readOnly = value
},
physicalBytes: () => physicalBytes,
highestFence: () => 0,
nextSequence: () => nextSequence,
tailRows: () => committedRows,
referencedBlobDigests: () => new Set(),
compact: async () => undefined,
commit: (row, nextPhysicalBytes) => {
overrides.commit?.(row, nextPhysicalBytes)
committedRows.push(row)
physicalBytes = nextPhysicalBytes
nextSequence = row.seq + 1
},
...(overrides.appendRows ? { appendRows: overrides.appendRows } : {})
})
return { writer, lifecycleAdmission, committedRows }
}
it('latches read-only when a post-append failure makes durability ambiguous', async () => {
let committed = false
const writer = new JournalRowWriter({
journalDir: root,
sessionId: 'session-1',
budget: new JournalAppendBudget('session-1', DEFAULT_JOURNAL_PAYLOAD_LIMITS),
lifecycleAdmission: new JournalLifecycleAdmission(
'session-1',
DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes,
(itemId) => itemId
),
autoCompact: false,
compaction: { minTailRows: 0, retainTailMs: 0 },
now: () => 1,
serialize: (run) => run(),
readOnly: () => readOnly,
setReadOnly: (value) => {
readOnly = value
},
physicalBytes: () => 0,
highestFence: () => 0,
nextSequence: () => 1,
tailRows: () => [],
referencedBlobDigests: () => new Set(),
compact: async () => undefined,
commit: () => {
committed = true
},
appendRows: async (journalDir, rows) => {
await appendJournalRows(journalDir, rows)
throw new Error('fsync failed after append')
}
})
await expect(writer.enqueue(row)).rejects.toThrow('fsync failed after append')
expect(readOnly).toBe(true)
expect(committed).toBe(false)
await expect(writer.enqueue(row)).rejects.toMatchObject({ code: 'journal_read_only' })
})
it('keeps blobs for a durable row when a post-append crash is reported', async () => {
const payload = 'durable blob payload'.repeat(2_000)
const bounded = boundPayload(payload, {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
inlineHeadBytes: 32
})
const writer = new JournalRowWriter({
journalDir: root,
sessionId: 'session-1',
budget: new JournalAppendBudget('session-1', DEFAULT_JOURNAL_PAYLOAD_LIMITS),
lifecycleAdmission: new JournalLifecycleAdmission(
'session-1',
DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes,
(itemId) => itemId
),
autoCompact: false,
compaction: { minTailRows: 0, retainTailMs: 0 },
now: () => 1,
serialize: (run) => run(),
readOnly: () => readOnly,
setReadOnly: (value) => {
readOnly = value
},
physicalBytes: () => 0,
highestFence: () => 0,
nextSequence: () => 1,
tailRows: () => [],
referencedBlobDigests: () => new Set(),
compact: async () => undefined,
commit: () => undefined,
appendRows: async (journalDir, rows) => {
await appendJournalRows(journalDir, rows)
throw new Error('crash after row append')
}
})
await expect(
writer.enqueue(
(seq, ts) => rowWithBlob(seq, ts, bounded),
[{ digest: bounded.digest, payload }]
)
).rejects.toThrow('crash after row append')
expect(readOnly).toBe(true)
await expect(writer.enqueue(row)).rejects.toMatchObject({ code: 'journal_read_only' })
expect(await readJournalBlob(root, bounded.digest)).toBe(payload)
const reopened = await loadJournal(root, 'session-1')
const item = reopened?.state.items.get('item-with-blob')
expect(item?.body).toMatchObject({
kind: 'tool-call',
output: { digest: bounded.digest, truncated: true }
})
})
it('does not leak a lifecycle reservation after budget refusal', async () => {
const probe = runningToolRow(1, 1)
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
maxSessionBytes: JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES + journalRowByteLength(probe) - 1
}
const { writer, lifecycleAdmission, committedRows } = writerHarness({ limits })
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).rejects.toMatchObject({
code: 'journal_bound_exceeded'
})
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
await expect(writer.enqueue(row)).resolves.toMatchObject({ kind: 'item', itemId: 'item-1' })
expect(
committedRows.map((entry) => (entry.kind === 'item' ? entry.itemId : 'non-item'))
).toEqual(['item-1'])
})
it('preflights existing durable-write temps before creating a blob or row', async () => {
const tempBytes = 512
const tempPath = join(root, 'log.jsonl.existing-write.tmp')
await writeFile(tempPath, 't'.repeat(tempBytes), 'utf8')
const probe = row(1, 1)
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
maxSessionBytes: tempBytes + journalRowByteLength(probe) - 1
}
const { writer, committedRows } = writerHarness({ limits })
await expect(writer.enqueue((seq, ts) => row(seq, ts))).rejects.toMatchObject({
code: 'journal_bound_exceeded'
})
expect(committedRows).toHaveLength(0)
expect(await readJournalBlob(root, 'a'.repeat(64))).toBeNull()
})
it('does not leak a lifecycle reservation after blob lookup failure', async () => {
const { writer, lifecycleAdmission } = writerHarness()
const digest = 'a'.repeat(64)
await writeFile(join(root, 'blobs'), 'not a directory', 'utf8')
await expect(
writer.enqueue((seq, ts) => runningToolRow(seq, ts), [{ digest, payload: 'payload' }])
).rejects.toThrow()
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
await rm(join(root, 'blobs'), { force: true })
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).resolves.toMatchObject({
kind: 'item',
itemId: 'running-tool'
})
expect(lifecycleAdmission.state).toEqual({
reservedBytes: JOURNAL_ITEM_TERMINAL_RESERVATION_BYTES,
reservedAppendSlots: 1
})
})
it('rolls back ordinary append-rate reservation after blob preflight failure', async () => {
const limits = {
...DEFAULT_JOURNAL_PAYLOAD_LIMITS,
maxAppendsPerWindow: 1,
appendWindowMs: 100
}
const { writer, committedRows } = writerHarness({ limits })
const payload = 'retryable blob payload'.repeat(100)
const bounded = boundPayload(payload, limits)
await writeFile(join(root, 'blobs'), 'not a directory', 'utf8')
await expect(
writer.enqueue(
(seq, ts) => rowWithBlob(seq, ts, bounded),
[{ digest: bounded.digest, payload }]
)
).rejects.toThrow()
await rm(join(root, 'blobs'), { force: true })
await expect(
writer.enqueue(
(seq, ts) => rowWithBlob(seq, ts, bounded),
[{ digest: bounded.digest, payload }]
)
).resolves.toMatchObject({ kind: 'item', itemId: 'item-with-blob' })
expect(committedRows).toHaveLength(1)
})
it('does not leak a lifecycle reservation after durable append failure', async () => {
const { writer, lifecycleAdmission } = writerHarness({
appendRows: async () => {
throw new Error('append failed before a durable row existed')
}
})
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).rejects.toThrow(
'append failed before a durable row existed'
)
expect(readOnly).toBe(true)
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
})
it('does not leak a lifecycle reservation after reducer commit failure', async () => {
const { writer, lifecycleAdmission } = writerHarness({
commit: () => {
throw new Error('commit failed after durable append')
}
})
await expect(writer.enqueue((seq, ts) => runningToolRow(seq, ts))).rejects.toThrow(
'commit failed after durable append'
)
expect(lifecycleAdmission.state).toEqual({ reservedBytes: 0, reservedAppendSlots: 0 })
})
})
@@ -0,0 +1,94 @@
// The write path's transaction.
//
// A transaction either commits or does not, so the old "a post-append failure
// makes durability ambiguous" latch has nothing left to latch on: the case that
// used to assert the latch asserts the rollback instead.
import { 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 { openJournalDatabase, type OpenJournalDatabase } from './journal-database'
import { journalDatabaseFile } from './journal-paths'
import {
insertJournalRow,
readJournalEpochRows,
upsertJournalSessionRow
} from './journal-row-table'
import type { JournalRow } from './journal-row-schema'
import { JournalRowWriter } from './journal-row-writer'
const SESSION_ID = 'session-1'
const EPOCH = 'epoch-1'
function row(seq: number, ts: number): JournalRow {
return {
v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION,
epoch: EPOCH,
seq,
fence: 0,
ts,
kind: 'item',
itemId: 'item-1',
revision: 1,
body: { kind: 'status', text: 'plain append' }
}
}
describe('journal row writer', () => {
let root: string
let database: OpenJournalDatabase
let readOnly = false
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-'))
database = openJournalDatabase(journalDatabaseFile(root))
upsertJournalSessionRow(database.db, SESSION_ID, EPOCH, 1)
readOnly = false
})
afterEach(async () => {
try {
database.db.close()
} catch {
// Already closed by the case.
}
await rm(root, { recursive: true, force: true })
})
function writerHarness() {
const committedRows: JournalRow[] = []
let sequence = 1
const writer = new JournalRowWriter({
sessionId: SESSION_ID,
now: () => 1,
serialize: (run) => run(),
database: () => database,
readOnly: () => readOnly,
highestFence: () => 0,
nextSequence: () => sequence,
commit: (committed) => {
committedRows.push(committed)
sequence = committed.seq + 1
}
})
return { writer, committedRows }
}
it('rolls the transaction back and sets no latch when the insert fails', async () => {
const { writer, committedRows } = writerHarness()
// A row already occupies sequence 1, so the insert violates the primary key.
insertJournalRow(database.db, SESSION_ID, row(1, 1))
await expect(writer.enqueue(row)).rejects.toThrow()
expect(readOnly).toBe(false)
expect(committedRows).toHaveLength(0)
expect(readJournalEpochRows(database.db, SESSION_ID, EPOCH)).toHaveLength(1)
// Still writable: there is no ambiguity for a latch to protect against.
await expect(writer.enqueue((seq, ts) => row(seq + 1, ts))).resolves.toMatchObject({
kind: 'item'
})
})
})
@@ -1,188 +1,42 @@
import {
budgetPressurePolicy,
journalTailCanShedRows,
journalTailIsReadyToCompact,
type JournalCompactionPolicy
} from './journal-compaction'
import { journalBlobFileSize, putJournalBlob, removeJournalBlob } from './journal-blob-store'
import { appendJournalRows } from './journal-log-file'
import { blobDigestsInBody } from './journal-reducer'
import { journalDirectoryBytes } from './journal-physical-quota'
import type { JournalLifecycleAdmission } from './journal-lifecycle-admission'
import { journalRowByteLength, type JournalRow } from './journal-row-schema'
import {
AgentSessionJournalError,
assertJournalFence,
assertJournalWritable,
type JournalAppendBudget
} from './journal-write-guards'
type JournalBlob = { digest: string; payload: string }
import type Database from '../../sqlite/sync-database'
import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table'
import type { JournalRow } from './journal-row-schema'
import { assertJournalFence, assertJournalWritable } from './journal-write-guards'
export type JournalRowWriterDeps = {
journalDir: string
sessionId: string
budget: JournalAppendBudget
lifecycleAdmission: JournalLifecycleAdmission
autoCompact: boolean
compaction: JournalCompactionPolicy
now: () => number
serialize: <T>(run: () => Promise<T>) => Promise<T>
database: () => { db: Database.Database }
readOnly: () => boolean
setReadOnly: (readOnly: boolean) => void
physicalBytes: () => number
highestFence: () => number
nextSequence: () => number
tailRows: () => readonly JournalRow[]
referencedBlobDigests: () => ReadonlySet<string>
compact: (now: number, policy: JournalCompactionPolicy) => Promise<void>
commit: (row: JournalRow, physicalBytes: number) => void
appendRows?: (journalDir: string, rows: readonly JournalRow[]) => Promise<void>
commit: (row: JournalRow) => void
}
export class JournalRowWriter {
constructor(private readonly deps: JournalRowWriterDeps) {}
enqueue(
build: (seq: number, ts: number) => JournalRow,
blobs: readonly JournalBlob[] = []
): Promise<JournalRow> {
enqueue(build: (seq: number, ts: number) => JournalRow): Promise<JournalRow> {
return this.deps.serialize(async () => {
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
const ts = this.deps.now()
const row = build(this.deps.nextSequence(), ts)
const row = build(this.deps.nextSequence(), this.deps.now())
assertJournalFence(row.fence, this.deps.highestFence())
// The in-memory counter is an optimization, not the quota source of
// truth: a prior crash may have left a durable-write temp beside the
// finals, and a concurrent/retried opener may have materialized files
// after the last commit callback. Recount before any speculative write
// so the peak check includes those bytes.
let physicalBytes = Math.max(
this.deps.physicalBytes(),
await journalDirectoryBytes(this.deps.journalDir)
)
const admission = this.deps.lifecycleAdmission.prepare(row, physicalBytes)
const newBlobs = await uniqueNewBlobs(this.deps.journalDir, blobs)
const blobBytes = newBlobs.reduce(
(total, blob) => total + Buffer.byteLength(blob.payload, 'utf8'),
0
)
const budgetCompaction = budgetPressurePolicy(this.deps.compaction)
let effectiveSize = physicalBytes + blobBytes + admission.protectedBytes
if (
this.deps.autoCompact &&
this.deps.budget.wouldExceedSize(row, effectiveSize) &&
journalTailCanShedRows(this.deps.tailRows(), budgetCompaction, ts)
) {
await this.deps.compact(ts, budgetCompaction)
physicalBytes = this.deps.physicalBytes()
effectiveSize = physicalBytes + blobBytes + admission.protectedBytes
}
const lifecycleRateCheckpoint = admission.lifecycleCovered
? this.deps.budget.checkpoint()
: null
const appendRateCheckpoint = this.deps.budget.checkpoint()
let committed = false
let appendMayHaveLanded = false
const { db } = this.deps.database()
db.exec('BEGIN IMMEDIATE')
try {
if (admission.lifecycleCovered) {
this.deps.budget.assertReservedLifecycle(row, effectiveSize)
} else {
this.deps.budget.assert(row, ts, effectiveSize)
}
const appendedBytes = blobBytes + journalRowByteLength(row)
if (
physicalBytes + appendedBytes >
this.deps.budget.maxSessionBytes - admission.protectedBytes
) {
throw new AgentSessionJournalError(
'journal_bound_exceeded',
`agent-session journal for ${this.deps.sessionId} reached its ${this.deps.budget.maxSessionBytes}-byte physical bound`
)
}
await this.commitFiles(row, newBlobs, () => {
appendMayHaveLanded = true
})
physicalBytes += appendedBytes
this.deps.commit(row, physicalBytes)
this.deps.lifecycleAdmission.commit(admission)
committed = true
insertJournalRow(db, this.deps.sessionId, row)
upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts)
db.exec('COMMIT')
} catch (error) {
if (!committed && lifecycleRateCheckpoint) {
this.deps.budget.restore(lifecycleRateCheckpoint)
}
if (!committed && !appendMayHaveLanded) {
this.deps.budget.restore(appendRateCheckpoint)
}
db.exec('ROLLBACK')
throw error
}
if (
this.deps.autoCompact &&
journalTailIsReadyToCompact(this.deps.tailRows(), this.deps.compaction, ts)
) {
await this.deps.compact(ts, this.deps.compaction)
}
// COMMIT landed, so the row is durable: adopt it before anything that can
// fail. Rejecting here instead would leave the next append reusing a
// sequence the table already holds.
this.deps.commit(row)
return row
})
}
private async commitFiles(
row: JournalRow,
blobs: readonly JournalBlob[],
markAppendLanded: () => void
): Promise<void> {
const persisted: string[] = []
let appendMayHaveLanded = false
try {
for (const blob of blobs) {
await putJournalBlob(this.deps.journalDir, blob.digest, blob.payload)
persisted.push(blob.digest)
}
appendMayHaveLanded = true
markAppendLanded()
await (this.deps.appendRows ?? appendJournalRows)(this.deps.journalDir, [row])
} catch (error) {
if (appendMayHaveLanded) {
this.deps.setReadOnly(true)
throw error
}
const retained = this.referencedBlobDigestsIncludingTail()
for (const digest of persisted) {
if (!retained.has(digest)) {
await removeJournalBlob(this.deps.journalDir, digest)
}
}
throw error
}
}
private referencedBlobDigestsIncludingTail(): Set<string> {
const retained = new Set(this.deps.referencedBlobDigests())
for (const row of this.deps.tailRows()) {
if (row.kind === 'item') {
blobDigestsInBody(row.body, retained)
} else if (row.kind === 'lifecycle-batch') {
for (const mutation of row.mutations) {
if (mutation.kind === 'item') {
blobDigestsInBody(mutation.body, retained)
}
}
}
}
return retained
}
}
async function uniqueNewBlobs(
journalDir: string,
blobs: readonly JournalBlob[]
): Promise<JournalBlob[]> {
const unique = new Map(blobs.map((blob) => [blob.digest, blob]))
const result: JournalBlob[] = []
for (const blob of unique.values()) {
if ((await journalBlobFileSize(journalDir, blob.digest)) === null) {
result.push(blob)
}
}
return result
}
@@ -0,0 +1,259 @@
// `close()`: enqueue-time admission, and the fulfilled/rejected split.
//
// The two failures this file exists to prevent: an append enqueued in the same
// turn as `close()` being rejected AFTER the queue accepted it, and a rejected
// close leaving a connection live but permanently unreachable through the API.
import { access, 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 type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { journalDatabaseFile } from './journal-paths'
import type { AgentSessionJournal } from './journal-store'
import { createTrackedJournalOpener } from './journal-store-test-open'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
let root: string
const journals = createTrackedJournalOpener()
function item(ordinal: number): AgentJournalItemIdentity {
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
}
function body(value: string): AgentJournalItemBody {
return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] }
}
function openJournal(): Promise<AgentSessionJournal> {
return journals.open({ identity: IDENTITY, journalDir: root })
}
/** Replaces the store's own release step, which is the only step that can
* reject the attempt in production. */
function injectReleaseFailure(journal: AgentSessionJournal): {
calls: () => number
stopFailing: () => void
} {
const internals = journal as unknown as { database: { db: { close: () => void } } }
const release = internals.database.db.close.bind(internals.database.db)
let calls = 0
let failing = true
internals.database.db.close = () => {
calls += 1
if (failing) {
throw new Error('injected release failure')
}
release()
}
return { calls: () => calls, stopFailing: () => (failing = false) }
}
async function exists(path: string): Promise<boolean> {
return access(path)
.then(() => true)
.catch(() => false)
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-close-'))
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
describe('closed-state admission happens at enqueue', () => {
it('completes a write enqueued in the same turn as the close', async () => {
const journal = await openJournal()
const append = journal.appendItem(item(1), body('before'), { fence: 1 })
const closed = journal.close()
await expect(append).resolves.toBeDefined()
await expect(closed).resolves.toBeUndefined()
const reopened = await openJournal()
expect(reopened.snapshot().items).toHaveLength(1)
})
it('refuses a write offered while the close is still in flight, without queueing it', async () => {
const journal = await openJournal()
const closing = journal.close()
const refused = journal.appendItem(item(1), body('during'), { fence: 1 })
// The rejection is available before the close step has run: it never joined
// the queue, so nothing is ever chained behind a close.
await expect(refused).rejects.toMatchObject({ code: 'journal_closed' })
await expect(closing).resolves.toBeUndefined()
})
it('refuses every write entry point after the close has settled', async () => {
const journal = await openJournal()
await journal.close()
const settle = (attempt: Promise<unknown>): Promise<unknown> =>
attempt.then(
() => new Error('resolved instead of refusing'),
(error: unknown) => error
)
const refusals = [
settle(journal.appendItem(item(1), body('after'), { fence: 1 })),
settle(journal.appendTombstone(item(3), { fence: 1 })),
settle(
journal.appendSubmission({
clientMessageId: 'cm_1',
payloadFingerprint: 'f',
body: { kind: 'message', role: 'user', blocks: [] },
fence: 1
})
),
settle(journal.resolveDispatch({ clientMessageId: 'cm_1', state: 'rejected', fence: 1 })),
settle(
journal.appendLifecycleBatch({
settlementId: 'settle',
fence: 1,
mutations: [{ kind: 'item', identity: item(4), body: body('x') }]
})
),
settle(journal.rollEpoch('handle_forked', 1)),
settle(journal.replaceEpochItems('handle_forked', 1, []))
]
for (const refusal of refusals) {
expect(await refusal).toMatchObject({ code: 'journal_closed' })
}
})
// The property part 1 rests on: every entry point reaches the gate in the
// caller's own turn, so a refusal never advances the queue.
it('rejects without waiting for the queue to advance', async () => {
const journal = await openJournal()
const inFlight = journal.appendItem(item(1), body('admitted'), { fence: 1 })
const closing = journal.close()
// Settles while the admitted append is still running: it reached the gate in
// the caller's own turn and never joined the queue behind it.
await expect(journal.appendItem(item(2), body('later'), { fence: 1 })).rejects.toMatchObject({
code: 'journal_closed'
})
await expect(inFlight).resolves.toBeDefined()
await expect(closing).resolves.toBeUndefined()
})
it('resolves a second close as a no-op without running the routine again', async () => {
const journal = await openJournal()
const injected = injectReleaseFailure(journal)
injected.stopFailing()
await journal.close()
expect(injected.calls()).toBe(1)
await expect(journal.close()).resolves.toBeUndefined()
expect(injected.calls()).toBe(1)
})
})
describe('a rejected close is a real retry', () => {
it('retries the release, releases the handle, and then goes terminal', async () => {
const journal = await openJournal()
await journal.appendItem(item(1), body('durable'), { fence: 1 })
const injected = injectReleaseFailure(journal)
await expect(journal.close()).rejects.toThrow('injected release failure')
expect(injected.calls()).toBe(1)
// Write-closed anyway: retry exists to release the OS handle, never to
// resurrect the store.
await expect(journal.appendItem(item(2), body('after'), { fence: 1 })).rejects.toMatchObject({
code: 'journal_closed'
})
injected.stopFailing()
await expect(journal.close()).resolves.toBeUndefined()
// The retry RE-ENTERED the release. A completion flag on it would skip the
// one step that had not succeeded, and the handle would never be released.
expect(injected.calls()).toBe(2)
const dbPath = journalDatabaseFile(root)
expect(await exists(`${dbPath}-wal`)).toBe(false)
expect(await exists(`${dbPath}-shm`)).toBe(false)
// Fulfilment is terminal: a third call must not issue a second db.close(),
// which `node:sqlite` answers with ERR_INVALID_STATE.
await expect(journal.close()).resolves.toBeUndefined()
expect(injected.calls()).toBe(2)
})
it('hands concurrent callers the same outcome', async () => {
const journal = await openJournal()
const injected = injectReleaseFailure(journal)
const first = journal.close()
const second = journal.close()
await expect(first).rejects.toThrow('injected release failure')
await expect(second).rejects.toThrow('injected release failure')
expect(injected.calls()).toBe(1)
})
})
// The two rejected readings of the contract, as models, because a green run on
// the real store proves nothing about what the alternatives would have done.
describe('negative controls', () => {
class UnconditionalNoOpClose {
calls = 0
private called = false
constructor(private readonly release: () => void) {}
async close(): Promise<void> {
if (this.called) {
return
}
this.called = true
this.calls += 1
this.release()
}
}
class AlwaysReentrantClose {
calls = 0
async close(release: () => void): Promise<void> {
this.calls += 1
release()
}
}
it('an unconditional second-call no-op leaves a failed close unreleasable', async () => {
let failing = true
let released = false
const model = new UnconditionalNoOpClose(() => {
if (failing) {
throw new Error('injected release failure')
}
released = true
})
await expect(model.close()).rejects.toThrow('injected release failure')
failing = false
await expect(model.close()).resolves.toBeUndefined()
// Fulfilled without the routine running: the handle is still held.
expect(model.calls).toBe(1)
expect(released).toBe(false)
})
it('an always-reentrant close issues the second db.close() that throws', async () => {
let open = true
const release = (): void => {
if (!open) {
throw new Error('ERR_INVALID_STATE: database is not open')
}
open = false
}
const model = new AlwaysReentrantClose()
await model.close(release)
await expect(model.close(release)).rejects.toThrow('database is not open')
})
})
@@ -0,0 +1,106 @@
// Releasing the one SQLite connection a store holds for its lifetime.
//
// The contract, in five parts:
//
// 1. Closed-state admission happens at ENQUEUE, once, and is permanent. The
// flag lives on the store's write gate; it is never cleared, not by a
// rejection and not by a retry. A store that failed to close is still a
// store nobody may write to.
// 2. The close step shares the write queue and BYPASSES that gate — a close
// that consulted the flag it had just set would refuse itself. Same queue
// orders the release behind admitted work; separate gate lets it run.
// 3. One in-flight attempt, shared: concurrent callers get the same outcome.
// 4. Fulfilled is TERMINAL; rejected is not. A later call after fulfilment is a
// genuine no-op — `DatabaseSync.close()` throws `ERR_INVALID_STATE` on a
// second call, so re-entering after success is the bug, not the fix.
// 5. The release is deliberately unguarded. There is no way to ask whether a
// `db.close()` that threw released the handle first, and guarding the step
// would skip it on retry — guaranteeing a permanent leak in exactly the case
// where it did not release.
import { AgentSessionJournalError } from './journal-write-guards'
import type Database from '../../sqlite/sync-database'
/**
* The write queue and its closed gate. Admission is checked at ENQUEUE and is
* permanent; the close step reaches the same queue through `serializePastGate`,
* because a close that consulted the flag it had just set would refuse itself.
*/
export class JournalWriteQueue {
private writes: Promise<unknown> = Promise.resolve()
private closed = false
constructor(private readonly sessionId: string) {}
markClosed(): void {
this.closed = true
}
serialize<T>(run: () => Promise<T>): Promise<T> {
if (this.closed) {
return Promise.reject(
new AgentSessionJournalError(
'journal_closed',
`agent-session journal for ${this.sessionId} is closed`
)
)
}
return this.serializePastGate(run)
}
serializePastGate<T>(run: () => Promise<T>): Promise<T> {
const started = this.writes.then(run)
this.writes = started.catch(() => undefined)
return started
}
}
export class JournalConnectionCloser {
private released = false
private inFlight: Promise<void> | null = null
constructor(
private readonly deps: {
connection: () => Database.Database | null
/** Chains onto the store's write queue past the closed gate. */
enqueue: (run: () => Promise<void>) => Promise<void>
}
) {}
get isReleased(): boolean {
return this.released
}
close(): Promise<void> {
if (this.released) {
return Promise.resolve()
}
if (this.inFlight) {
return this.inFlight
}
const attempt = this.deps
.enqueue(() => this.release())
.then(
() => {
this.released = true
this.inFlight = null
},
(error: unknown) => {
this.inFlight = null
throw error
}
)
this.inFlight = attempt
return attempt
}
private async release(): Promise<void> {
const db = this.deps.connection()
if (!db) {
return
}
// SQLite checkpoints and removes the WAL itself when the last connection to
// the database closes.
db.close()
}
}
@@ -0,0 +1,88 @@
// Wiring for the store's collaborators.
//
// Split out of the store itself so the class stays a description of the public
// surface rather than sixty lines of constructor plumbing.
import type {
AgentJournalCursor,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { OpenJournalDatabase } from './journal-database'
import { JournalEpochController } from './journal-epoch-controller'
import { JournalItemAppender } from './journal-item-appender'
import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
import type { JournalLoad } from './journal-open'
import type { JournalReducerState } from './journal-reducer'
import { JournalRowWriter } from './journal-row-writer'
import { restoreJournalStore } from './journal-store-restore'
import type { JournalRow } from './journal-row-schema'
import type { AgentSessionJournal } from './journal-store'
export type JournalStoreHost = {
identity: AgentSessionJournalIdentity
journalDir: string
now: () => number
mintEpoch: () => string
serialize: <T>(run: () => Promise<T>) => Promise<T>
database: () => OpenJournalDatabase
state: () => JournalReducerState
readOnly: () => boolean
setReadOnly: (readOnly: boolean) => void
cursor: () => AgentJournalCursor
adopt: (loaded: JournalLoad) => void
commit: (row: JournalRow) => void
/** A caller-supplied load, which suppresses replay entirely when present. */
loaded: () => JournalLoad | null | undefined
malformedRows: () => number
setMalformedRows: (count: number) => void
journal: () => AgentSessionJournal
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise<JournalRow>
}
export type JournalStoreCollaborators = {
rowWriter: JournalRowWriter
epochController: JournalEpochController
itemAppender: JournalItemAppender
lifecycleBatchAppender: JournalLifecycleBatchAppender
/** Restores the store's state from disk. Owned here because it needs the same
* collaborators the constructor just built. */
restore: () => Promise<void>
}
export function createJournalStoreCollaborators(host: JournalStoreHost): JournalStoreCollaborators {
const epochController = new JournalEpochController({
identity: host.identity,
now: host.now,
mintEpoch: host.mintEpoch,
serialize: host.serialize,
database: host.database,
readOnly: host.readOnly,
setReadOnly: host.setReadOnly,
highestFence: () => host.state().highestFence,
cursor: host.cursor,
adopt: host.adopt
})
return {
epochController,
restore: () => restoreJournalStore(host, { epochController }),
rowWriter: new JournalRowWriter({
sessionId: host.identity.sessionId,
now: host.now,
serialize: host.serialize,
database: host.database,
readOnly: host.readOnly,
highestFence: () => host.state().highestFence,
nextSequence: () => host.state().lastSequence + 1,
commit: host.commit
}),
itemAppender: new JournalItemAppender({
state: host.state,
enqueue: host.enqueue
}),
lifecycleBatchAppender: new JournalLifecycleBatchAppender({
state: host.state,
cursor: host.cursor,
enqueue: host.enqueue
})
}
}
@@ -6,19 +6,13 @@ import type {
AgentJournalResetReason,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import type { JournalCompactionPolicy } from './journal-compaction'
import type { JournalLoad } from './journal-open'
import type { JournalPayloadLimits } from './journal-payload-bounds'
import type { JournalLifecycleMutationInput } from './journal-row-builders'
import type { JournalRow } from './journal-row-schema'
export type AgentSessionJournalOptions = {
identity: AgentSessionJournalIdentity
journalDir: string
limits?: JournalPayloadLimits
compaction?: JournalCompactionPolicy
/** Compact as the tail grows. Defaults on: without it the log never sheds. */
autoCompact?: boolean
now?: () => number
mintEpoch?: () => string
/** A caller that already loaded the journal can avoid reading the same files again. */
@@ -45,7 +39,6 @@ export type JournalAppendResult = {
}
export type JournalItemAppendOptions = { fence: number; observedAt?: number; recovered?: true }
export type JournalBlobInput = { digest: string; payload: string }
export type JournalTombstoneInput = { fence: number }
export type JournalLifecycleBatchInput = {
@@ -1,16 +1,14 @@
import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types'
import { malformedRowsDisclosure, quarantineCorruptSuffix } from './journal-corruption-quarantine'
import { ensureJournalDir } from './journal-log-file'
import { loadJournal, type JournalLoad } from './journal-open'
import { assertJournalPhysicalCapacity, journalDirectoryBytes } from './journal-physical-quota'
import type { JournalRow } from './journal-row-schema'
import { mkdir } from 'node:fs/promises'
import type { JournalLoad } from './journal-open'
import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure'
export async function ensureJournalDir(journalDir: string): Promise<void> {
await mkdir(journalDir, { recursive: true })
}
export function journalStoreLoadedFields(loaded: JournalLoad) {
return {
state: loaded.state,
tailRows: loaded.tailRows,
compactedThrough: loaded.compactedThrough,
sizeBytes: loaded.sizeBytes,
readOnly: loaded.readOnly,
malformedRows: loaded.malformedRows
}
@@ -18,60 +16,47 @@ export function journalStoreLoadedFields(loaded: JournalLoad) {
export async function openJournalStoreState(input: {
journalDir: string
sessionId: string
maxBytes: number
loaded: JournalLoad | null | undefined
start: () => Promise<void>
replay: () => JournalLoad | null
/** Drops the rejected suffix and records the rebuild it owes, in ONE
* transaction. Corruption is not preserved; replay keeps reporting `corrupt`
* until provider history republishes the epoch or the session writes past
* `contentFrom`, the first sequence the repair left free. */
deleteSuffix: (fromSeq: number, contentFrom: number) => number
start: () => void
adopt: (loaded: JournalLoad) => void
tailRows: () => readonly JournalRow[]
snapshot: () => AgentJournalSnapshot
rebuildLifecycle: (snapshot: AgentJournalSnapshot, physicalBytes: number) => void
/** Republishes an anchor row for an epoch a repair emptied. */
publishRepairEpoch: () => void
appendDisclosure: (
identity: ReturnType<typeof malformedRowsDisclosure>['identity'],
body: ReturnType<typeof malformedRowsDisclosure>['body'],
identity: JournalRepairDisclosure['identity'],
body: JournalRepairDisclosure['body'],
fence: number
) => Promise<unknown>
highestFence: () => number
malformedRows: () => number
setMalformedRows: (count: number) => void
readOnly: () => boolean
setPhysicalBytes: (bytes: number) => void
}): Promise<void> {
await ensureJournalDir(input.journalDir)
input.setPhysicalBytes(
await assertJournalPhysicalCapacity({
journalDir: input.journalDir,
sessionId: input.sessionId,
maxBytes: input.maxBytes
})
)
const loaded =
input.loaded !== undefined
? input.loaded
: await loadJournal(input.journalDir, input.sessionId, { maxBytes: input.maxBytes })
const loaded = input.loaded !== undefined ? input.loaded : input.replay()
if (!loaded) {
await input.start()
input.setPhysicalBytes(await journalDirectoryBytes(input.journalDir))
input.start()
return
}
input.adopt(loaded)
if (loaded.corrupt && !loaded.readOnly) {
await quarantineCorruptSuffix(input.journalDir, input.tailRows(), loaded.quarantineRemainder, {
sessionId: input.sessionId,
maxBytes: input.maxBytes
})
if (loaded.truncateFrom !== undefined && !loaded.readOnly) {
input.deleteSuffix(loaded.truncateFrom, loaded.state.lastSequence + 1)
}
let physicalBytes = await journalDirectoryBytes(input.journalDir)
input.setPhysicalBytes(physicalBytes)
// A future-schema/read-only journal is inspection-only. Its reduced state is
// intentionally empty, and rebuilding reservations from it would mutate the
// in-memory quota model (and could influence later admission decisions).
if (!loaded.readOnly) {
input.rebuildLifecycle(input.snapshot(), physicalBytes)
// A repair that took every live row leaves the epoch with no anchor. Publish
// one before anything can append into it: an ordinary row at sequence 1 would
// replay as a clean timeline and hide that the history was never rebuilt.
if (!loaded.readOnly && loaded.state.lastSequence === 0) {
input.publishRepairEpoch()
// The replacement epoch adopts a clean load; what this open's repair did is
// still the answer `repair` and the disclosure below owe the caller.
input.setMalformedRows(loaded.malformedRows)
}
if (input.malformedRows() > 0 && !input.readOnly()) {
const disclosure = malformedRowsDisclosure(input.malformedRows())
const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
}
physicalBytes = await journalDirectoryBytes(input.journalDir)
input.setPhysicalBytes(physicalBytes)
}
@@ -0,0 +1,49 @@
// Bringing a store's in-memory state up from disk.
//
// Split out of the store for the same reason its collaborators were: this is the
// ORDERING between replay, suffix repair and disclosure, and none of it belongs
// to the store's public surface. Every step here reads or writes through the
// same host the collaborators use, so the store keeps the state and this owns
// the sequence.
import type { JournalEpochController } from './journal-epoch-controller'
import { replayJournal } from './journal-open'
import type { JournalStoreHost } from './journal-store-collaborators'
import { openJournalStoreState } from './journal-store-open'
import { deleteJournalRepairedSuffix } from './journal-repair-marker'
export function restoreJournalStore(
host: JournalStoreHost,
collaborators: { epochController: JournalEpochController }
): Promise<void> {
return openJournalStoreState({
journalDir: host.journalDir,
loaded: host.loaded(),
replay: () => {
const opened = host.database()
return replayJournal(opened.db, opened.readOnly, host.identity.sessionId)
},
deleteSuffix: (fromSeq, contentFrom) =>
deleteJournalRepairedSuffix({
db: host.database().db,
sessionId: host.identity.sessionId,
epoch: host.state().epoch,
fromSeq,
contentFrom,
now: host.now()
}),
start: () => collaborators.epochController.start('session_created', 0),
// `unreconcilable_prefix` is the durable statement that this epoch exists
// because a repair emptied one: replay reads it back and keeps asking for
// provider history until the timeline is rebuilt or the session writes.
publishRepairEpoch: () =>
collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence),
adopt: host.adopt,
appendDisclosure: (identity, body, fence) =>
host.journal().appendItem(identity, body, { fence }),
highestFence: () => host.state().highestFence,
malformedRows: host.malformedRows,
setMalformedRows: host.setMalformedRows,
readOnly: host.readOnly
})
}

Some files were not shown because too many files have changed in this diff Show More