From 38275c2aa2d4fa2bb45938f63f3bfb4e8948268b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:53:41 -0700 Subject: [PATCH] fix(codex): publish Windows system-default sessions (#12611) * fix(codex): publish Windows system-default sessions * fix(codex): close launch-scheduling races in session migration scheduler * fix(codex): bound repeated session migration audits * fix(codex): preserve delayed session publication passes * fix(codex): bound failed session audit events * fix(codex): fence stale session migration markers * chore: preserve main formatting after merge * perf(codex): bound launch session migration scans * perf(codex): preserve coalesced migration scope * fix(codex): preserve scheduled migration recovery * fix(codex): preserve session migration recovery * fix(codex): close session migration launch races * fix(codex): harden session migration completion --- .../runtime-home-service.test.ts | 71 ++- .../codex-accounts/runtime-home-service.ts | 81 +++- ...ime-home-windows-profile-ownership.test.ts | 1 + .../codex-session-backfill-audit-pass.ts | 118 +++++ .../codex/codex-session-backfill-audit.ts | 137 +++++- src/main/codex/codex-session-backfill-date.ts | 65 +++ .../codex/codex-session-backfill-marker.ts | 39 +- .../codex/codex-session-backfill-types.ts | 12 + src/main/codex/codex-session-backfill.test.ts | 428 +++++++++++++++-- src/main/codex/codex-session-backfill.ts | 178 +++---- .../codex/codex-session-index-heal-state.ts | 28 +- .../codex/codex-session-index-heal.test.ts | 31 +- .../codex-session-migration-scheduler.test.ts | 450 +++++++++++++++++- .../codex-session-migration-scheduler.ts | 252 +++++++++- src/main/index.ts | 48 +- src/main/ipc/pty.test.ts | 86 +++- src/main/ipc/pty.ts | 71 ++- .../window/attach-main-window-services.ts | 7 +- src/types/psl.ts | 17 + 19 files changed, 1890 insertions(+), 230 deletions(-) create mode 100644 src/main/codex/codex-session-backfill-audit-pass.ts create mode 100644 src/main/codex/codex-session-backfill-date.ts create mode 100644 src/types/psl.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3a52fa8..e07496b9e 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1004,10 +1004,62 @@ describe('CodexRuntimeHomeService', () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - // Why: a mirror launch never leaves the real home, so its backfill stays valid. - expect(existsSync(markerPath)).toBe(true) + expect(existsSync(markerPath)).toBe(false) + service.finishHostSystemDefaultSessionMigrationPass() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) + service.finishHostSystemDefaultSessionMigrationPass() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + service.prepareForCodexLaunch() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + expect(existsSync(markerPath)).toBe(false) + service.prepareForCodexLaunch() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null)).toBeNull() + service.finishHostSystemDefaultSessionMigrationPass() + writeFileSync( + markerPath, + `${JSON.stringify({ + version: 3, + systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'), + summary: { scannedFiles: 1 } + })}\n`, + 'utf-8' + ) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe( + false + ) + expect(existsSync(markerPath)).toBe(false) + store.updateSettings({ + codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} } + }) + service.prepareForCodexLaunch() + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) @@ -1037,6 +1089,10 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(markerPath, '{}\n', 'utf-8') expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(existsSync(markerPath)).toBe(false) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) + service.finishHostSystemDefaultSessionMigrationPass() service.setRealHomeLaneGate(() => true) const perSpawnCustomHome = join(testState.fakeHomeDir, 'per-spawn-custom-codex-home') writeFileSync(markerPath, '{}\n', 'utf-8') @@ -1045,6 +1101,11 @@ describe('CodexRuntimeHomeService', () => { getRuntimeCodexHomePath() ) expect(existsSync(markerPath)).toBe(true) + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath(), { + launchEnv: { CODEX_HOME: perSpawnCustomHome } + }) + ).toBeNull() if (process.platform !== 'win32') { // Why: shell startup CODEX_HOME discovery is a POSIX-shell lane; Windows // must not invoke an ambient WSL bash while evaluating this contract. @@ -1719,7 +1780,11 @@ describe('CodexRuntimeHomeService', () => { // A host managed account's own home is its CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath()) + ).toBeNull() // The per-account home keeps its own auth in place; the shared mirror's // auth.json is never hot-swapped, so two accounts cannot race one file. expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe( diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index c2b99b52c..68d054f35 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -41,7 +41,6 @@ import { import { getOrcaManagedCodexHomePath, getOrcaUserDataPath, - getCodexSessionBackfillStateDirPath, getSystemCodexHomePath, resolveOrcaManagedCodexHomePath, syncCodexGlobalInstructionsIntoManagedHome, @@ -68,7 +67,11 @@ import { } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { + hasCompletedCodexSessionBackfillMarker, + invalidateCodexSessionBackfillMarker +} from '../codex/codex-session-backfill-marker' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' import { codexAuthCouldBelongToManagedAccount, @@ -181,6 +184,9 @@ export class CodexRuntimeHomeService { private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + private hostSystemDefaultSessionMigrationPending = false + private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + private pendingHostSystemDefaultSessionMigrationTarget: string | null = null constructor(private readonly store: Store) { this.safeRecoverInterruptedRuntimeAuthOperation() @@ -253,6 +259,53 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass(): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + } + invalidateCodexSessionBackfillMarker(paths.markerPath) + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } + // Why: a managed HOST account runs against its own self-contained CODEX_HOME // (codex-accounts//home) rather than the shared runtime mirror. Its // auth.json lives there and codex refreshes it in place, so two accounts never @@ -404,18 +457,26 @@ export class CodexRuntimeHomeService { this.lastHostAccountUsedSelfContainedHome = false } - private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { + private invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== null) { - return + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null } - // Why: reached only when the real-home lane is selected but its gate is off, - // so the launch runs on the mirror and the backfill marker is stale. - if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) { - invalidateCodexSessionBackfillMarker( - join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot + this.hostSystemDefaultSessionMigrationPending = true } + return this.prepareHostSystemDefaultSessionMigrationPass() } private startWslSessionBridgeForLaunch( diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index 54ab5d5f0..337aaaf53 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -29,6 +29,7 @@ describe('Windows System Default Codex home ownership', () => { SHELL: 'powershell.exe' }) ).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true) }) }) diff --git a/src/main/codex/codex-session-backfill-audit-pass.ts b/src/main/codex/codex-session-backfill-audit-pass.ts new file mode 100644 index 000000000..8958cf689 --- /dev/null +++ b/src/main/codex/codex-session-backfill-audit-pass.ts @@ -0,0 +1,118 @@ +import type { Stats } from 'node:fs' +import { lstat } from 'node:fs/promises' +import { + appendCodexSessionHealAuditRecord, + createCodexSessionBackfillAuditWriter, + createCodexSessionBackfillDiagnosticEventId, + createCodexSessionBackfillFileEventId, + readCodexSessionBackfillAuditCoverage, + recordExistingCodexSessionForHeal, + type CodexSessionBackfillAuditWriter +} from './codex-session-backfill-audit' +import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' + +export type CodexSessionBackfillAuditPass = { + appendRecord: CodexSessionBackfillAuditWriter + recordExisting( + summary: CodexSessionBackfillSummary, + source: string, + target: string, + targetStat: Stats | null + ): Promise + recordPublished( + summary: CodexSessionBackfillSummary, + action: 'hardlink' | 'copy', + 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 +} + +export async function createCodexSessionBackfillAuditPass( + auditLogPath: string +): Promise { + const coverage = await readCodexSessionBackfillAuditCoverage(auditLogPath) + const writeAuditRecord = createCodexSessionBackfillAuditWriter(auditLogPath) + let auditChanged = false + const appendRecord: CodexSessionBackfillAuditWriter = async (record) => { + const appended = await writeAuditRecord(record) + auditChanged ||= appended + return appended + } + + return { + appendRecord, + async recordExisting(summary, source, target, targetStat): Promise { + const fileEventId = targetStat + ? createCodexSessionBackfillFileEventId(target, targetStat) + : undefined + if (fileEventId && coverage.fileEventIds.has(fileEventId)) { + summary.skippedExistingFiles += 1 + return + } + if ( + await recordExistingCodexSessionForHeal(appendRecord, summary, source, target, fileEventId) + ) { + if (fileEventId) { + coverage.fileEventIds.add(fileEventId) + } + } + }, + async recordPublished(summary, action, source, target): Promise { + const targetStat = await readCodexSessionTargetStat(target) + const fileEventId = targetStat + ? createCodexSessionBackfillFileEventId(target, targetStat) + : undefined + if ( + await appendCodexSessionHealAuditRecord(appendRecord, summary, { + action, + source, + target, + ...(fileEventId ? { fileEventId } : {}) + }) + ) { + if (fileEventId) { + coverage.fileEventIds.add(fileEventId) + } + } + }, + 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 }) + } + } + } +} + +/** A broken symlink at the target still counts as taken. */ +export async function readCodexSessionTargetStat(entryPath: string): Promise { + try { + return await lstat(entryPath) + } catch { + return null + } +} diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index c4740b955..1bec59382 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -1,10 +1,22 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream, type Stats } from 'node:fs' import { appendFile, mkdir } from 'node:fs/promises' import { dirname } from 'node:path' +import { createInterface } from 'node:readline' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' export type CodexSessionBackfillAuditWriter = (record: Record) => Promise +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 ): CodexSessionBackfillAuditWriter { @@ -48,28 +60,139 @@ export function createCodexSessionBackfillAuditWriter( } } +export async function readCodexSessionBackfillAuditCoverage( + auditLogPath: string +): Promise { + const coverage: CodexSessionBackfillAuditCoverage = { + fileEventIds: new Set(), + diagnosticEventIds: new Set(), + hasRunSummary: false + } + const input = createReadStream(auditLogPath, { encoding: 'utf-8' }) + const lines = createInterface({ input, crlfDelay: Infinity }) + try { + for await (const raw of lines) { + try { + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue + } + const record = parsed as Record + coverage.hasRunSummary ||= record.action === 'run-summary' + if ( + typeof record.action === 'string' && + HEAL_AUDIT_ACTIONS.has(record.action) && + typeof record.fileEventId === 'string' + ) { + 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. + } + } + } catch (error) { + if (!isNotFoundError(error)) { + throw error + } + } + return coverage +} + +export function createCodexSessionBackfillFileEventId(targetPath: string, stat: Stats): string { + const fileIdentity = [ + stat.dev, + stat.ino, + stat.birthtimeMs, + stat.size, + stat.mtimeMs, + stat.ctimeMs + ].join('\0') + return createHash('sha256') + .update(normalizeRuntimePathForComparison(targetPath)) + .update('\0') + .update(fileIdentity) + .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, record: Record -): Promise { - if (!(await writer(record))) { +): Promise { + const appended = await writer(record) + if (!appended) { summary.failedHealAuditRecords += 1 } + return appended } export async function recordExistingCodexSessionForHeal( writer: CodexSessionBackfillAuditWriter, summary: CodexSessionBackfillSummary, source: string, - target: string -): Promise { + target: string, + fileEventId?: string +): Promise { summary.skippedExistingFiles += 1 // Why: this also recovers a rollout installed before a crash or audit // failure; thread/read is idempotent for a pre-existing real-home file. - await appendCodexSessionHealAuditRecord(writer, summary, { + return appendCodexSessionHealAuditRecord(writer, summary, { action: 'existing', source, - target + target, + ...(fileEventId ? { fileEventId } : {}) }) } + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-backfill-date.ts b/src/main/codex/codex-session-backfill-date.ts new file mode 100644 index 000000000..8c2157d9d --- /dev/null +++ b/src/main/codex/codex-session-backfill-date.ts @@ -0,0 +1,65 @@ +import { join, relative, sep } from 'node:path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillOptions +} from './codex-session-backfill-types' + +export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate { + return [ + String(date.getUTCFullYear()).padStart(4, '0'), + String(date.getUTCMonth() + 1).padStart(2, '0'), + String(date.getUTCDate()).padStart(2, '0') + ] +} + +export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean { + const pathParts = relative(sessionsRoot, filePath).split(sep) + if (pathParts.length !== 4) { + return false + } + const [year, month, day, fileName] = pathParts + return ( + /^\d{4}$/.test(year) && + /^\d{2}$/.test(month) && + /^\d{2}$/.test(day) && + /^rollout-.+\.jsonl$/.test(fileName) + ) +} + +export async function* listCodexSessionBackfillFilesForDates( + sessionsRoot: string, + options: CodexSessionBackfillOptions, + onDirectoryError: (directoryPath: string, error: unknown) => void | Promise +): AsyncGenerator { + const scanRoots = resolveCodexSessionBackfillDateRoots(sessionsRoot, options.scanDates) + for (const scanRoot of scanRoots) { + yield* listCodexSessionJsonlFilesIncrementally( + scanRoot, + options, + async (directoryPath, error) => { + if (directoryPath !== scanRoot || !isNotFoundError(error)) { + await onDirectoryError(directoryPath, error) + } + } + ) + } +} + +function resolveCodexSessionBackfillDateRoots( + sessionsRoot: string, + scanDates: readonly CodexSessionBackfillDate[] | undefined +): string[] { + if (!scanDates?.length) { + return [sessionsRoot] + } + return scanDates + .filter( + ([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day) + ) + .map(([year, month, day]) => join(sessionsRoot, year, month, day)) +} + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' +} diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index d3d5a07e5..8c2c9fb9c 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -6,6 +6,11 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types // Why: bump to re-run the backfill for every host after a layout or semantics // change; the run itself stays skip-existing so re-runs never overwrite. const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3 +let markerInvalidationGeneration = 0 + +export function captureCodexSessionBackfillMarkerGeneration(): number { + return markerInvalidationGeneration +} export function hasCompletedCodexSessionBackfillMarker( markerPath: string, @@ -16,13 +21,22 @@ export function hasCompletedCodexSessionBackfillMarker( if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return false } - const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown } + const marker = parsed as { + version?: unknown + systemSessionsRoot?: unknown + summary?: { scannedFiles?: unknown } + } // Why: changing the configured real Codex home must backfill the new // target instead of honoring a marker written for a different history. - return ( + const markerMatchesTarget = marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION && marker.systemSessionsRoot === systemSessionsRoot - ) + if (!markerMatchesTarget) { + return false + } + // Why: an empty source can become populated after an early migration run; + // let the incremental async walk verify it without blocking the main thread. + return marker.summary?.scannedFiles !== 0 } catch { return false } @@ -31,8 +45,13 @@ export function hasCompletedCodexSessionBackfillMarker( export function writeCodexSessionBackfillMarker( markerPath: string, systemSessionsRoot: string, - summary: CodexSessionBackfillSummary + summary: CodexSessionBackfillSummary, + expectedGeneration: number ): void { + // Why: a launch can invalidate this pass before its delayed replacement begins. + if (expectedGeneration !== markerInvalidationGeneration) { + return + } mkdirSync(dirname(markerPath), { recursive: true }) writeFileAtomically( markerPath, @@ -50,11 +69,23 @@ export function writeCodexSessionBackfillMarker( } export function invalidateCodexSessionBackfillMarker(markerPath: string): void { + markerInvalidationGeneration += 1 try { // Why: a managed-lane system-default launch can create new source // rollouts, so a prior one-time marker must not suppress the next opt-in. rmSync(markerPath, { force: true }) } catch (error) { console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error) + try { + writeFileAtomically( + markerPath, + `${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n` + ) + } catch (fallbackError) { + throw new AggregateError( + [error, fallbackError], + 'Failed to invalidate Codex session backfill marker' + ) + } } } diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts index d3f25b37b..92891beaa 100644 --- a/src/main/codex/codex-session-backfill-types.ts +++ b/src/main/codex/codex-session-backfill-types.ts @@ -24,4 +24,16 @@ export type CodexSessionBackfillPaths = { export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & { /** Polled before each target mutation; true stops with progress preserved. */ shouldStop?: () => boolean + /** Limits a launch-triggered pass to the date directories that can contain its rollouts. */ + scanDates?: readonly CodexSessionBackfillDate[] + /** A scheduled launch pass must not be suppressed by a marker it just invalidated. */ + ignoreCompletionMarker?: boolean + /** Active launch passes defer global completion until their final exit scan. */ + writeCompletionMarker?: boolean + /** Final launch scans can extend a previously certified full-tree baseline. */ + writeBoundedCompletionMarker?: boolean + /** Rechecks launch scheduling state immediately before marker publication. */ + canWriteCompletionMarker?: () => boolean } + +export type CodexSessionBackfillDate = readonly [year: string, month: string, day: string] diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index bd5e0bca6..418ef0d83 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + appendFileSync, existsSync, lstatSync, mkdirSync, @@ -23,12 +24,14 @@ const { homedirMock } = vi.hoisted(() => ({ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, - failInstallLink: false, - failInstallLinkTransiently: false, + failLinkTransiently: false, + failLinkPermission: false, raceTargetIntoExistence: false, - failCopy: false, + failMarkerRm: false, + failMarkerReplacement: false, failAuditMkdirOnce: false, failAuditWrites: false, + failMkdirPath: null as string | null, failDirectoryPath: null as string | null, failLstatPath: null as string | null } @@ -43,6 +46,30 @@ vi.mock('node:fs', async () => { return false } return actual.existsSync(...args) + }, + rmSync: (...args: Parameters) => { + if ( + fsMockState.failMarkerRm && + String(args[0]).includes('codex-session-backfill') && + String(args[0]).endsWith('backfill-complete.json') + ) { + const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.rmSync(...args) + }, + renameSync: (...args: Parameters) => { + if ( + fsMockState.failMarkerReplacement && + String(args[1]).includes('codex-session-backfill') && + String(args[1]).endsWith('backfill-complete.json') + ) { + const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } + return actual.renameSync(...args) } } }) @@ -52,6 +79,11 @@ vi.mock('node:fs/promises', async () => { return { ...actual, mkdir: (...args: Parameters) => { + if (args[0] === fsMockState.failMkdirPath) { + const error = new Error('EACCES: target directory inaccessible') as NodeJS.ErrnoException + error.code = 'EACCES' + throw error + } if (fsMockState.failAuditMkdirOnce && String(args[0]).includes('codex-session-backfill')) { fsMockState.failAuditMkdirOnce = false const error = new Error( @@ -91,30 +123,17 @@ vi.mock('node:fs/promises', async () => { error.code = 'EXDEV' throw error } - // Simulate a target filesystem with no hardlink support: even the - // same-volume staged-copy install link (.orca-backfill-*.tmp) fails. - if (fsMockState.failInstallLink && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EPERM: hardlinks unsupported') as NodeJS.ErrnoException - error.code = 'EPERM' - throw error - } - if (fsMockState.failInstallLinkTransiently && String(args[0]).includes('.orca-backfill-')) { - const error = new Error('EIO: transient install failure') as NodeJS.ErrnoException + if (fsMockState.failLinkTransiently && String(args[0]).includes('codex-runtime-home')) { + const error = new Error('EIO: transient hardlink failure') as NodeJS.ErrnoException error.code = 'EIO' throw error } - return actual.link(...args) - }, - copyFile: async (...args: Parameters) => { - if (fsMockState.failCopy) { - // 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 + if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) { + const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException error.code = 'EACCES' throw error } - return actual.copyFile(...args) + return actual.link(...args) }, opendir: (...args: Parameters) => { if (args[0] === fsMockState.failDirectoryPath) { @@ -140,6 +159,7 @@ import { resolveCodexSessionBackfillPaths, startCodexSessionBackfillInBackground } from './codex-session-backfill' +import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker' let fakeHomeDir: string let userDataDir: string @@ -168,27 +188,40 @@ function writeManagedSession(relativePath: string, contents: string): string { return filePath } -function readAuditActions(): string[] { +type BackfillAuditRecord = { + action: string + target?: string + fileEventId?: string + diagnosticEventId?: string +} + +function readBackfillAuditRecords(): BackfillAuditRecord[] { return readFileSync(getAuditLogPath(), 'utf-8') .split('\n') .filter(Boolean) .flatMap((line) => { try { - return [(JSON.parse(line) as { action: string }).action] + return [JSON.parse(line) as BackfillAuditRecord] } catch { return [] } }) } +function readAuditActions(): string[] { + return readBackfillAuditRecords().map((record) => record.action) +} + beforeEach(() => { fsMockState.failLink = false - fsMockState.failInstallLink = false - fsMockState.failInstallLinkTransiently = false + fsMockState.failLinkTransiently = false + fsMockState.failLinkPermission = false fsMockState.raceTargetIntoExistence = false - fsMockState.failCopy = false + fsMockState.failMarkerRm = false + fsMockState.failMarkerReplacement = false fsMockState.failAuditMkdirOnce = false fsMockState.failAuditWrites = false + fsMockState.failMkdirPath = null fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) @@ -367,27 +400,22 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['hardlink', 'run-summary']) }) - it('falls back to copy when hardlinking fails across volumes', async () => { + it('skips cross-volume rollouts instead of freezing a mutable snapshot', async () => { fsMockState.failLink = true - const managedPath = writeManagedSession( - join('2026', '05', '26', 'rollout-a ü.jsonl'), - '{"id":"a"}\n' - ) + const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 1, failedFiles: 0 }) - const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a ü.jsonl') - expect(readFileSync(targetPath, 'utf-8')).toBe(readFileSync(managedPath, 'utf-8')) - expect(lstatSync(targetPath).ino).not.toBe(lstatSync(managedPath).ino) - expect(readAuditActions()).toEqual(['copy', 'run-summary']) + expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) it('fails closed when the target filesystem cannot install without overwrite', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -406,9 +434,8 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary']) }) - it('keeps transient install failures retryable', async () => { - fsMockState.failLink = true - fsMockState.failInstallLinkTransiently = true + it('keeps transient hardlink failures retryable', async () => { + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -422,9 +449,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(readAuditActions()).toEqual(['failed', 'run-summary']) }) + it('keeps target directory permission failures retryable', async () => { + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + fsMockState.failMkdirPath = dirname(join(getSystemSessionsRoot(), relativePath)) + + const summary = await startCodexSessionBackfillInBackground() + + expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(existsSync(getMarkerPath())).toBe(false) + expect(readAuditActions()).toEqual(['failed', 'run-summary']) + }) + + it('keeps hardlink permission failures retryable', async () => { + fsMockState.failLinkPermission = true + const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') + writeManagedSession(relativePath, '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground() + + expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) + expect(existsSync(getMarkerPath())).toBe(false) + }) + it('records per-file failures without aborting the run', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( @@ -446,6 +497,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => { expect(summary).toMatchObject({ scannedFiles: 0 }) expect(existsSync(getSystemSessionsRoot())).toBe(false) }) + + it('bounds a launch pass to its rollout date directories', async () => { + const oldRelativePath = join('2025', '12', '31', 'rollout-old.jsonl') + const launchRelativePath = join('2026', '08', '05', 'rollout-launch.jsonl') + writeManagedSession(oldRelativePath, 'old\n') + writeManagedSession(launchRelativePath, 'launch\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths(), + { scanDates: [['2026', '08', '05']] } + ) + + expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 1, failedDirectories: 0 }) + expect(existsSync(join(getSystemSessionsRoot(), launchRelativePath))).toBe(true) + expect(existsSync(join(getSystemSessionsRoot(), oldRelativePath))).toBe(false) + }) + + it('treats a not-yet-created launch date as an empty bounded pass', async () => { + writeManagedSession(join('2025', '12', '31', 'rollout-old.jsonl'), 'old\n') + + const summary = await backfillManagedCodexSessionsIntoSystemHome( + resolveCodexSessionBackfillPaths(), + { scanDates: [['2026', '08', '05']] } + ) + + expect(summary).toMatchObject({ scannedFiles: 0, linkedFiles: 0, failedDirectories: 0 }) + }) }) describe('startCodexSessionBackfillInBackground', () => { @@ -479,6 +557,81 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(false) }) + it('defers completion while a launch lease is active', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const active = await startCodexSessionBackfillInBackground({ + writeCompletionMarker: false + }) + expect(active).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const completed = await startCodexSessionBackfillInBackground() + expect(completed).toMatchObject({ skippedExistingFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('rechecks launch state before publishing completion', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const summary = await startCodexSessionBackfillInBackground({ + canWriteCompletionMarker: () => false + }) + + expect(summary).toMatchObject({ linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + }) + + it('keeps an invalidated active pass from recreating the completion marker', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + let invalidated = false + + const raced = await startCodexSessionBackfillInBackground({ + shouldStop: () => { + if (!invalidated) { + invalidated = true + invalidateCodexSessionBackfillMarker(getMarkerPath()) + } + return false + } + }) + + expect(raced).toMatchObject({ linkedFiles: 1, stopped: false }) + expect(existsSync(getMarkerPath())).toBe(false) + const racedAudit = readFileSync(getAuditLogPath(), 'utf-8') + + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 }) + expect(existsSync(getMarkerPath())).toBe(true) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit) + }) + + it('replaces a stale marker when direct removal fails', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + fsMockState.failMarkerRm = true + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 }) + const recovered = await startCodexSessionBackfillInBackground() + expect(recovered).toMatchObject({ skippedExistingFiles: 1 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + }) + + it('fails launch preparation when a stale marker cannot be invalidated', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + fsMockState.failMarkerRm = true + fsMockState.failMarkerReplacement = true + + expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow( + 'Failed to invalidate Codex session backfill marker' + ) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) + }) + it('writes a completion marker and skips the walk on later runs', async () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') @@ -487,13 +640,160 @@ describe('startCodexSessionBackfillInBackground', () => { expect(existsSync(getMarkerPath())).toBe(true) expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 }) - // A file appearing after the marker must not be backfilled again. + // An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker. writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n') const second = await startCodexSessionBackfillInBackground() expect(second).toBeNull() expect( existsSync(join(getSystemSessionsRoot(), '2026', '07', '01', 'rollout-later.jsonl')) ).toBe(false) + + const scheduled = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '07', '01']] + }) + expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + }) + + it('does not let a bounded pass certify older unscanned history', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl') + writeManagedSession(missedRelativePath, 'missed\n') + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const bounded = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '08', '05']] + }) + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + + const recovered = await startCodexSessionBackfillInBackground() + expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 }) + expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('lets an explicit bounded final pass restore a certified baseline', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n') + + const bounded = await startCodexSessionBackfillInBackground({ + ignoreCompletionMarker: true, + scanDates: [['2026', '08', '05']], + writeBoundedCompletionMarker: true + }) + + expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect(existsSync(getMarkerPath())).toBe(true) + }) + + it('records a new heal event when a linked rollout grows in place', async () => { + const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl') + const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink') + invalidateCodexSessionBackfillMarker(getMarkerPath()) + appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8') + await startCodexSessionBackfillInBackground() + + const fileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'existing'].includes(record.action) + ) + expect(fileRecords).toHaveLength(2) + expect(fileRecords[1]).toMatchObject({ action: 'existing' }) + expect(fileRecords[1]?.fileEventId).not.toBe(firstRecord?.fileEventId) + }) + + it('keeps repeated launch invalidations audit-stable', async () => { + writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') + + const first = await startCodexSessionBackfillInBackground() + expect(first).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 0 }) + const firstAudit = readFileSync(getAuditLogPath(), 'utf-8') + + for (let pass = 0; pass < 2; pass += 1) { + invalidateCodexSessionBackfillMarker(getMarkerPath()) + const repeated = await startCodexSessionBackfillInBackground() + expect(repeated).toMatchObject({ + linkedFiles: 0, + copiedFiles: 0, + skippedExistingFiles: 1, + failedHealAuditRecords: 0 + }) + expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit) + } + + const fileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + expect(fileRecords).toEqual([ + expect.objectContaining({ action: 'hardlink', fileEventId: expect.any(String) }) + ]) + }) + + it('recovers a post-install audit interruption without duplicating prior events', async () => { + const firstRelativePath = join('2026', '05', '26', 'rollout-a.jsonl') + const secondRelativePath = join('2026', '05', '26', 'rollout-b.jsonl') + writeManagedSession(firstRelativePath, '{"id":"a"}\n') + await startCodexSessionBackfillInBackground() + + invalidateCodexSessionBackfillMarker(getMarkerPath()) + writeManagedSession(secondRelativePath, '{"id":"b"}\n') + fsMockState.failAuditWrites = true + + const interrupted = await startCodexSessionBackfillInBackground() + + expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 }) + expect(existsSync(getMarkerPath())).toBe(false) + expect( + readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + ).toHaveLength(1) + + fsMockState.failAuditWrites = false + const recovered = await startCodexSessionBackfillInBackground() + + expect(recovered).toMatchObject({ + linkedFiles: 0, + skippedExistingFiles: 2, + failedHealAuditRecords: 0 + }) + expect(existsSync(getMarkerPath())).toBe(true) + const recoveredFileRecords = readBackfillAuditRecords().filter((record) => + ['hardlink', 'copy', 'existing'].includes(record.action) + ) + expect(recoveredFileRecords).toHaveLength(2) + expect(new Set(recoveredFileRecords.map((record) => record.target))).toEqual( + new Set([ + join(getSystemSessionsRoot(), firstRelativePath), + join(getSystemSessionsRoot(), secondRelativePath) + ]) + ) + }) + + it('self-heals a zero-file marker when managed rollouts appear later', async () => { + const empty = await startCodexSessionBackfillInBackground() + expect(empty).toMatchObject({ scannedFiles: 0, linkedFiles: 0 }) + expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ + summary: { scannedFiles: 0 } + }) + + writeManagedSession(join('2026', '07', '28', 'rollout-later.jsonl'), '{"id":"later"}\n') + const healed = await startCodexSessionBackfillInBackground() + + expect(healed).toMatchObject({ scannedFiles: 1, linkedFiles: 1 }) + expect( + existsSync(join(getSystemSessionsRoot(), '2026', '07', '28', 'rollout-later.jsonl')) + ).toBe(true) }) it('recovers an installed rollout after the completion marker write fails', async () => { @@ -534,19 +834,52 @@ describe('startCodexSessionBackfillInBackground', () => { it('does not retry a stable hardlink-less filesystem limitation', async () => { fsMockState.failLink = true - fsMockState.failInstallLink = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await 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.failLinkTransiently = 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.failLinkTransiently = 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('leaves the marker unset when any file fails so the next startup retries', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() @@ -555,8 +888,7 @@ describe('startCodexSessionBackfillInBackground', () => { const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl') expect(existsSync(targetPath)).toBe(false) - fsMockState.failLink = false - fsMockState.failCopy = false + fsMockState.failLinkTransiently = false const second = await startCodexSessionBackfillInBackground() expect(second).toMatchObject({ linkedFiles: 1, failedFiles: 0 }) expect(readFileSync(targetPath, 'utf-8')).toBe('{"id":"a"}\n') diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index e0294a9a7..720469cf2 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -1,24 +1,24 @@ import { link, lstat, mkdir } from 'node:fs/promises' -import { dirname, join, relative, sep } from 'node:path' +import { dirname, join, relative } from 'node:path' import { getCodexSessionBackfillStateDirPath, getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { - appendCodexSessionHealAuditRecord, - createCodexSessionBackfillAuditWriter, - recordExistingCodexSessionForHeal, - type CodexSessionBackfillAuditWriter -} from './codex-session-backfill-audit' + createCodexSessionBackfillAuditPass, + readCodexSessionTargetStat, + type CodexSessionBackfillAuditPass +} from './codex-session-backfill-audit-pass' +import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit' import { - copySessionFileWithoutOverwrite, - isAtomicNoReplaceUnsupportedError -} from './codex-session-backfill-copy' -import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + isCodexSessionRolloutPath, + listCodexSessionBackfillFilesForDates +} from './codex-session-backfill-date' import { + captureCodexSessionBackfillMarkerGeneration, hasCompletedCodexSessionBackfillMarker, - writeCodexSessionBackfillMarker + writeCodexSessionBackfillMarker as writeBackfillMarker } from './codex-session-backfill-marker' import type { CodexSessionBackfillOptions, @@ -87,7 +87,11 @@ async function runCodexSessionBackfillOncePerHost( systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) - if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { + const markerGeneration = captureCodexSessionBackfillMarkerGeneration() + if ( + !options.ignoreCompletionMarker && + hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + ) { return null } const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) @@ -96,11 +100,14 @@ async function runCodexSessionBackfillOncePerHost( if ( !summary.stopped && options.shouldStop?.() !== true && + options.writeCompletionMarker !== false && + options.canWriteCompletionMarker?.() !== false && + (options.scanDates === undefined || options.writeBoundedCompletionMarker === true) && summary.failedFiles === 0 && summary.failedDirectories === 0 && summary.failedHealAuditRecords === 0 ) { - writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary) + writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration) } return summary } @@ -109,8 +116,8 @@ async function runCodexSessionBackfillOncePerHost( * Backfills managed-home session rollout files into the real Codex home. * * Non-destructive by contract: existing target files are always skipped, and - * nothing in either home is deleted or moved. Hardlink first so resume sees - * one physical JSONL log; copy is the cross-volume fallback. + * nothing in either home is deleted or moved. A hardlink keeps mutable rollout + * contents coherent; cross-volume snapshots are skipped as unsupported. */ export async function backfillManagedCodexSessionsIntoSystemHome( paths: CodexSessionBackfillPaths, @@ -129,22 +136,18 @@ export async function backfillManagedCodexSessionsIntoSystemHome( failedFiles: 0, failedHealAuditRecords: 0 } - const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath) + const auditPass = await createCodexSessionBackfillAuditPass(paths.auditLogPath) const ensuredTargetDirectories = new Set() - const managedSessionsRootExists = await checkManagedSessionsRoot( - paths, - summary, - appendAuditRecord - ) + const managedSessionsRootExists = await checkManagedSessionsRoot(paths, summary, auditPass) if (managedSessionsRootExists) { - for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally( + for await (const managedSessionFilePath of listCodexSessionBackfillFilesForDates( paths.managedSessionsRoot, options, async (directoryPath, error) => { // Why: a partial walk must remain retryable; otherwise an unreadable // date directory would be silently omitted behind a completion marker. summary.failedDirectories += 1 - await appendAuditRecord({ + await auditPass.appendRecord({ action: 'scan-failed', source: directoryPath, error: describeError(error) @@ -158,7 +161,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( break } summary.scannedFiles += 1 - if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { + if (!isCodexSessionRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) { summary.skippedUnexpectedFiles += 1 continue } @@ -168,13 +171,13 @@ export async function backfillManagedCodexSessionsIntoSystemHome( paths, managedSessionFilePath, summary, - appendAuditRecord, - ensuredTargetDirectories + ensuredTargetDirectories, + auditPass ) } } summary.stopped ||= options.shouldStop?.() === true - await appendAuditRecord({ action: 'run-summary', ...summary }) + await auditPass.finish(summary) // Why: opt-out can land while the async summary append is pending; carry it // back to the marker gate so a managed launch cannot be hidden by stale completion. summary.stopped ||= options.shouldStop?.() === true @@ -184,7 +187,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome( async function checkManagedSessionsRoot( paths: CodexSessionBackfillPaths, summary: CodexSessionBackfillSummary, - appendAuditRecord: CodexSessionBackfillAuditWriter + auditPass: CodexSessionBackfillAuditPass ): Promise { try { await lstat(paths.managedSessionsRoot) @@ -196,7 +199,7 @@ async function checkManagedSessionsRoot( // Why: existsSync collapses access failures into "missing," which could // permanently hide sessions behind an incorrect completion marker. summary.failedDirectories += 1 - await appendAuditRecord({ + await auditPass.appendRecord({ action: 'scan-failed', source: paths.managedSessionsRoot, error: describeError(error) @@ -205,26 +208,12 @@ async function checkManagedSessionsRoot( } } -function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean { - const pathParts = relative(sessionsRoot, filePath).split(sep) - if (pathParts.length !== 4) { - return false - } - const [year, month, day, fileName] = pathParts - return ( - /^\d{4}$/.test(year) && - /^\d{2}$/.test(month) && - /^\d{2}$/.test(day) && - /^rollout-.+\.jsonl$/.test(fileName) - ) -} - async function backfillOneManagedSessionFile( paths: CodexSessionBackfillPaths, managedSessionFilePath: string, summary: CodexSessionBackfillSummary, - appendAuditRecord: CodexSessionBackfillAuditWriter, - ensuredTargetDirectories: Set + ensuredTargetDirectories: Set, + auditPass: CodexSessionBackfillAuditPass ): Promise { if (await isSymbolicLink(managedSessionFilePath)) { // Why: bridge-created symlinks already point at a file in the user's own @@ -234,16 +223,18 @@ async function backfillOneManagedSessionFile( } const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath) const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) - if (await pathEntryExists(systemSessionFilePath)) { - await recordExistingCodexSessionForHeal( - appendAuditRecord, + const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath) + if (existingTargetStat) { + await auditPass.recordExisting( summary, managedSessionFilePath, - systemSessionFilePath + systemSessionFilePath, + existingTargetStat ) return } + let linkAttempted = false try { const targetDirectory = dirname(systemSessionFilePath) if (!ensuredTargetDirectories.has(targetDirectory)) { @@ -252,66 +243,56 @@ async function backfillOneManagedSessionFile( await mkdir(targetDirectory, { recursive: true }) ensuredTargetDirectories.add(targetDirectory) } + linkAttempted = true await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 - await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { - action: 'hardlink', - source: managedSessionFilePath, - target: systemSessionFilePath - }) + await auditPass.recordPublished( + summary, + 'hardlink', + managedSessionFilePath, + systemSessionFilePath + ) } catch (linkError) { - if (isExistsError(linkError)) { + if (linkAttempted && isExistsError(linkError)) { // Why: another window can publish the target after our existence probe; // enqueue it here too in case that writer died before its audit append. - await recordExistingCodexSessionForHeal( - appendAuditRecord, + await auditPass.recordExisting( summary, managedSessionFilePath, - systemSessionFilePath + systemSessionFilePath, + await readCodexSessionTargetStat(systemSessionFilePath) ) return } if (isNotFoundError(linkError)) { ensuredTargetDirectories.delete(dirname(systemSessionFilePath)) } - try { - // Why: cross-volume copies are staged so failures cannot strand a - // truncated rollout, then installed without overwriting collisions. - await copySessionFileWithoutOverwrite(managedSessionFilePath, systemSessionFilePath) - summary.copiedFiles += 1 - await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, { - action: 'copy', - source: managedSessionFilePath, - target: systemSessionFilePath - }) - } catch (copyError) { - if (isExistsError(copyError)) { - await recordExistingCodexSessionForHeal( - appendAuditRecord, - summary, - managedSessionFilePath, - systemSessionFilePath - ) - return - } - if (isAtomicNoReplaceUnsupportedError(copyError)) { - summary.skippedUnsupportedFilesystemFiles += 1 - await appendAuditRecord({ + const sourceStat = await readCodexSessionTargetStat(managedSessionFilePath) + if (linkAttempted && isUnsupportedHardlinkError(linkError)) { + // Why: a mutable rollout cannot be kept coherent by a cross-volume snapshot. + summary.skippedUnsupportedFilesystemFiles += 1 + await auditPass.recordDiagnostic( + { action: 'copy-unsupported', source: managedSessionFilePath, - target: systemSessionFilePath - }) - return - } - summary.failedFiles += 1 - await appendAuditRecord({ + target: systemSessionFilePath, + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) + return + } + summary.failedFiles += 1 + await auditPass.recordDiagnostic( + { action: 'failed', source: managedSessionFilePath, target: systemSessionFilePath, - error: describeError(copyError), - linkError: describeError(linkError) - }) - } + linkError: describeError(linkError), + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) } } @@ -323,16 +304,6 @@ async function isSymbolicLink(filePath: string): Promise { } } -/** Existence via lstat so a broken symlink at the target still counts as taken. */ -async function pathEntryExists(entryPath: string): Promise { - try { - await lstat(entryPath) - return true - } catch { - return false - } -} - function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } @@ -341,6 +312,11 @@ function isNotFoundError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } +function isUnsupportedHardlinkError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'EXDEV' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'ENOSYS' +} + function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/src/main/codex/codex-session-index-heal-state.ts b/src/main/codex/codex-session-index-heal-state.ts index 4efc12431..ec7b0caf9 100644 --- a/src/main/codex/codex-session-index-heal-state.ts +++ b/src/main/codex/codex-session-index-heal-state.ts @@ -72,10 +72,11 @@ export function collectPendingHealThreads(paths: CodexSessionIndexHealPaths): Pe const threadId = match[2].toLowerCase() const auditRecordId = typeof line.recordId === 'string' ? line.recordId : null if ( - processed.healedThreadIds.has(threadId) || - (auditRecordId - ? processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) - : processed.legacyMissingThreadIds.has(threadId)) + auditRecordId + ? processed.healedAuditRecords.has(`${threadId}\0${auditRecordId}`) || + processed.missingAuditRecords.has(`${threadId}\0${auditRecordId}`) + : processed.legacyHealedThreadIds.has(threadId) || + processed.legacyMissingThreadIds.has(threadId) ) { // Why: only the newest publication event for a thread matters. A later // processed event must displace an older pending event from this scan. @@ -94,11 +95,13 @@ function lastPathSegment(filePath: string): string { } function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { - healedThreadIds: Set + healedAuditRecords: Set + legacyHealedThreadIds: Set missingAuditRecords: Set legacyMissingThreadIds: Set } { - const healedThreadIds = new Set() + const healedAuditRecords = new Set() + const legacyHealedThreadIds = new Set() const missingAuditRecords = new Set() const legacyMissingThreadIds = new Set() const expectedRoot = normalizeRuntimePathForComparison(paths.systemSessionsRoot) @@ -112,7 +115,11 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { ) { const threadId = line.threadId.toLowerCase() if (line.outcome === 'healed') { - healedThreadIds.add(threadId) + if (typeof line.auditRecordId === 'string') { + healedAuditRecords.add(`${threadId}\0${line.auditRecordId}`) + } else { + legacyHealedThreadIds.add(threadId) + } } else if (typeof line.auditRecordId === 'string') { missingAuditRecords.add(`${threadId}\0${line.auditRecordId}`) } else { @@ -120,7 +127,12 @@ function readProcessedHealThreads(paths: CodexSessionIndexHealPaths): { } } } - return { healedThreadIds, missingAuditRecords, legacyMissingThreadIds } + return { + healedAuditRecords, + legacyHealedThreadIds, + missingAuditRecords, + legacyMissingThreadIds + } } export function appendHealLedgerRecord( diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index 861c7f72b..a12dd7dc5 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -232,7 +232,7 @@ describe('runCodexSessionIndexHeal', () => { expect(marker.healedThreads).toBe(3) }) - it('is a no-op when the marker matches the audit ledger size', async () => { + it('keeps second and later passes cheap when the audit ledger is unchanged', async () => { const rig = createHealRig({ auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] }) @@ -245,11 +245,38 @@ describe('runCodexSessionIndexHeal', () => { buildInvocation: rig.buildInvocation, interBatchDelayMs: 0 }) + const third = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) expect(second.outcome).toBe('up-to-date') - // One spawn from the first run only — the no-op run must not hit the CLI. + expect(third.outcome).toBe('up-to-date') + // One spawn from the first run only — no-op runs must not hit the CLI. expect(rig.readLog().serverStarts).toBe(1) }) + it('re-reads a healed thread after a later publication event', async () => { + const id = threadId('1') + const stamp = '2026-07-01T10-00-00' + const rig = createHealRig({ auditedThreads: [{ stamp, id }] }) + await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + await createCodexSessionBackfillAuditWriter(rig.paths.auditLogPath)({ + action: 'existing', + target: rolloutTarget(rig.paths.systemSessionsRoot, stamp, id) + }) + const repeated = await runCodexSessionIndexHeal(rig.paths, { + buildInvocation: rig.buildInvocation, + interBatchDelayMs: 0 + }) + + expect(repeated).toMatchObject({ pendingThreads: 1, healedThreads: 1 }) + expect(rig.readLog().threadIds).toEqual([id, id]) + }) + it('resumes only unprocessed sessions when the audit ledger grows', async () => { const rig = createHealRig({ auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }] diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 54094e022..9f9aed317 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler' describe('createCodexSessionMigrationScheduler', () => { @@ -8,12 +9,14 @@ describe('createCodexSessionMigrationScheduler', () => { it('runs after a managed-account startup switches to host system default', async () => { let eligible = false - const startBackfill = vi.fn().mockResolvedValue(null) + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi.fn().mockResolvedValue(null) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => eligible, isQuitting: () => false, resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, startBackfill, startIndexHeal }) @@ -26,6 +29,222 @@ describe('createCodexSessionMigrationScheduler', () => { scheduler.requestRun() await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).not.toHaveBeenCalled() + }) + + it('schedules a delayed rerun after a shared-home launch', async () => { + vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0)) + const prepareScheduledRun = vi.fn() + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + finishScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(999) + expect(startBackfill).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [['2026', '08', '05']], + ignoreCompletionMarker: true + }), + undefined + ) + expect(prepareScheduledRun).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('covers both launch and run dates when a delayed pass crosses midnight', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59.500Z')) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + }), + undefined + ) + }) + + it('keeps launch passes full when no completed baseline can cover older failures', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun(true) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined }), + undefined + ) + }) + + it('upgrades a bounded pass when its target changed before the timer fired', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => '/moved-history', + prepareScheduledRun: () => true, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined }), + '/moved-history' + ) + }) + + it('delays the startup run from the latest shared-home launch', async () => { + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleInitialRun() + await vi.advanceTimersByTimeAsync(999) + scheduler.scheduleRun() + + await vi.advanceTimersByTimeAsync(1) + expect(startBackfill).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(999) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).toHaveBeenCalledOnce() + }) + + it('preserves a delayed launch rerun while an earlier migration is active', async () => { + let releaseFirstIndexHeal: (() => void) | undefined + const prepareScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const startIndexHeal = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstIndexHeal = resolve + }) + ) + .mockResolvedValueOnce(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.requestRun() + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenCalledOnce() + expect(prepareScheduledRun).not.toHaveBeenCalled() + + releaseFirstIndexHeal?.() + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledTimes(2)) + expect(prepareScheduledRun).toHaveBeenCalledOnce() + expect(prepareScheduledRun.mock.invocationCallOrder[0]).toBeLessThan( + startBackfill.mock.invocationCallOrder[1]! + ) + }) + + it('prepares a delayed launch pass after an earlier migration settles before the timer', async () => { + let releaseFirstBackfill: (() => void) | undefined + let markerPresent = false + const prepareScheduledRun = vi.fn(() => { + markerPresent = false + }) + const startBackfill = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirstBackfill = () => { + markerPresent = true + resolve() + } + }) + ) + .mockImplementationOnce(async () => { + expect(markerPresent).toBe(false) + }) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.requestRun() + scheduler.scheduleRun() + releaseFirstBackfill?.() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(markerPresent).toBe(true) + expect(startIndexHeal).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(prepareScheduledRun).toHaveBeenCalledOnce() }) it('coalesces concurrent run requests and stops before index heal after opt-out', async () => { @@ -90,4 +309,233 @@ describe('createCodexSessionMigrationScheduler', () => { await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) }) + + it('preserves scheduled identity and target recovery after a stopped pass', async () => { + let eligible = true + let target = '/old-history' + let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined + const prepareScheduledRun = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const finishScheduledRun = vi.fn() + const startBackfill = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ stopped: boolean }>((resolve) => { + releaseFirstBackfill = resolve + }) + ) + .mockResolvedValueOnce({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => eligible, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => target, + prepareScheduledRun, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + const firstOptions = startBackfill.mock.calls[0]?.[0] + eligible = false + expect(firstOptions?.shouldStop()).toBe(true) + target = '/new-history' + releaseFirstBackfill?.({ stopped: true }) + await vi.waitFor(() => expect(prepareScheduledRun).toHaveBeenCalledOnce()) + expect(finishScheduledRun).not.toHaveBeenCalled() + + eligible = true + scheduler.requestRun() + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ ignoreCompletionMarker: true, scanDates: undefined }), + '/new-history' + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('finishes a launch generation only after its PTY exits and every date is rescanned', async () => { + vi.setSystemTime(new Date('2026-08-05T23:59:59Z')) + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun: vi.fn(), + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + expect(finishScheduledRun).not.toHaveBeenCalled() + + vi.setSystemTime(new Date('2026-08-07T01:00:00Z')) + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'], + ['2026', '08', '07'] + ], + ignoreCompletionMarker: true, + writeCompletionMarker: true, + writeBoundedCompletionMarker: true + }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('keeps a failed full scan required for the final launch pass', async () => { + const finishScheduledRun = vi.fn() + const startBackfill = vi + .fn() + .mockResolvedValueOnce({ stopped: false, failedFiles: 1 }) + .mockResolvedValueOnce({ stopped: false, failedFiles: 0 }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + prepareScheduledRun: () => false, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-1', true) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce()) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('pty-1') + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2)) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('blocks marker publication when a newer launch pass is pending', async () => { + let releaseBackfill: ((result: { stopped: boolean }) => void) | undefined + const startBackfill = vi.fn( + (_options: CodexSessionBackfillOptions) => + new Promise<{ stopped: boolean }>((resolve) => { + releaseBackfill = resolve + }) + ) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal, + initialDelayMs: 1_000 + }) + + scheduler.scheduleRun() + await vi.advanceTimersByTimeAsync(1_000) + const firstOptions = startBackfill.mock.calls[0]?.[0] + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(true) + + scheduler.beginLaunch('pty-2') + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false) + scheduler.finishLaunch('pty-2') + expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false) + + releaseBackfill?.({ stopped: false }) + await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce()) + }) + + it('turns an exit-before-begin race into a full recovery pass', async () => { + const finishScheduledRun = vi.fn() + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + finishScheduledRun, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('pty-fast-exit', 2) + scheduler.beginLaunch('pty-fast-exit', false, new Date(), 1) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }), + undefined + ) + await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce()) + }) + + it('does not consume an exit from an earlier stable-id incarnation', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.finishLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + }) + + it('preserves the pre-spawn UTC date when launch setup crosses midnight', async () => { + vi.setSystemTime(new Date('2026-08-06T00:00:01Z')) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + scheduler.beginLaunch('pty-midnight', false, new Date('2026-08-05T23:59:59Z')) + scheduler.finishLaunch('pty-midnight') + await vi.advanceTimersByTimeAsync(1_000) + + expect(startBackfill).toHaveBeenCalledWith( + expect.objectContaining({ + scanDates: [ + ['2026', '08', '05'], + ['2026', '08', '06'] + ] + }), + undefined + ) + }) }) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 908f77a6a..28ce4b991 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,12 +1,29 @@ -import type { CodexSessionBackfillOptions } from './codex-session-backfill-types' +import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import type { + CodexSessionBackfillDate, + CodexSessionBackfillOptions +} from './codex-session-backfill-types' type MigrationRun = ( options: CodexSessionBackfillOptions, systemCodexHomePathOverride?: string ) => Promise +const EARLY_PTY_EXIT_RETENTION_MS = 60_000 +const MAX_EARLY_PTY_EXITS = 256 + +type EarlyPtyExit = { sequence: number; recordedAt: number } + export type CodexSessionMigrationScheduler = { + beginLaunch( + leaseId: string, + fullScanRequired?: boolean, + startedAt?: Date, + startedSequence?: number + ): void + finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void + scheduleRun(fullScanRequired?: boolean): void requestRun(): void } @@ -14,24 +31,64 @@ export function createCodexSessionMigrationScheduler(args: { isEligible: () => boolean isQuitting: () => boolean resolveSystemCodexHomePathOverride: () => string | undefined + prepareScheduledRun?: () => boolean | void + finishScheduledRun?: () => void startBackfill: MigrationRun startIndexHeal: MigrationRun initialDelayMs?: number }): CodexSessionMigrationScheduler { - let initialTimer: ReturnType | null = null + let scheduledTimer: ReturnType | null = null + let scheduledRunGeneration = 0 + let pendingScheduledRunGeneration: number | null = null + const scheduledScanDates = new Map() + const pendingScanDates = new Map() + let scheduledFullScan = false + let pendingFullScan = false let migrationTask: Promise | null = null + const activeLaunches = new Map() + const earlyPtyExits = new Map() let activeRunStopObserved = false let rerunRequested = false - const requestRun = (): void => { + const requestRun = ( + rerunIfActive = false, + requestedGeneration?: number, + requestedScanDates: readonly CodexSessionBackfillDate[] = [], + requestedFullScan = false + ): void => { + if (requestedGeneration !== undefined) { + pendingScheduledRunGeneration = Math.max( + requestedGeneration, + pendingScheduledRunGeneration ?? requestedGeneration + ) + for (const scanDate of requestedScanDates) { + pendingScanDates.set(scanDate.join('-'), scanDate) + } + pendingFullScan ||= requestedFullScan + } if (args.isQuitting() || !args.isEligible()) { return } if (migrationTask) { - // Why: an account transition can re-enable migration while the prior run is still stopping. - rerunRequested ||= activeRunStopObserved + // Why: delayed launches and resumed account transitions must survive an older active pass. + rerunRequested ||= rerunIfActive || activeRunStopObserved return } + const isScheduledRun = pendingScheduledRunGeneration !== null + const activeScheduledRunGeneration = pendingScheduledRunGeneration + let preparationNeedsFullScan = false + if (isScheduledRun) { + pendingScheduledRunGeneration = null + // Why: an older active pass can rewrite the marker after launch invalidates it. + preparationNeedsFullScan = args.prepareScheduledRun?.() === true + } + const fullScanRequired = pendingFullScan || preparationNeedsFullScan + const scanDates = + !fullScanRequired && pendingScanDates.size > 0 + ? [...pendingScanDates.values()].sort(compareBackfillDates) + : undefined + pendingScanDates.clear() + pendingFullScan = false activeRunStopObserved = false rerunRequested = false const shouldStop = (): boolean => { @@ -41,10 +98,27 @@ export function createCodexSessionMigrationScheduler(args: { } const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride() let stoppedBackfill = false + let incompleteBackfill = true const task = args - .startBackfill({ shouldStop }, systemCodexHomePathOverride) + .startBackfill( + { + shouldStop, + scanDates, + ignoreCompletionMarker: isScheduledRun, + writeCompletionMarker: activeLaunches.size === 0, + writeBoundedCompletionMarker: + isScheduledRun && activeLaunches.size === 0 && !fullScanRequired, + canWriteCompletionMarker: () => + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null && + (!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration) + }, + systemCodexHomePathOverride + ) .then((result) => { stoppedBackfill = isStoppedMigrationResult(result) + incompleteBackfill = isIncompleteBackfillResult(result) if (stoppedBackfill || shouldStop()) { return } @@ -58,9 +132,29 @@ export function createCodexSessionMigrationScheduler(args: { void task.finally(() => { if (migrationTask === task) { migrationTask = null - const shouldRerun = rerunRequested || stoppedBackfill + const scheduledRunIncomplete = stoppedBackfill || activeRunStopObserved + const shouldRerun = rerunRequested || scheduledRunIncomplete rerunRequested = false activeRunStopObserved = false + if ((shouldRerun || incompleteBackfill) && isScheduledRun) { + pendingScheduledRunGeneration = Math.max( + activeScheduledRunGeneration!, + pendingScheduledRunGeneration ?? activeScheduledRunGeneration! + ) + pendingFullScan ||= fullScanRequired + for (const scanDate of scanDates ?? []) { + pendingScanDates.set(scanDate.join('-'), scanDate) + } + } + if ( + isScheduledRun && + !incompleteBackfill && + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null + ) { + args.finishScheduledRun?.() + } if (shouldRerun) { requestRun() } @@ -68,20 +162,150 @@ export function createCodexSessionMigrationScheduler(args: { }) } + const armScheduledRun = (generation?: number): void => { + scheduledTimer = setTimeout(() => { + scheduledTimer = null + if (generation !== undefined) { + const currentDate = getCodexSessionBackfillDate() + scheduledScanDates.set(currentDate.join('-'), currentDate) + } + const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates) + scheduledScanDates.clear() + const fullScanRequired = scheduledFullScan + scheduledFullScan = false + // Why: a launch can invalidate the marker while a long index-heal pass is active. + requestRun(true, generation, scanDates, fullScanRequired) + }, args.initialDelayMs ?? 15_000) + } + + const scheduleRun = (fullScanRequired = false): void => { + if (scheduledTimer) { + clearTimeout(scheduledTimer) + } + scheduledRunGeneration += 1 + scheduledFullScan ||= fullScanRequired + const launchDate = getCodexSessionBackfillDate() + scheduledScanDates.set(launchDate.join('-'), launchDate) + armScheduledRun(scheduledRunGeneration) + } + return { - scheduleInitialRun(): void { - if (initialTimer) { + beginLaunch(leaseId, fullScanRequired = false, startedAt = new Date(), startedSequence): void { + if (args.isQuitting() || activeLaunches.has(leaseId)) { return } - initialTimer = setTimeout(() => { - initialTimer = null - requestRun() - }, args.initialDelayMs ?? 15_000) + if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) { + scheduleRun(true) + return + } + activeLaunches.set(leaseId, startedAt) + scheduleRun(fullScanRequired) }, - requestRun + finishLaunch(leaseId, exitSequence): void { + const startedAt = activeLaunches.get(leaseId) + if (!startedAt) { + if (exitSequence !== undefined) { + recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence) + } + return + } + activeLaunches.delete(leaseId) + if (args.isQuitting()) { + return + } + for (const scanDate of getCodexSessionBackfillDatesBetween(startedAt, new Date())) { + scheduledScanDates.set(scanDate.join('-'), scanDate) + } + scheduleRun() + }, + scheduleInitialRun(): void { + if (!scheduledTimer) { + armScheduledRun() + } + }, + scheduleRun, + requestRun: () => requestRun() } } +function getCodexSessionBackfillDatesBetween( + startedAt: Date, + finishedAt: Date +): CodexSessionBackfillDate[] { + const dates: CodexSessionBackfillDate[] = [] + const cursor = new Date( + Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate()) + ) + const last = new Date( + Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate()) + ) + while (cursor <= last) { + dates.push(getCodexSessionBackfillDate(cursor)) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + return dates +} + +function compareBackfillDates( + left: CodexSessionBackfillDate, + right: CodexSessionBackfillDate +): number { + return left.join('-').localeCompare(right.join('-')) +} + +function recordEarlyPtyExit( + exits: Map, + leaseId: string, + sequence: number +): void { + const now = Date.now() + for (const [id, exit] of exits) { + if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) { + exits.delete(id) + } + } + exits.set(leaseId, { sequence, recordedAt: now }) + while (exits.size > MAX_EARLY_PTY_EXITS) { + const oldestLeaseId = exits.keys().next().value + if (oldestLeaseId === undefined) { + break + } + exits.delete(oldestLeaseId) + } +} + +function consumeEarlyPtyExit( + exits: Map, + leaseId: string, + startedSequence: number | undefined +): boolean { + const exit = exits.get(leaseId) + exits.delete(leaseId) + return ( + exit !== undefined && + startedSequence !== undefined && + exit.sequence > startedSequence && + Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS + ) +} + function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } + +function isIncompleteBackfillResult(result: unknown): boolean { + if (!result || typeof result !== 'object') { + return true + } + return ( + isStoppedMigrationResult(result) || + readPositiveResultCount(result, 'failedFiles') || + readPositiveResultCount(result, 'failedDirectories') || + readPositiveResultCount(result, 'failedHealAuditRecords') + ) +} + +function readPositiveResultCount(result: object, key: string): boolean { + const value = key in result ? (result as Record)[key] : undefined + return typeof value === 'number' && value > 0 +} diff --git a/src/main/index.ts b/src/main/index.ts index e5a0050a7..b85a31559 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -336,6 +336,7 @@ let codexUsage: CodexUsageStore | null = null let openCodeUsage: OpenCodeUsageStore | null = null let codexAccounts: CodexAccountService | null = null let codexRuntimeHome: CodexRuntimeHomeService | null = null +let codexSessionMigration: ReturnType | null = null let claudeAccounts: ClaudeAccountService | null = null let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null let runtime: OrcaRuntimeService | null = null @@ -389,6 +390,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + const fullScanRequired = + codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +function handlePtyExit(id: string, exitSequence: number): void { + codexSessionMigration?.finishLaunch(id, exitSequence) +} // Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. // Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. // It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its @@ -1400,6 +1428,8 @@ function openMainWindow(): BrowserWindow { }, // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), updateInstallMode: resolveUpdateInstallMode(isServeMode), @@ -2254,21 +2284,21 @@ void app.whenReady().then(async () => { codexRuntimeHome.isHostSystemDefaultRealHome() && isAgentStatusHooksEnabled(store?.getSettings()) ) - const codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, isQuitting: () => isQuitting, resolveSystemCodexHomePathOverride: () => resolveHostCodexSessionSourceHome(store!.getSettings()), + prepareScheduledRun: () => codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(), + finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), startBackfill: startCodexSessionBackfillInBackground, startIndexHeal: startCodexSessionIndexHealInBackground }) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { onHostSystemDefaultSelected: codexSessionMigration.requestRun }) - // Why: one-time per-host backfill makes historical Orca-managed Codex - // sessions visible to the user's own resume picker and app history (#4444, - // #8612). Deferred so startup and first PTY spawns never compete with the - // sessions tree walk. + // Why: migrate historical shared-home sessions after startup; compatibility + // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). codexSessionMigration.scheduleInitialRun() claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) @@ -2883,7 +2913,11 @@ void app.whenReady().then(async () => { () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store, - prepareCodexSessionResumeForLaunch + prepareCodexSessionResumeForLaunch, + { + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit + } ) await runtime.refreshRestoredOrchestrationAuthority() await runtime.reconcileLegacyWorkerTerminals() diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c3d3a7795..e3e705985 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3074,16 +3074,25 @@ describe('registerPtyHandlers', () => { new Promise((resolve) => (releaseRecovery = resolve)) ) readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON) + const onCodexHomePtySpawned = vi.fn() handlers.clear() - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ - codexManagedAccounts: [ - { - id: 'account-1', - managedHomePath: TEST_CODEX_HOME, - managedHomeRuntime: 'host' - } - ] - })) as never) + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never, + undefined, + undefined, + { onCodexHomePtySpawned } + ) const spawnPromise = handlers.get('pty:spawn')!(null, { cols: 80, @@ -3094,10 +3103,17 @@ describe('registerPtyHandlers', () => { expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME) ) expect(spawnMock).not.toHaveBeenCalled() + expect(onCodexHomePtySpawned).not.toHaveBeenCalled() releaseRecovery() - await spawnPromise + const result = (await spawnPromise) as { id: string } expect(spawnMock).toHaveBeenCalledTimes(1) + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) }) it('does not gate a bare local shell on managed Codex auth', async () => { @@ -3108,19 +3124,37 @@ describe('registerPtyHandlers', () => { return '' }) handlers.clear() - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({ - codexManagedAccounts: [ - { - id: 'account-1', - managedHomePath: TEST_CODEX_HOME, - managedHomeRuntime: 'host' - } - ] - })) as never) + const onCodexHomePtySpawned = vi.fn() + registerPtyHandlers( + mainWindow as never, + undefined, + () => TEST_CODEX_HOME, + (() => ({ + codexManagedAccounts: [ + { + id: 'account-1', + managedHomePath: TEST_CODEX_HOME, + managedHomeRuntime: 'host' + } + ] + })) as never, + undefined, + undefined, + { onCodexHomePtySpawned } + ) - await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } expect(spawnMock).toHaveBeenCalledOnce() + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) }) it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => { @@ -8883,6 +8917,7 @@ describe('registerPtyHandlers', () => { const prepareClaudeAuth = vi.fn(() => { throw new Error('replacement auth preflight must not run') }) + const onCodexHomePtySpawned = vi.fn() let controller: RuntimeSpawnController | null = null const runtime = { setPtyController: vi.fn((value) => { @@ -8920,7 +8955,8 @@ describe('registerPtyHandlers', () => { undefined, undefined, prepareClaudeAuth, - store as never + store as never, + { onCodexHomePtySpawned } ) const spawnController = controller as unknown as RuntimeSpawnController await spawnController.spawn({ @@ -9014,6 +9050,7 @@ describe('registerPtyHandlers', () => { rows: 40, cwd, command: 'codex resume should-not-run', + launchAgent: 'codex', worktreeId, preAllocatedHandle: 'term-live-owner', tabId, @@ -9039,6 +9076,13 @@ describe('registerPtyHandlers', () => { incarnationId: 'inc-live-owner', isReattach: true }) + expect(onCodexHomePtySpawned).toHaveBeenCalledWith({ + id: 'pty-live-owner', + codexHomePath: null, + reattached: true, + startedAt: expect.any(Date), + startedSequence: expect.any(Number) + }) expect(claimedResult).toMatchObject({ id: 'pty-live-owner', stablePaneOwner: { handle: 'term-live-owner', tabId, leafId } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 54ce28b68..7b56d142d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1202,6 +1202,18 @@ export type PrepareCodexSessionResume = (args: { launchEnv?: NodeJS.ProcessEnv workspacePath?: string }) => Promise + +export type CodexHomePtySpawnedLifecycleArgs = { + id: string + codexHomePath: string | null + reattached?: boolean + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +} + +let ptyLifecycleSequence = 0 + type PrepareClaudeAuth = ( target?: ClaudeAccountSelectionTarget ) => Promise @@ -2243,6 +2255,8 @@ export function registerPtyHandlers( awaitLocalPtyProviderStartup?: () => Promise // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void } ): void { // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. @@ -3563,6 +3577,7 @@ export function registerPtyHandlers( } function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence) const release = preparePtyExitForRenderer(payload) if (!release) { return @@ -4290,6 +4305,8 @@ export function registerPtyHandlers( }, adoptStablePane, spawn: async (args) => { + const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const preAdoptedStablePane = args.adoptedStablePane ?? null const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -4300,7 +4317,7 @@ export function registerPtyHandlers( if (!handle) { throw new Error('terminal_pane_owner_unknown') } - return { + const result = { id: preAdoptedStablePane.result.id, ...(preAdoptedStablePane.result.incarnationId ? { incarnationId: preAdoptedStablePane.result.incarnationId } @@ -4314,6 +4331,16 @@ export function registerPtyHandlers( leafId: preAdoptedStablePane.owner.leafId } } + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: null, + reattached: true, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence + }) + } + return result } if (!preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) @@ -4976,6 +5003,16 @@ export function registerPtyHandlers( leafId: owner.surface.leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + reattached: true, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(env ? { launchEnv: env } : {}) + }) + } return { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5140,6 +5177,15 @@ export function registerPtyHandlers( } // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. sendPtySpawnedToRenderer(result.id) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) + }) + } const response = { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5623,6 +5669,8 @@ export function registerPtyHandlers( } } ) => { + const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined const spawnTiming = createPtySpawnTiming() const startupPromise = getLocalPtyStartupPromise(args.connectionId) if (startupPromise) { @@ -6601,6 +6649,19 @@ export function registerPtyHandlers( } // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. sendPtySpawnedToRenderer(result.id) + if (!args.connectionId) { + options?.onCodexHomePtySpawned?.({ + id: result.id, + codexHomePath: selectedCodexHomePath, + startedAt: codexHomeLaunchStartedAt, + startedSequence: codexHomeLaunchStartedSequence, + ...(result.isReattach === true + ? { reattached: true } + : baseEnv + ? { launchEnv: baseEnv } + : {}) + }) + } return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) } catch (err) { if (pendingRegistrationPtyId) { @@ -7387,7 +7448,11 @@ export function registerHeadlessPtyRuntime( getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void + } ): void { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. const headlessWindow = { @@ -7405,7 +7470,7 @@ export function registerHeadlessPtyRuntime( getSettings, prepareClaudeAuth, store, - { prepareCodexSessionResume } + { prepareCodexSessionResume, ...lifecycle } ) } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index aeaca19da..ad8551acb 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -23,6 +23,7 @@ import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup' import { getLocalPtyProvider, registerPtyHandlers, + type CodexHomePtySpawnedLifecycleArgs, type GetSelectedCodexHomePath, type PrepareCodexSessionResume } from '../ipc/pty' @@ -98,6 +99,8 @@ export function attachMainWindowServices( onBeforeRendererReload?: (args: { webContentsId: number; ignoreCache: boolean }) => void // Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787). isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void onBeforeUpdateQuit?: () => void | Promise updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void @@ -125,7 +128,9 @@ export function attachMainWindowServices( prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, - isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight + isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight, + onCodexHomePtySpawned: options?.onCodexHomePtySpawned, + onPtyExit: options?.onPtyExit } ) // Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1). diff --git a/src/types/psl.ts b/src/types/psl.ts new file mode 100644 index 000000000..b34a53295 --- /dev/null +++ b/src/types/psl.ts @@ -0,0 +1,17 @@ +declare module 'psl' { + export type ParsedDomain = { + input: string + tld: string | null + sld: string | null + domain: string | null + subdomain: string | null + listed: boolean + } + + export type ParseError = { + input: string + error: { code: string; message: string } + } + + export function parse(input: string): ParsedDomain | ParseError +}