From d01d0386bd0dbc210bc7d32ee3bcf9f9d58b1aeb Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 14:43:49 -0700 Subject: [PATCH] fix(native-chat): keep the notice out of a repaired journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass, on the first pass's own fix. Re-offering the notice when the epoch holds nothing reads `loaded.state`, which is the PRE-repair load — so a journal this same open just emptied looks identical to one that lost its append. The repair's `unreconcilable_prefix` epoch is the durable marker that history was deleted and never rebuilt, and `awaitsRebuild` retires it the moment any row that is not the repair's own disclosure appears. Appending here therefore stopped the session ever asking the provider for that history again, while the journal still held none — and handed the user two contradictory explanations in one transcript. Reachability was not limited to the affected users: every session predating #18652 has a `log.jsonl` beside it and nothing deletes it, so any later corruption in any of those directories hit this. Guarded on `!loaded.corrupt`, which preserves the crash-window case exactly — a committed `session_created` epoch whose append was lost loads `corrupt: false`. The regression test needed the right fixture to bite: a malformed row costs a repair disclosure, which fills `items` and hides the branch. Deleting the epoch anchor leaves everything unanchored, so the repair publishes an empty epoch and appends nothing — the one state where this branch and a repair meet. Verified red without the guard. Also from the pass: the status text now goes through `boundJournalStatusText` like every other status body, and a latched (newer-schema) journal is pinned to write nothing, since it loads empty and reaches the same branch where an append would throw and make the session unopenable rather than read-only. --- .../journal-file-format-remnant.test.ts | 55 +++++++++++++++++++ .../journal-file-format-remnant.ts | 11 ++-- .../journal-store-open.ts | 8 ++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index b4e853fc8e3..57da9fa262f 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -8,10 +8,15 @@ 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 Database from '../../sqlite/sync-database' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' +import { openJournalDatabase } from './journal-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' +import { loadJournal } from './journal-open' +import { journalDatabaseFile } from './journal-paths' import type { AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener } from './journal-store-test-open' @@ -107,6 +112,56 @@ describe('a chat whose history is still in the pre-SQLite format', () => { expect(disclosure(reopened)).toContain(join(root, 'log.jsonl')) }) + // A repair's epoch is the marker that history was deleted and never rebuilt, + // and any row that is not the repair's own disclosure retires it. Appending + // here would silently stop the session ever asking the provider for that + // history — with the journal still holding none. + it('stays out of a journal this open just repaired', async () => { + const journal = await open() + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, + { fence: 1 } + ) + await journal.close() + // Deleting the anchor leaves every row unanchored: replay keeps nothing, so + // the repair publishes an empty `unreconcilable_prefix` epoch and — costing + // no malformed row — appends no disclosure of its own. That is the one state + // where this branch and a repair meet. + const opened = openJournalDatabase(journalDatabaseFile(root)) + try { + opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + } finally { + opened.db.close() + } + await writeRemnant() + + const repaired = await open() + + expect(disclosure(repaired)).toBeNull() + // Still asking the provider for the history the repair dropped. + expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + }) + + // A latched journal loads empty, so it reaches the same branch — and an append + // into one throws, which would make the session unopenable rather than read-only. + it('writes nothing into a journal latched by a newer schema', async () => { + const founded = await open() + await founded.close() + const db = new Database(journalDatabaseFile(root)) + try { + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + } finally { + db.close() + } + await writeRemnant() + + const latched = await open() + + expect(latched.isReadOnly).toBe(true) + expect(disclosure(latched)).toBeNull() + }) + // A row nothing projects is a row nobody reads. it('renders in the transcript as a system line', async () => { await writeRemnant() diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts index 7c13c3b458a..a2d24800c62 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts @@ -11,6 +11,7 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { boundJournalStatusText } from './journal-prompt-body-bounds' import { formatAgentTypeLabel } from '../../../shared/agent-type-label' import type { AgentType } from '../../../shared/agent-status-types' @@ -48,12 +49,12 @@ export function journalFileFormatRemnantDisclosure(input: { identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY, body: { kind: 'status', - text: + text: boundJournalStatusText( `This chat's history was saved in an older format Orca no longer reads, so it starts ` + - `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + - `conversation — send a ` + - `message to pick up where you left off. The original transcript is on the session's ` + - `host at ${input.transcriptPath}` + `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + + `conversation — send a message to pick up where you left off. The original ` + + `transcript is on the session's host at ${input.transcriptPath}` + ) } } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index e49f23081a1..63ed3d861ec 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -71,7 +71,13 @@ export async function openJournalStoreState(input: { // that interrupts between them — a quit during startup restore, a failed // append — would otherwise lose the message for good. An epoch holding nothing // is exactly the state that append was owed, so offer it again. - if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { + // + // Never onto a repair, though: `loaded.state` is the PRE-repair load, so a + // journal this open just emptied looks identical. The repair's epoch is the + // marker that its history was deleted and never rebuilt, and any row that is + // not the repair's own disclosure retires it — this row would silently stop + // the session ever asking the provider for that history again. + if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { await discloseFileFormatRemnant(input) } }