fix(agent-session-journal): stop an empty provider transcript retiring the repair marker

A transcript that exists but decodes to zero messages was imported as a
success: the import published an empty `legacy_import` replacement that
deleted the `unreconcilable_prefix` anchor and its disclosure, so the next
probe read the session as clean and every later attach skipped provider
recovery while the user's rows sat in quarantine for good.

The import now leaves the epoch untouched when nothing decodes, reporting
`replaced: false`, and recovery treats that like a transcript it could not
read — the marker stands and a later attach with real history rebuilds the
timeline.
This commit is contained in:
Merge Sim
2026-09-04 08:28:20 -07:00
parent a35daa40e6
commit ba55993dab
4 changed files with 121 additions and 5 deletions
@@ -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({
@@ -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 = {
@@ -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()
@@ -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 {