Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
c1bbffc
feat(native-chat): one structured-chat journal database per host, own…
brennanb2025 Sep 28, 2026
73795f6
perf(native-chat): key journal rows by block so one chat's rows sit t…
brennanb2025 Sep 28, 2026
1e1a809
feat(native-chat): copy a chat's per-chat journal again when an older…
brennanb2025 Sep 28, 2026
0f76d9c
test(native-chat): fixture deps match the host journal database shape
brennanb2025 Sep 28, 2026
c368fd3
test(native-chat): state why the runtime-state fixtures' existing cas…
brennanb2025 Sep 28, 2026
d31a341
fix(native-chat): start up normally when this process cannot open the…
brennanb2025 Sep 28, 2026
57faadb
test(native-chat): name the owner-lock sweep test after the two sweep…
brennanb2025 Sep 28, 2026
4f02a2d
fix(native-chat): session history and terminal resume work while chat…
brennanb2025 Sep 28, 2026
a1b2dcf
test(native-chat): the first-work rename's fake journal saves the lis…
brennanb2025 Sep 28, 2026
1f2266a
fix(native-chat): open a chat whose per-chat journal file never got i…
brennanb2025 Sep 28, 2026
3700f85
fix(native-chat): let the event loop run between chats during startup…
brennanb2025 Sep 28, 2026
93aed4a
fix(native-chat): import a per-chat journal in bounded batches
brennanb2025 Sep 28, 2026
bfc41cf
fix(native-chat): refuse chats when the owner lock file cannot be opened
brennanb2025 Sep 28, 2026
fe384b5
fix(native-chat): finish reclaiming pages a delete frees during a run…
brennanb2025 Sep 28, 2026
31062a5
test(native-chat): desktop session history is served while chats are …
brennanb2025 Sep 28, 2026
1b778b6
fix(native-chat): a send to a chat holding a newer Orca's rows says t…
brennanb2025 Sep 28, 2026
c6a891e
fix(native-chat): keep chat tabs while this process cannot list its c…
brennanb2025 Sep 28, 2026
ea9ec6a
fix(native-chat): say chats are open in another Orca, with this proce…
brennanb2025 Sep 28, 2026
0303e4c
fix(native-chat): restore chats on taking ownership, without a list t…
brennanb2025 Sep 28, 2026
e974fa1
test(native-chat): a navigation reply says chats are unverifiable whi…
brennanb2025 Sep 28, 2026
dbf2eef
fix(native-chat): retry a refused owner lock at most every 5 seconds
brennanb2025 Sep 28, 2026
5f4b171
fix(native-chat): install before deciding whether a takeover must rep…
brennanb2025 Sep 28, 2026
3a9f5cb
fix(native-chat): no takeover lands a host after the runtime stop
brennanb2025 Sep 28, 2026
4a40671
fix(native-chat): show a thrown refusal in its own words, not its code
brennanb2025 Sep 28, 2026
e8228fc
fix(native-chat): log a failed journal open once per distinct failure
brennanb2025 Sep 28, 2026
dbe6ee8
fix(native-chat): restore lists a chat from its per-chat file and cop…
brennanb2025 Sep 28, 2026
68c0614
fix(native-chat): no host stays registered after a stop an install sp…
brennanb2025 Sep 28, 2026
b897ea1
fix(native-chat): checkpoint the journal with a full flush on macOS
brennanb2025 Sep 28, 2026
36d4b19
fix(native-chat): delete a per-chat journal once its copy verifies
brennanb2025 Sep 28, 2026
40a26c3
fix(native-chat): verify an imported chat a batch at a time
brennanb2025 Sep 28, 2026
271199c
fix(native-chat): restore reads a chat's per-chat file a batch at a time
brennanb2025 Sep 28, 2026
235ea68
fix(native-chat): end a per-chat copy on a turn of its own
brennanb2025 Sep 28, 2026
643c302
fix(native-chat): commit a per-chat copy's batches without an fsync each
brennanb2025 Sep 28, 2026
e1a8546
fix(native-chat): roll back a chat journal transaction whose COMMIT f…
brennanb2025 Sep 28, 2026
791f8cc
fix(native-chat): keep the chat journal connection until its close su…
brennanb2025 Sep 28, 2026
67f5e35
fix(native-chat): publish the runtime only once it holds the chat jou…
brennanb2025 Sep 28, 2026
508839f
fix(native-chat): keep a chat's history when an older build started i…
brennanb2025 Sep 28, 2026
b50fab8
test(native-chat): pin which chats startup restore copies
brennanb2025 Sep 28, 2026
600710f
test(native-chat): pin the copy wait on a read that opens a chat rest…
brennanb2025 Sep 28, 2026
ff4ae98
fix(native-chat): record a set-aside per-chat file so no later open r…
brennanb2025 Sep 28, 2026
654e9bf
fix(native-chat): set aside any per-chat file at an epoch this build …
brennanb2025 Sep 28, 2026
4ccb483
Merge origin/main into brennanb2025/chat-journal-appwide-db
brennanb2025 Sep 28, 2026
6a5ee54
fix(native-chat): copy a reappearing per-chat file again only while t…
brennanb2025 Sep 28, 2026
74f4867
test(native-chat): pin that a chat founded here keeps its history, an…
brennanb2025 Sep 28, 2026
b156850
test(native-chat): drop a lost copied row by patching the source, not…
brennanb2025 Sep 28, 2026
7ca760b
chore(mobile): restore the mobile lockfile to main's
brennanb2025 Sep 28, 2026
0c2ca11
fix(native-chat): pass a classified journal refusal through a send or…
brennanb2025 Sep 28, 2026
e130f3f
fix(native-chat): refuse a read whose owed copy fails as a failed ope…
brennanb2025 Sep 28, 2026
8dfd406
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-jou…
brennanb2025 Sep 28, 2026
6c1d0b1
test(native-chat): measure only the replace's WAL in the block-key case
brennanb2025 Sep 29, 2026
e7b2241
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-jou…
brennanb2025 Sep 29, 2026
6bef62f
test(native-chat): the RPC fixture's status journal can save its list…
brennanb2025 Sep 29, 2026
8a05f93
test(native-chat): state why the RPC fixture's status journal cast is…
brennanb2025 Sep 29, 2026
76cdb3b
fix(native-chat): refuse a per-chat copy whose rows differ from the f…
brennanb2025 Sep 29, 2026
08ea4f6
fix(bench): build the replay benchmark's baseline arm from the base t…
brennanb2025 Sep 29, 2026
e2e6001
fix(native-chat): retry a failed listing status save on the next read…
brennanb2025 Sep 29, 2026
3cbf141
refactor(native-chat): drop the chat journal owner lock; the process …
brennanb2025 Sep 29, 2026
3a35f85
fix(startup): dev desktops take the single-instance lock, and a secon…
brennanb2025 Sep 29, 2026
94182a8
refactor(native-chat): key journal rows by chat, epoch and sequence
brennanb2025 Sep 29, 2026
297fad4
fix(native-chat): open a chat journal a newer Orca wrote read-only in…
brennanb2025 Sep 29, 2026
f0c4657
refactor(native-chat): leave the saved listing status to the change t…
brennanb2025 Sep 29, 2026
a6a514f
Merge origin/main into brennanb2025/chat-journal-appwide-db
brennanb2025 Sep 29, 2026
c6e7f63
fix(native-chat): a chat saved by a newer Orca says to update Orca, n…
brennanb2025 Sep 29, 2026
34add2b
fix(native-chat): a chat journal from an unreleased build reads as un…
brennanb2025 Sep 29, 2026
5d84fe5
docs(native-chat): drop the second-Orca-owns-the-chats case from thre…
brennanb2025 Sep 29, 2026
bcd99f0
docs(native-chat): correct three chat-journal comments the redesign l…
brennanb2025 Sep 29, 2026
9d7d51b
refactor(native-chat): stop exporting chat-journal names nothing imports
brennanb2025 Sep 29, 2026
8c9d705
test(native-chat): name the version-0 test for what it covers, and ch…
brennanb2025 Sep 29, 2026
4b78201
fix(startup): a second dev launch's exit line no longer claims it foc…
brennanb2025 Sep 29, 2026
5ca5372
fix(native-chat): a failed structured-chat install closes the journal…
brennanb2025 Sep 29, 2026
e3f6c16
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-jou…
brennanb2025 Sep 29, 2026
c790491
Merge origin/main into brennanb2025/chat-journal-appwide-db
brennanb2025 Sep 29, 2026
8c0bd34
Merge remote-tracking branch 'origin/main' into brennanb2025/chat-jou…
brennanb2025 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 96 additions & 60 deletions config/scripts/journal-replay-retention-benchmark.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,75 +2,86 @@
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { basename, dirname, join, resolve } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { build } from 'esbuild'

