diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 3786cf99571..350fc26fd9a 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -757,6 +757,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({ diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 126196af38e..23eb755c1da 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -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: { @@ -142,8 +149,22 @@ export async function importLegacyTranscriptIntoJournal(input: { 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 the quarantined rows with nothing asking for them + // 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 = { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index d5aa310d20b..3b20689bc27 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -373,6 +373,64 @@ describe('openAgentSessionJournalWithRecovery', () => { }) }) + // An empty transcript is a plausible transient provider state, and it used to + // end recovery for good: the import published an empty replacement epoch that + // deleted the repair's anchor and disclosure, the next probe called that clean, + // and the user's rows stayed in quarantine with nothing asking for them again. + it('does not retire the repair marker when provider history exists but holds no messages', async () => { + await seedRepairableSession() + const empty = join(root, 'empty.jsonl') + await writeFile(empty, '', 'utf-8') + + const first = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath: empty + }) + journals.track(first.journal) + expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) + expect(first.recovery?.error).toBeTruthy() + // The anchor the repair published, and its disclosure, are still the epoch. + expect(first.journal.snapshot().items.map((entry) => entry.body.kind)).toEqual(['status']) + expect( + first.journal + .snapshot() + .items.some( + (entry) => entry.body.kind === 'status' && entry.body.text.includes('set aside') + ) + ).toBe(true) + const epoch = first.journal.epoch + await first.journal.close() + await withJournalDatabase(journalDir, (db) => { + const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) + expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ + kind: 'epoch', + seq: 1, + reason: 'unreconcilable_prefix' + }) + }) + + // The session still reports corrupt, so the next attach retries. + expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + + // And a transcript that DOES have content still rebuilds the timeline. + const retried = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + journals.track(retried.journal) + expect(retried.recovery?.imported).toBeGreaterThan(0) + expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain( + 'add a retry' + ) + expect( + retried.journal.recoverQuarantinedRows().map((row) => JSON.parse(row.rowJson).kind) + ).toEqual(['submission', 'dispatch']) + }) + it('rebuilds the emptied epoch once provider history is readable again', async () => { await seedRepairableSession() diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 9496d0d6da3..282600f6b02 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -26,7 +26,8 @@ export type AgentSessionJournalRecovery = { reset: AgentJournalResetReason epoch: string imported: number - /** Set when provider history could not be read; the intact journal prefix remains live. */ + /** Set when provider history could not be read, or held nothing to restore; the + * intact journal prefix remains live. */ error?: string } @@ -113,13 +114,16 @@ async function rehydrate(input: { fence: input.fence, ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {}) }) - if (!result.ok) { + // A transcript that held nothing is the same outcome as one that could not be + // read: nothing was restored, so the repair's marker has to stand and be + // retried on a later attach rather than being retired as a completed recovery. + if (!result.ok || !result.replaced) { return { trigger: input.trigger, reset, epoch: input.journal.epoch, imported: 0, - error: result.error + error: result.ok ? 'Provider history held no messages to restore' : result.error } } return {