diff --git a/src/main/codex/codex-session-backfill-audit-pass.ts b/src/main/codex/codex-session-backfill-audit-pass.ts index 5f7e8329dc2..8958cf689b6 100644 --- a/src/main/codex/codex-session-backfill-audit-pass.ts +++ b/src/main/codex/codex-session-backfill-audit-pass.ts @@ -3,6 +3,7 @@ import { lstat } from 'node:fs/promises' import { appendCodexSessionHealAuditRecord, createCodexSessionBackfillAuditWriter, + createCodexSessionBackfillDiagnosticEventId, createCodexSessionBackfillFileEventId, readCodexSessionBackfillAuditCoverage, recordExistingCodexSessionForHeal, @@ -24,6 +25,18 @@ export type CodexSessionBackfillAuditPass = { source: string, target: string ): Promise + recordDiagnostic( + record: { + action: 'copy-unsupported' | 'failed' + source: string + target: string + error?: string + linkError?: string + errorCode?: string + linkErrorCode?: string + }, + sourceStat: Stats | null + ): Promise finish(summary: CodexSessionBackfillSummary): Promise } @@ -75,6 +88,18 @@ export async function createCodexSessionBackfillAuditPass( } } }, + async recordDiagnostic(record, sourceStat): Promise { + const diagnosticEventId = createCodexSessionBackfillDiagnosticEventId({ + ...record, + sourceStat + }) + if (coverage.diagnosticEventIds.has(diagnosticEventId)) { + return + } + if (await appendRecord({ ...record, diagnosticEventId })) { + coverage.diagnosticEventIds.add(diagnosticEventId) + } + }, async finish(summary): Promise { if (auditChanged || !coverage.hasRunSummary) { await appendRecord({ action: 'run-summary', ...summary }) diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 66fe7717130..3bc096a4d27 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -10,10 +10,12 @@ export type CodexSessionBackfillAuditWriter = (record: Record) export type CodexSessionBackfillAuditCoverage = { fileEventIds: Set + diagnosticEventIds: Set hasRunSummary: boolean } const HEAL_AUDIT_ACTIONS = new Set(['hardlink', 'copy', 'existing']) +const DIAGNOSTIC_AUDIT_ACTIONS = new Set(['copy-unsupported', 'failed']) export function createCodexSessionBackfillAuditWriter( auditLogPath: string @@ -63,6 +65,7 @@ export async function readCodexSessionBackfillAuditCoverage( ): Promise { const coverage: CodexSessionBackfillAuditCoverage = { fileEventIds: new Set(), + diagnosticEventIds: new Set(), hasRunSummary: false } const input = createReadStream(auditLogPath, { encoding: 'utf-8' }) @@ -83,6 +86,13 @@ export async function readCodexSessionBackfillAuditCoverage( ) { coverage.fileEventIds.add(record.fileEventId) } + if ( + typeof record.action === 'string' && + DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) && + typeof record.diagnosticEventId === 'string' + ) { + coverage.diagnosticEventIds.add(record.diagnosticEventId) + } } catch { // Torn audit tails are quarantined by the writer's leading newline. } @@ -107,6 +117,48 @@ export function createCodexSessionBackfillFileEventId(targetPath: string, stat: .digest('hex') } +export function createCodexSessionBackfillDiagnosticEventId(args: { + action: 'copy-unsupported' | 'failed' + source: string + target: string + sourceStat: Stats | null + errorCode?: string + linkErrorCode?: string +}): string { + const sourceIdentity = args.sourceStat + ? [ + args.sourceStat.dev, + args.sourceStat.ino, + args.sourceStat.birthtimeMs, + args.sourceStat.size, + args.sourceStat.mtimeMs, + args.sourceStat.ctimeMs + ].join('\0') + : 'unavailable' + return createHash('sha256') + .update(args.action) + .update('\0') + .update(normalizeRuntimePathForComparison(args.source)) + .update('\0') + .update(normalizeRuntimePathForComparison(args.target)) + .update('\0') + .update(sourceIdentity) + .update('\0') + .update(args.errorCode ?? '') + .update('\0') + .update(args.linkErrorCode ?? '') + .digest('hex') +} + +export function describeCodexSessionBackfillErrorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | null)?.code + return typeof code === 'string' && code + ? code + : error instanceof Error + ? error.name + : typeof error +} + export async function appendCodexSessionHealAuditRecord( writer: CodexSessionBackfillAuditWriter, summary: CodexSessionBackfillSummary, diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index b1ef73d8edd..54ced848927 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -110,7 +110,9 @@ vi.mock('node:fs/promises', async () => { // Simulate a copy that fails after opening its destination, which is // the dangerous case for resumability rather than a preflight error. await actual.writeFile(args[1], 'partial copy\n', 'utf-8') - const error = new Error('EACCES: copy disabled for test') as NodeJS.ErrnoException + const error = new Error( + `EACCES: copy disabled for test, '${String(args[1])}'` + ) as NodeJS.ErrnoException error.code = 'EACCES' throw error } @@ -173,6 +175,7 @@ type BackfillAuditRecord = { action: string target?: string fileEventId?: string + diagnosticEventId?: string } function readBackfillAuditRecords(): BackfillAuditRecord[] { @@ -636,9 +639,61 @@ describe('startCodexSessionBackfillInBackground', () => { expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) expect(existsSync(getMarkerPath())).toBe(true) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const repeated = await startCodexSessionBackfillInBackground() + + expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + expect(await startCodexSessionBackfillInBackground()).toBeNull() }) + it('keeps repeated unchanged per-file failures audit-stable', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ failedFiles: 1 }) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + + const repeated = await startCodexSessionBackfillInBackground() + + expect(repeated).toMatchObject({ failedFiles: 1 }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + }) + + it('records a new failure event when the source file changes', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + writeManagedSession(relativePath, '{"id":"a","changed":true}\n') + await startCodexSessionBackfillInBackground() + + const failedRecords = readBackfillAuditRecords().filter((record) => record.action === 'failed') + expect(failedRecords).toHaveLength(2) + expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2) + }) + + it('records a new failure event when the filesystem outcome changes', async () => { + fsMockState.failLink = true + fsMockState.failCopy = true + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + fsMockState.failCopy = false + fsMockState.failInstallLinkTransiently = true + await startCodexSessionBackfillInBackground() + + const failedRecords = readBackfillAuditRecords().filter((record) => record.action === 'failed') + expect(failedRecords).toHaveLength(2) + expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2) + }) + it('leaves the marker unset when any file fails so the next startup retries', async () => { fsMockState.failLink = true fsMockState.failCopy = true diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index 018e6da38d0..801b83370fd 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -10,6 +10,7 @@ import { readCodexSessionTargetStat, type CodexSessionBackfillAuditPass } from './codex-session-backfill-audit-pass' +import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit' import { copySessionFileWithoutOverwrite, isAtomicNoReplaceUnsupportedError @@ -300,21 +301,31 @@ async function backfillOneManagedSessionFile( } if (isAtomicNoReplaceUnsupportedError(copyError)) { summary.skippedUnsupportedFilesystemFiles += 1 - await auditPass.appendRecord({ - action: 'copy-unsupported', - source: managedSessionFilePath, - target: systemSessionFilePath - }) + await auditPass.recordDiagnostic( + { + action: 'copy-unsupported', + source: managedSessionFilePath, + target: systemSessionFilePath, + errorCode: describeCodexSessionBackfillErrorCode(copyError), + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + await readCodexSessionTargetStat(managedSessionFilePath) + ) return } summary.failedFiles += 1 - await auditPass.appendRecord({ - action: 'failed', - source: managedSessionFilePath, - target: systemSessionFilePath, - error: describeError(copyError), - linkError: describeError(linkError) - }) + await auditPass.recordDiagnostic( + { + action: 'failed', + source: managedSessionFilePath, + target: systemSessionFilePath, + error: describeError(copyError), + linkError: describeError(linkError), + errorCode: describeCodexSessionBackfillErrorCode(copyError), + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + await readCodexSessionTargetStat(managedSessionFilePath) + ) } } }