// Pass a directory containing journal-open.ts and journal-row-table.ts from the base commit.
// Pass the root of a checkout (or `git archive <base> src` extract) of the base commit: each arm is
// bundled whole from its own tree and writes its own fixture, since the two store history differently.
const baselineDir = process.argv[2]
assert.ok(
baselineDir,
'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_DIR'
'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_CHECKOUT_ROOT'
)
assert.ok(global.gc, 'Run with --expose-gc to measure live backing memory during replay')
const root = fileURLToPath(new URL('../..', import.meta.url))
const journalSource = './src/main/native-chat/agent-session-journal'
const entries = {
baseline: `export {openAgentSessionJournal} from '${journalSource}/journal-store-factory'; export {replayJournal} from '${journalSource}/journal-open'; export {openJournalDatabase} from '${journalSource}/journal-database'; export {journalDatabaseFile} from '${journalSource}/journal-paths';`,
current: `export {openAgentSessionJournal} from '${journalSource}/journal-store-factory'; export {replayJournal} from '${journalSource}/journal-open'; export {JournalHostDatabase, journalDatabasePath} from '${journalSource}/journal-host-database';`
}
const sourceRoots = { baseline: resolve(baselineDir), current: root }
const identity = {
sessionId: 'benchmark',
workspaceId: 'fixture',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread' }
}
const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-'))
try {
const implementations = {}
for (const arm of ['baseline', 'current']) {
const outfile = join(fixture, `${arm}.cjs`)
await build({
stdin: {
contents:
"export {openAgentSessionJournal} from './src/main/native-chat/agent-session-journal/journal-store-factory'; export {loadJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {journalDatabaseFile} from './src/main/native-chat/agent-session-journal/journal-paths';",
resolveDir: root
},
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
plugins: [
{
name: 'replay-memory-probe',
setup(plugin) {
plugin.onLoad(
{ filter: /journal-(?:open|row-table|reducer)\.ts$/ },
async ({ path }) => {
const leaf = basename(path)
let source = await readFile(
arm === 'baseline' && leaf !== 'journal-reducer.ts'
? join(baselineDir, leaf)
: path,
'utf8'
)
if (leaf === 'journal-reducer.ts') {
const marker =
'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {'
assert.ok(source.includes(marker))
source = source.replace(
marker,
`${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);`
)
}
return { contents: source, loader: 'ts', resolveDir: dirname(path) }
}
)
}
// Released in `finally`, newest first: an open SQLite handle blocks the fixture's removal on Windows.
const releases = []

async function bundle(arm) {
const outfile = join(fixture, `${arm}.cjs`)
await build({
stdin: { contents: entries[arm], resolveDir: sourceRoots[arm] },
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
// A bare base checkout has no node_modules of its own.
nodePaths: [join(root, 'node_modules')],
plugins: [
{
name: 'replay-memory-probe',
setup(plugin) {
plugin.onLoad({ filter: /journal-reducer\.ts$/ }, async ({ path }) => {
const marker =
'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {'
const source = await readFile(path, 'utf8')
assert.ok(source.includes(marker), `${basename(path)} lost the probe marker`)
return {
contents: source.replace(
marker,
`${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);`
),
loader: 'ts',
resolveDir: dirname(path)
}
})
}
]
})
implementations[arm] = createRequire(import.meta.url)(outfile)
}
const identity = {
sessionId: 'benchmark',
workspaceId: 'fixture',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread' }
}
]
})
return createRequire(import.meta.url)(outfile)
}

/** One long-revised item, written by the arm's own store; returns the arm's replay of it. */
async function openArm(arm) {
const implementation = await bundle(arm)
const stateDirectory = join(fixture, arm)
let journal
let database
if (arm === 'baseline') {
journal = await implementation.openAgentSessionJournal({ identity, journalDir: stateDirectory })
} else {
database = implementation.JournalHostDatabase.open(stateDirectory)
releases.push(() => database.close())
journal = await implementation.openAgentSessionJournal({ identity, database })
}
const journalDir = join(fixture, 'session')
const journal = await implementations.current.openAgentSessionJournal({ identity, journalDir })
releases.push(() => journal.close())
const item = { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 }
const text = 'x'.repeat(32768)
for (let revision = 0; revision < 2000; revision++) {
Expand All @@ -84,11 +95,29 @@ try {
{ fence: 1 }
)
}
await journal.close()
await releases.pop()()
if (arm === 'current') {
return {
path: implementation.journalDatabasePath(stateDirectory),
replay: () => implementation.replayJournal(database.db, identity.sessionId)
}
}
const path = implementation.journalDatabaseFile(stateDirectory)
const opened = implementation.openJournalDatabase(path)
releases.push(() => opened.db.close())
return {
path,
// The base replay takes the connection's read-only flag before the chat.
replay: () => implementation.replayJournal(opened.db, opened.readOnly, identity.sessionId)
}
}

try {
const arms = { baseline: await openArm('baseline'), current: await openArm('current') }
for (const arm of ['baseline', 'current', 'current', 'baseline']) {
global.gc()
const start = performance.now()
let loaded = implementations[arm].loadJournal(journalDir, identity.sessionId)
let loaded = arms[arm].replay()
const ms = performance.now() - start
assert.equal(loaded.state.items.size, 1)
assert.equal([...loaded.state.items.values()][0].revision, 2000)
Expand All @@ -103,20 +132,27 @@ try {
global.gc()
peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed)
}
loaded = implementations[arm].loadJournal(journalDir, identity.sessionId)
loaded = arms[arm].replay()
delete globalThis.__replayMemoryProbe
assert.equal(loaded.state.items.size, 1)
loaded = null
console.log(
JSON.stringify({
arm,
ms,
databaseBytes: (await stat(implementations[arm].journalDatabaseFile(journalDir))).size,
databaseBytes: (await stat(arms[arm].path)).size,
peakLiveHeapDelta: peakLiveHeap - initialHeap
})
)
}
} finally {
delete globalThis.__replayMemoryProbe
for (const release of releases.toReversed()) {
try {
await release()
} catch (error) {
console.error('[journal-replay-retention-benchmark] release failed', error)
}
}
await rm(fixture, { recursive: true, force: true })
}
40 changes: 36 additions & 4 deletions mobile/src/session/mobile-structured-agent-session-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
agentSessionWriteNoticeParts
} from '../../../src/shared/agent-session-refusal-notice'
import {
agentSessionRpcErrorFailure,
agentSessionRefusalFailure,
agentSessionThrownFailure,
agentSessionWriteKindForMethod,
readAgentSessionErrorRefusal,
type AgentSessionWriteKind
} from '../../../src/shared/agent-session-write-failure'
import { structuredSessionOperationId } from './structured-session-operation-id'
Expand Down Expand Up @@ -51,12 +53,36 @@ export type StructuredAgentSessionMutate = <TValue>(
class AgentSessionRpcResponseError extends Error {
constructor(
readonly code: string,
message: string
message: string,
/** A thrown refusal's reason rides here; its message is only the bare code. */
readonly data?: unknown
) {
super(message)
}
}

/** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error
* frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come
* from the refusal in the error's data. */
export function agentSessionReadFailureText(failure: unknown): string {
const refusal = readAgentSessionErrorRefusal(
typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure
)
if (refusal) {
return agentSessionWriteNoticeEnglish(
agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), 'read-history')
)
}
if (failure instanceof Error) {
return failure.message
}
return typeof failure === 'object' && failure !== null
? 'message' in failure
? String(failure.message ?? '')
: ''
: String(failure)
}

export async function callAgentSession<TResult>(
client: RpcClient,
method: string,
Expand All @@ -70,7 +96,11 @@ export async function callAgentSession<TResult>(
...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {})
})
if (!response.ok) {
throw new AgentSessionRpcResponseError(response.error.code, response.error.message)
throw new AgentSessionRpcResponseError(
response.error.code,
response.error.message,
response.error.data
)
}
return response.result as TResult
}
Expand Down Expand Up @@ -186,7 +216,9 @@ export async function requestStructuredAgentSessionMutation<TValue>(args: {
}
} catch (error) {
const answered =
error instanceof AgentSessionRpcResponseError ? agentSessionRpcErrorFailure(error.code) : null
error instanceof AgentSessionRpcResponseError
? agentSessionThrownFailure(error, error.code)
: null
if (answered && answered.kind !== 'unconfirmed') {
// The host turned the request away before running it; its text is written for a log.
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
agentSessionReadFailureText,
callAgentSession,
requestStructuredAgentSessionMutation
} from './mobile-structured-agent-session-rpc'

// As `mapRuntimeError` sends a thrown refusal (pinned in `src/main/runtime/rpc/errors.test.ts`):
// its message is the bare code, and its reason rides in data.
const THROWN_JOURNAL_REFUSAL = {
code: 'runtime_error',
message: 'agent_session_journal_unreadable',
data: {
refusal: {
code: 'agent_session_journal_unreadable',
details: { reason: 'journalCorrupt' }
}
}
}

function refusingClient(): RpcClient {
const sendRequest = async () => ({ id: 'req-1', ok: false, error: THROWN_JOURNAL_REFUSAL })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the paths under test reach only `sendRequest`.
return { sendRequest } as unknown as RpcClient
}

describe('a refusal the host threw', () => {
it("reads a chat's history failure in the refusal's words, from a stream or a request", async () => {
const words = 'Unable to load this chat.'
// The stream's error frame, as the RPC client hands it over.
expect(
agentSessionReadFailureText({
type: 'error',
message: THROWN_JOURNAL_REFUSAL.message,
error: THROWN_JOURNAL_REFUSAL
})
).toBe(words)
const thrown = await callAgentSession(refusingClient(), 'agentSession.history', {}).catch(
(error: unknown) => error
)
expect(agentSessionReadFailureText(thrown)).toBe(words)
expect(agentSessionReadFailureText({ type: 'error', message: 'Connection interrupted' })).toBe(
'Connection interrupted'
)
})

it("fails a write with the refusal's words, not as unconfirmed", async () => {
const result = await requestStructuredAgentSessionMutation({
client: refusingClient(),
method: 'agentSession.cancel',
fingerprintMethod: 'agentSession.cancel',
sessionId: 'session-1',
expectedRuntimeFence: 3,
fields: { turnId: 'turn-1' },
clientOperationId: `1900000000000-${'c'.repeat(32)}`
})

expect(result).toEqual({
status: 'failed',
message: "Unable to load this chat. The agent wasn't stopped."
})
})
})

describe('a write refused on a journal a newer Orca wrote', () => {
// As the host answers it (pinned in `journal-open-failure.test.ts`).
const newerOrcaClient = (): RpcClient => {
const sendRequest = async () => ({
id: 'req-1',
ok: true,
result: {
ok: false,
refusal: {
code: 'agent_session_journal_unreadable',
message: 'Chats were saved by a newer Orca. Update Orca to keep using them.',
details: { reason: 'journalWrittenByNewerOrca' }
}
}
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the paths under test reach only `sendRequest`.
return { sendRequest } as unknown as RpcClient
}

it.each([
[
'agentSession.send',
{ body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } },
'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.'
],
[
'agentSession.cancel',
{ turnId: 'turn-1' },
"Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them."
]
] as const)('says to update Orca for %s', async (method, fields, message) => {
const result = await requestStructuredAgentSessionMutation({
client: newerOrcaClient(),
method,
fingerprintMethod: method,
sessionId: 'session-1',
expectedRuntimeFence: 3,
fields,
clientOperationId: `1900000000000-${'d'.repeat(32)}`
})

expect(result).toEqual({
status: 'refused',
code: 'agent_session_journal_unreadable',
message
})
})
})
Loading
Loading