From 81dde06a98bc5f0f7ef0e8c1ac55d2fa581cb028 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:53:24 -0700 Subject: [PATCH] fix(codex): close session migration launch races --- .../runtime-home-service.test.ts | 37 ++-- .../codex-accounts/runtime-home-service.ts | 22 ++- .../codex-session-backfill-audit-pass.ts | 12 -- .../codex/codex-session-backfill-audit.ts | 25 --- src/main/codex/codex-session-backfill-copy.ts | 48 +---- .../codex-session-backfill-owned-copy.ts | 46 ----- .../codex/codex-session-backfill-types.ts | 4 + src/main/codex/codex-session-backfill.test.ts | 184 +++++++----------- src/main/codex/codex-session-backfill.ts | 98 ++++------ .../codex/codex-session-index-heal-state.ts | 28 ++- .../codex/codex-session-index-heal.test.ts | 22 +++ .../codex-session-migration-scheduler.test.ts | 93 ++++++++- .../codex-session-migration-scheduler.ts | 99 ++++++++-- src/main/index.ts | 24 ++- src/main/ipc/pty.test.ts | 34 +++- src/main/ipc/pty.ts | 20 +- .../window/attach-main-window-services.ts | 6 +- 17 files changed, 424 insertions(+), 378 deletions(-) delete mode 100644 src/main/codex/codex-session-backfill-owned-copy.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3ed4d8c57..29edcb02ddc 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1004,13 +1004,11 @@ describe('CodexRuntimeHomeService', () => { const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const scheduleSessionMigration = vi.fn() - service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration) - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) expect(existsSync(markerPath)).toBe(false) - expect(scheduleSessionMigration).toHaveBeenCalledOnce() - expect(scheduleSessionMigration).toHaveBeenCalledWith(true) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) service.finishHostSystemDefaultSessionMigrationPass() writeFileSync( markerPath, @@ -1022,15 +1020,20 @@ describe('CodexRuntimeHomeService', () => { 'utf-8' ) service.prepareForCodexLaunch() - expect(scheduleSessionMigration).toHaveBeenLastCalledWith(false) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) service.prepareForCodexLaunch() - expect(scheduleSessionMigration).toHaveBeenLastCalledWith(false) - expect(scheduleSessionMigration).toHaveBeenCalledTimes(3) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + false + ) store.updateSettings({ codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} } }) service.prepareForCodexLaunch() - expect(scheduleSessionMigration).toHaveBeenLastCalledWith(true) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe( + true + ) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) @@ -1060,17 +1063,21 @@ 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') - const scheduleSessionMigration = vi.fn() - service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration) writeFileSync(markerPath, '{}\n', 'utf-8') expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false) expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe( getRuntimeCodexHomePath() ) expect(existsSync(markerPath)).toBe(true) - expect(scheduleSessionMigration).not.toHaveBeenCalled() + expect( + service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath()) + ).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. @@ -1742,14 +1749,14 @@ describe('CodexRuntimeHomeService', () => { ) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const scheduleSessionMigration = vi.fn() - service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration) // 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(scheduleSessionMigration).not.toHaveBeenCalled() + 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 d88b25092a8..7baeef8d3f3 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -184,7 +184,6 @@ export class CodexRuntimeHomeService { private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() - private scheduleHostSystemDefaultSessionMigration: (fullScanRequired: boolean) => void = () => {} private hostSystemDefaultSessionMigrationPending = false private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false private pendingHostSystemDefaultSessionMigrationTarget: string | null = null @@ -248,8 +247,7 @@ export class CodexRuntimeHomeService { this.reconcileLegacySharedHomeForRetainedPanes() return null } - const sessionMigrationFullScanRequired = - this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) this.syncForCurrentSelection(target, launchEnv) syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -258,16 +256,20 @@ export class CodexRuntimeHomeService { {}, resolveHostCodexSessionSourceHome(this.store.getSettings()) ) - if (sessionMigrationFullScanRequired !== null) { - this.scheduleHostSystemDefaultSessionMigration(sessionMigrationFullScanRequired) - } return this.getRuntimeHomePath() } - setHostSystemDefaultSessionMigrationScheduler( - schedule: (fullScanRequired: boolean) => void - ): void { - this.scheduleHostSystemDefaultSessionMigration = schedule + beginHostSystemDefaultSessionMigrationLaunch(codexHomePath: string | null): boolean | null { + if ( + !this.hostSystemDefaultSessionMigrationPending || + !this.isHostSystemDefaultSessionMigrationEligible() || + !codexHomePath || + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath()) + ) { + return null + } + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan } isHostSystemDefaultSessionMigrationEligible(): boolean { diff --git a/src/main/codex/codex-session-backfill-audit-pass.ts b/src/main/codex/codex-session-backfill-audit-pass.ts index 7751fa01614..8958cf689b6 100644 --- a/src/main/codex/codex-session-backfill-audit-pass.ts +++ b/src/main/codex/codex-session-backfill-audit-pass.ts @@ -2,8 +2,6 @@ import type { Stats } from 'node:fs' import { lstat } from 'node:fs/promises' import { appendCodexSessionHealAuditRecord, - addOwnedCopyEventId, - createCodexSessionBackfillCopyOwnershipKey, createCodexSessionBackfillAuditWriter, createCodexSessionBackfillDiagnosticEventId, createCodexSessionBackfillFileEventId, @@ -15,7 +13,6 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types export type CodexSessionBackfillAuditPass = { appendRecord: CodexSessionBackfillAuditWriter - isOwnedCopy(source: string, target: string, targetStat: Stats): boolean recordExisting( summary: CodexSessionBackfillSummary, source: string, @@ -57,12 +54,6 @@ export async function createCodexSessionBackfillAuditPass( return { appendRecord, - isOwnedCopy(source, target, targetStat): boolean { - const eventIds = coverage.ownedCopyEventIds.get( - createCodexSessionBackfillCopyOwnershipKey(source, target) - ) - return eventIds?.has(createCodexSessionBackfillFileEventId(target, targetStat)) === true - }, async recordExisting(summary, source, target, targetStat): Promise { const fileEventId = targetStat ? createCodexSessionBackfillFileEventId(target, targetStat) @@ -94,9 +85,6 @@ export async function createCodexSessionBackfillAuditPass( ) { if (fileEventId) { coverage.fileEventIds.add(fileEventId) - if (action === 'copy') { - addOwnedCopyEventId(coverage, source, target, fileEventId) - } } } }, diff --git a/src/main/codex/codex-session-backfill-audit.ts b/src/main/codex/codex-session-backfill-audit.ts index 79c3f91b030..1bec593821a 100644 --- a/src/main/codex/codex-session-backfill-audit.ts +++ b/src/main/codex/codex-session-backfill-audit.ts @@ -11,7 +11,6 @@ export type CodexSessionBackfillAuditWriter = (record: Record) export type CodexSessionBackfillAuditCoverage = { fileEventIds: Set diagnosticEventIds: Set - ownedCopyEventIds: Map> hasRunSummary: boolean } @@ -67,7 +66,6 @@ export async function readCodexSessionBackfillAuditCoverage( const coverage: CodexSessionBackfillAuditCoverage = { fileEventIds: new Set(), diagnosticEventIds: new Set(), - ownedCopyEventIds: new Map>(), hasRunSummary: false } const input = createReadStream(auditLogPath, { encoding: 'utf-8' }) @@ -87,13 +85,6 @@ export async function readCodexSessionBackfillAuditCoverage( typeof record.fileEventId === 'string' ) { coverage.fileEventIds.add(record.fileEventId) - if ( - record.action === 'copy' && - typeof record.source === 'string' && - typeof record.target === 'string' - ) { - addOwnedCopyEventId(coverage, record.source, record.target, record.fileEventId) - } } if ( typeof record.action === 'string' && @@ -130,22 +121,6 @@ export function createCodexSessionBackfillFileEventId(targetPath: string, stat: .digest('hex') } -export function createCodexSessionBackfillCopyOwnershipKey(source: string, target: string): string { - return `${normalizeRuntimePathForComparison(source)}\0${normalizeRuntimePathForComparison(target)}` -} - -export function addOwnedCopyEventId( - coverage: CodexSessionBackfillAuditCoverage, - source: string, - target: string, - fileEventId: string -): void { - const key = createCodexSessionBackfillCopyOwnershipKey(source, target) - const eventIds = coverage.ownedCopyEventIds.get(key) ?? new Set() - eventIds.add(fileEventId) - coverage.ownedCopyEventIds.set(key, eventIds) -} - export function createCodexSessionBackfillDiagnosticEventId(args: { action: 'copy-unsupported' | 'failed' source: string diff --git a/src/main/codex/codex-session-backfill-copy.ts b/src/main/codex/codex-session-backfill-copy.ts index ee7e5cc124b..8c033abf97d 100644 --- a/src/main/codex/codex-session-backfill-copy.ts +++ b/src/main/codex/codex-session-backfill-copy.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto' -import type { Stats } from 'node:fs' -import { copyFile, link, lstat, rename, rm, writeFile } from 'node:fs/promises' +import { copyFile, link, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' const ATOMIC_NO_REPLACE_UNSUPPORTED_CODE = 'ORCA_ATOMIC_NO_REPLACE_UNSUPPORTED' @@ -14,7 +13,7 @@ export async function copySessionFileWithoutOverwrite( // copy cannot strand a truncated session that a later retry would skip. await writeFile(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 }) try { - await copyStableSessionFile(sourcePath, temporaryPath) + await copyFile(sourcePath, temporaryPath) try { // Why: this same-volume hardlink atomically installs the staged copy // without risking a collision overwrite after an EXDEV fallback. @@ -41,49 +40,6 @@ export async function copySessionFileWithoutOverwrite( } } -export async function replaceOwnedSessionCopy( - sourcePath: string, - targetPath: string, - expectedTargetStat: Stats -): Promise { - const temporaryPath = join(dirname(targetPath), `.orca-backfill-${randomUUID()}.tmp`) - await writeFile(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 }) - try { - await copyStableSessionFile(sourcePath, temporaryPath) - const currentTargetStat = await lstat(targetPath) - if (!fileStatsMatch(currentTargetStat, expectedTargetStat)) { - const error = new Error(`Owned backfill target changed before refresh: ${targetPath}`) - ;(error as NodeJS.ErrnoException).code = 'EEXIST' - throw error - } - await rename(temporaryPath, targetPath) - } finally { - await rm(temporaryPath, { force: true }).catch(() => undefined) - } -} - -async function copyStableSessionFile(sourcePath: string, targetPath: string): Promise { - const sourceStatBefore = await lstat(sourcePath) - await copyFile(sourcePath, targetPath) - const sourceStatAfter = await lstat(sourcePath) - if (!fileStatsMatch(sourceStatAfter, sourceStatBefore)) { - const error = new Error(`Session changed while it was copied: ${sourcePath}`) - ;(error as NodeJS.ErrnoException).code = 'EBUSY' - throw error - } -} - -function fileStatsMatch(left: Stats, right: Stats): boolean { - return ( - left.dev === right.dev && - left.ino === right.ino && - left.birthtimeMs === right.birthtimeMs && - left.size === right.size && - left.mtimeMs === right.mtimeMs && - left.ctimeMs === right.ctimeMs - ) -} - function isExistsError(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST' } diff --git a/src/main/codex/codex-session-backfill-owned-copy.ts b/src/main/codex/codex-session-backfill-owned-copy.ts deleted file mode 100644 index 4b1f2ff33cd..00000000000 --- a/src/main/codex/codex-session-backfill-owned-copy.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Stats } from 'node:fs' -import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit' -import { - readCodexSessionTargetStat, - type CodexSessionBackfillAuditPass -} from './codex-session-backfill-audit-pass' -import { replaceOwnedSessionCopy } from './codex-session-backfill-copy' -import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' - -export async function refreshOwnedCodexSessionCopy(args: { - source: string - target: string - targetStat: Stats - summary: CodexSessionBackfillSummary - auditPass: CodexSessionBackfillAuditPass -}): Promise { - if (!args.auditPass.isOwnedCopy(args.source, args.target, args.targetStat)) { - return false - } - const sourceStat = await readCodexSessionTargetStat(args.source) - if (!sourceStat || sourceStat.size <= args.targetStat.size) { - return false - } - try { - await replaceOwnedSessionCopy(args.source, args.target, args.targetStat) - args.summary.copiedFiles += 1 - await args.auditPass.recordPublished(args.summary, 'copy', args.source, args.target) - } catch (error) { - args.summary.failedFiles += 1 - await args.auditPass.recordDiagnostic( - { - action: 'failed', - source: args.source, - target: args.target, - error: describeError(error), - errorCode: describeCodexSessionBackfillErrorCode(error) - }, - sourceStat - ) - } - return true -} - -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} diff --git a/src/main/codex/codex-session-backfill-types.ts b/src/main/codex/codex-session-backfill-types.ts index 34f570eb607..4f5966895c7 100644 --- a/src/main/codex/codex-session-backfill-types.ts +++ b/src/main/codex/codex-session-backfill-types.ts @@ -28,6 +28,10 @@ export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & 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 } 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 bb30cf6e696..c92fe908119 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -24,13 +24,11 @@ const { homedirMock } = vi.hoisted(() => ({ const { fsMockState } = vi.hoisted(() => ({ fsMockState: { failLink: false, - failInstallLink: false, - failInstallLinkTransiently: false, - growSourceDuringCopy: false, + failLinkTransiently: false, raceTargetIntoExistence: false, - failCopy: false, failAuditMkdirOnce: false, failAuditWrites: false, + failMkdirPath: null as string | null, failDirectoryPath: null as string | null, failLstatPath: null as string | null } @@ -54,6 +52,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( @@ -93,37 +96,13 @@ 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, '${String(args[1])}'` - ) as NodeJS.ErrnoException - error.code = 'EACCES' - throw error - } - await actual.copyFile(...args) - if (fsMockState.growSourceDuringCopy) { - fsMockState.growSourceDuringCopy = false - await actual.appendFile(args[0], '{"event":"during-copy"}\n', 'utf-8') - } - }, opendir: (...args: Parameters) => { if (args[0] === fsMockState.failDirectoryPath) { const error = new Error('EACCES: directory unreadable') as NodeJS.ErrnoException @@ -203,13 +182,11 @@ function readAuditActions(): string[] { beforeEach(() => { fsMockState.failLink = false - fsMockState.failInstallLink = false - fsMockState.failInstallLinkTransiently = false - fsMockState.growSourceDuringCopy = false + fsMockState.failLinkTransiently = false fsMockState.raceTargetIntoExistence = false - fsMockState.failCopy = false fsMockState.failAuditMkdirOnce = false fsMockState.failAuditWrites = false + fsMockState.failMkdirPath = null fsMockState.failDirectoryPath = null fsMockState.failLstatPath = null fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-')) @@ -388,73 +365,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 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']) - }) - - it('refreshes an unchanged owned copy after its source grows', async () => { - fsMockState.failLink = true - const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl') - const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n') - const paths = resolveCodexSessionBackfillPaths() - - await backfillManagedCodexSessionsIntoSystemHome(paths) - appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8') - const refreshed = await backfillManagedCodexSessionsIntoSystemHome(paths) - - expect(refreshed).toMatchObject({ copiedFiles: 1, failedFiles: 0 }) - expect(readFileSync(join(getSystemSessionsRoot(), relativePath), 'utf-8')).toBe( - '{"id":"a"}\n{"event":"later"}\n' - ) - }) - - it('does not publish a cross-volume snapshot while its source grows', async () => { - fsMockState.failLink = true - fsMockState.growSourceDuringCopy = true - const relativePath = join('2026', '05', '26', 'rollout-active.jsonl') + const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl') writeManagedSession(relativePath, '{"id":"a"}\n') const summary = await backfillManagedCodexSessionsIntoSystemHome( resolveCodexSessionBackfillPaths() ) - expect(summary).toMatchObject({ copiedFiles: 0, failedFiles: 1 }) + expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 }) expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false) - }) - - it('does not refresh an owned copy after the target changes', async () => { - fsMockState.failLink = true - const relativePath = join('2026', '05', '26', 'rollout-diverged.jsonl') - const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n') - const targetPath = join(getSystemSessionsRoot(), relativePath) - const paths = resolveCodexSessionBackfillPaths() - - await backfillManagedCodexSessionsIntoSystemHome(paths) - writeFileSync(targetPath, 'user-owned continuation\n', 'utf-8') - appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8') - const repeated = await backfillManagedCodexSessionsIntoSystemHome(paths) - - expect(repeated).toMatchObject({ copiedFiles: 0, skippedExistingFiles: 1 }) - expect(readFileSync(targetPath, 'utf-8')).toBe('user-owned continuation\n') + 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( @@ -473,9 +399,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( @@ -489,9 +414,21 @@ 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('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( @@ -573,6 +510,20 @@ 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('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 @@ -643,6 +594,23 @@ describe('startCodexSessionBackfillInBackground', () => { 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') @@ -783,7 +751,6 @@ 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() @@ -801,8 +768,7 @@ describe('startCodexSessionBackfillInBackground', () => { }) it('keeps repeated unchanged per-file failures audit-stable', 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() @@ -816,8 +782,7 @@ describe('startCodexSessionBackfillInBackground', () => { }) it('records a new failure event when the source file changes', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true const relativePath = join('2026', '05', '26', 'rollout-a.jsonl') writeManagedSession(relativePath, '{"id":"a"}\n') await startCodexSessionBackfillInBackground() @@ -830,24 +795,8 @@ describe('startCodexSessionBackfillInBackground', () => { expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2) }) - it('records a new failure event when the filesystem outcome changes', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true - writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') - await startCodexSessionBackfillInBackground() - - fsMockState.failCopy = false - fsMockState.failInstallLinkTransiently = true - await startCodexSessionBackfillInBackground() - - const failedRecords = readBackfillAuditRecords().filter((record) => record.action === 'failed') - expect(failedRecords).toHaveLength(2) - expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2) - }) - it('leaves the marker unset when any file fails so the next startup retries', async () => { - fsMockState.failLink = true - fsMockState.failCopy = true + fsMockState.failLinkTransiently = true writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') const first = await startCodexSessionBackfillInBackground() @@ -856,8 +805,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 a547b140796..6cff679667a 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -11,10 +11,6 @@ import { type CodexSessionBackfillAuditPass } from './codex-session-backfill-audit-pass' import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit' -import { - copySessionFileWithoutOverwrite, - isAtomicNoReplaceUnsupportedError -} from './codex-session-backfill-copy' import { isCodexSessionRolloutPath, listCodexSessionBackfillFilesForDates @@ -24,7 +20,6 @@ import { hasCompletedCodexSessionBackfillMarker, writeCodexSessionBackfillMarker as writeBackfillMarker } from './codex-session-backfill-marker' -import { refreshOwnedCodexSessionCopy } from './codex-session-backfill-owned-copy' import type { CodexSessionBackfillOptions, CodexSessionBackfillPaths, @@ -105,7 +100,8 @@ async function runCodexSessionBackfillOncePerHost( if ( !summary.stopped && options.shouldStop?.() !== true && - options.scanDates === undefined && + options.writeCompletionMarker !== false && + (options.scanDates === undefined || options.writeBoundedCompletionMarker === true) && summary.failedFiles === 0 && summary.failedDirectories === 0 && summary.failedHealAuditRecords === 0 @@ -119,8 +115,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, @@ -228,17 +224,6 @@ async function backfillOneManagedSessionFile( const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath) const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath) if (existingTargetStat) { - if ( - await refreshOwnedCodexSessionCopy({ - source: managedSessionFilePath, - target: systemSessionFilePath, - targetStat: existingTargetStat, - summary, - auditPass - }) - ) { - return - } await auditPass.recordExisting( summary, managedSessionFilePath, @@ -248,6 +233,7 @@ async function backfillOneManagedSessionFile( return } + let linkAttempted = false try { const targetDirectory = dirname(systemSessionFilePath) if (!ensuredTargetDirectories.has(targetDirectory)) { @@ -256,6 +242,7 @@ async function backfillOneManagedSessionFile( await mkdir(targetDirectory, { recursive: true }) ensuredTargetDirectories.add(targetDirectory) } + linkAttempted = true await link(managedSessionFilePath, systemSessionFilePath) summary.linkedFiles += 1 await auditPass.recordPublished( @@ -265,7 +252,7 @@ async function backfillOneManagedSessionFile( 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 auditPass.recordExisting( @@ -279,55 +266,32 @@ async function backfillOneManagedSessionFile( 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 auditPass.recordPublished( - summary, - 'copy', - managedSessionFilePath, - systemSessionFilePath - ) - } catch (copyError) { - if (isExistsError(copyError)) { - await auditPass.recordExisting( - summary, - managedSessionFilePath, - systemSessionFilePath, - await readCodexSessionTargetStat(systemSessionFilePath) - ) - return - } - if (isAtomicNoReplaceUnsupportedError(copyError)) { - summary.skippedUnsupportedFilesystemFiles += 1 - await auditPass.recordDiagnostic( - { - action: 'copy-unsupported', - source: managedSessionFilePath, - target: systemSessionFilePath, - errorCode: describeCodexSessionBackfillErrorCode(copyError), - linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) - }, - await readCodexSessionTargetStat(managedSessionFilePath) - ) - return - } - summary.failedFiles += 1 + 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: 'failed', + action: 'copy-unsupported', source: managedSessionFilePath, target: systemSessionFilePath, - error: describeError(copyError), - linkError: describeError(linkError), - errorCode: describeCodexSessionBackfillErrorCode(copyError), linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) }, - await readCodexSessionTargetStat(managedSessionFilePath) + sourceStat ) + return } + summary.failedFiles += 1 + await auditPass.recordDiagnostic( + { + action: 'failed', + source: managedSessionFilePath, + target: systemSessionFilePath, + linkError: describeError(linkError), + linkErrorCode: describeCodexSessionBackfillErrorCode(linkError) + }, + sourceStat + ) } } @@ -347,6 +311,18 @@ 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 === 'EPERM' || + code === 'EACCES' || + 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 4efc124316e..ec7b0caf9e0 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 efd5eac317a..a12dd7dc5bd 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -255,6 +255,28 @@ describe('runCodexSessionIndexHeal', () => { 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 9d18f08190d..759d9b244c2 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -9,7 +9,7 @@ describe('createCodexSessionMigrationScheduler', () => { it('runs after a managed-account startup switches to host system default', async () => { let eligible = false const prepareScheduledRun = vi.fn() - const startBackfill = vi.fn().mockResolvedValue(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi.fn().mockResolvedValue(null) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => eligible, @@ -35,7 +35,7 @@ describe('createCodexSessionMigrationScheduler', () => { vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0)) const prepareScheduledRun = vi.fn() const finishScheduledRun = vi.fn() - const startBackfill = vi.fn().mockResolvedValue(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi.fn().mockResolvedValue(null) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => true, @@ -69,7 +69,7 @@ describe('createCodexSessionMigrationScheduler', () => { it('covers both launch and run dates when a delayed pass crosses midnight', async () => { vi.setSystemTime(new Date(2026, 7, 5, 23, 59, 59, 500)) - const startBackfill = vi.fn().mockResolvedValue(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => true, isQuitting: () => false, @@ -94,7 +94,7 @@ describe('createCodexSessionMigrationScheduler', () => { }) it('keeps launch passes full when no completed baseline can cover older failures', async () => { - const startBackfill = vi.fn().mockResolvedValue(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => true, isQuitting: () => false, @@ -114,7 +114,7 @@ describe('createCodexSessionMigrationScheduler', () => { }) it('upgrades a bounded pass when its target changed before the timer fired', async () => { - const startBackfill = vi.fn().mockResolvedValue(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => true, isQuitting: () => false, @@ -136,7 +136,7 @@ describe('createCodexSessionMigrationScheduler', () => { it('delays the startup run from the latest shared-home launch', async () => { const prepareScheduledRun = vi.fn() - const startBackfill = vi.fn().mockResolvedValue(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi.fn().mockResolvedValue(null) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => true, @@ -164,7 +164,7 @@ describe('createCodexSessionMigrationScheduler', () => { 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(null) + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const startIndexHeal = vi .fn() .mockImplementationOnce( @@ -354,4 +354,83 @@ describe('createCodexSessionMigrationScheduler', () => { ) 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, 7, 5, 23, 59, 59)) + 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, 7, 7, 1, 0, 0)) + 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()) + }) }) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index fea33cfa17e..81e8ed0a308 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -10,6 +10,8 @@ type MigrationRun = ( ) => Promise export type CodexSessionMigrationScheduler = { + beginLaunch(leaseId: string, fullScanRequired?: boolean): void + finishLaunch(leaseId: string): void scheduleInitialRun(): void scheduleRun(fullScanRequired?: boolean): void requestRun(): void @@ -33,6 +35,7 @@ export function createCodexSessionMigrationScheduler(args: { let scheduledFullScan = false let pendingFullScan = false let migrationTask: Promise | null = null + const activeLaunches = new Map() let activeRunStopObserved = false let rerunRequested = false @@ -82,13 +85,22 @@ export function createCodexSessionMigrationScheduler(args: { } const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride() let stoppedBackfill = false + let incompleteBackfill = true const task = args .startBackfill( - { shouldStop, scanDates, ignoreCompletionMarker: isScheduledRun }, + { + shouldStop, + scanDates, + ignoreCompletionMarker: isScheduledRun, + writeCompletionMarker: activeLaunches.size === 0, + writeBoundedCompletionMarker: + isScheduledRun && activeLaunches.size === 0 && !fullScanRequired + }, systemCodexHomePathOverride ) .then((result) => { stoppedBackfill = isStoppedMigrationResult(result) + incompleteBackfill = isIncompleteBackfillResult(result) if (stoppedBackfill || shouldStop()) { return } @@ -106,7 +118,7 @@ export function createCodexSessionMigrationScheduler(args: { const shouldRerun = rerunRequested || scheduledRunIncomplete rerunRequested = false activeRunStopObserved = false - if (shouldRerun && isScheduledRun) { + if ((shouldRerun || incompleteBackfill) && isScheduledRun) { pendingScheduledRunGeneration = Math.max( activeScheduledRunGeneration!, pendingScheduledRunGeneration ?? activeScheduledRunGeneration! @@ -116,7 +128,13 @@ export function createCodexSessionMigrationScheduler(args: { pendingScanDates.set(scanDate.join('-'), scanDate) } } - if (isScheduledRun && scheduledTimer === null && pendingScheduledRunGeneration === null) { + if ( + isScheduledRun && + !incompleteBackfill && + activeLaunches.size === 0 && + scheduledTimer === null && + pendingScheduledRunGeneration === null + ) { args.finishScheduledRun?.() } if (shouldRerun) { @@ -142,27 +160,80 @@ export function createCodexSessionMigrationScheduler(args: { }, 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 { + beginLaunch(leaseId, fullScanRequired = false): void { + if (args.isQuitting() || activeLaunches.has(leaseId)) { + return + } + activeLaunches.set(leaseId, new Date()) + scheduleRun(fullScanRequired) + }, + finishLaunch(leaseId): void { + const startedAt = activeLaunches.get(leaseId) + if (!startedAt) { + 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(fullScanRequired = false): void { - // Why: delay from the latest launch so Codex has time to create its rollout. - if (scheduledTimer) { - clearTimeout(scheduledTimer) - } - scheduledRunGeneration += 1 - scheduledFullScan ||= fullScanRequired - const launchDate = getCodexSessionBackfillDate() - scheduledScanDates.set(launchDate.join('-'), launchDate) - armScheduledRun(scheduledRunGeneration) - }, + scheduleRun, requestRun: () => requestRun() } } +function getCodexSessionBackfillDatesBetween( + startedAt: Date, + finishedAt: Date +): CodexSessionBackfillDate[] { + const dates: CodexSessionBackfillDate[] = [] + const cursor = new Date(startedAt.getFullYear(), startedAt.getMonth(), startedAt.getDate()) + const last = new Date(finishedAt.getFullYear(), finishedAt.getMonth(), finishedAt.getDate()) + while (cursor <= last) { + dates.push(getCodexSessionBackfillDate(cursor)) + cursor.setDate(cursor.getDate() + 1) + } + return dates +} + 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 67d9d4d7e6c..c8327879619 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -335,6 +335,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 @@ -388,6 +389,18 @@ 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 handleCodexPtySpawned(args: { id: string; codexHomePath: string | null }): void { + const fullScanRequired = + codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath) ?? null + if (fullScanRequired !== null) { + codexSessionMigration?.beginLaunch(args.id, fullScanRequired) + } +} + +function handlePtyExit(id: string): void { + codexSessionMigration?.finishLaunch(id) +} // 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 @@ -1396,6 +1409,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, + onCodexPtySpawned: handleCodexPtySpawned, + onPtyExit: handlePtyExit, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), updateInstallMode: resolveUpdateInstallMode(isServeMode), @@ -2250,7 +2265,7 @@ void app.whenReady().then(async () => { codexRuntimeHome.isHostSystemDefaultRealHome() && isAgentStatusHooksEnabled(store?.getSettings()) ) - const codexSessionMigration = createCodexSessionMigrationScheduler({ + codexSessionMigration = createCodexSessionMigrationScheduler({ isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, isQuitting: () => isQuitting, resolveSystemCodexHomePathOverride: () => @@ -2260,7 +2275,6 @@ void app.whenReady().then(async () => { startBackfill: startCodexSessionBackfillInBackground, startIndexHeal: startCodexSessionIndexHealInBackground }) - codexRuntimeHome.setHostSystemDefaultSessionMigrationScheduler(codexSessionMigration.scheduleRun) codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { onHostSystemDefaultSelected: codexSessionMigration.requestRun }) @@ -2880,7 +2894,11 @@ void app.whenReady().then(async () => { () => store!.getSettings(), (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), store, - prepareCodexSessionResumeForLaunch + prepareCodexSessionResumeForLaunch, + { + onCodexPtySpawned: handleCodexPtySpawned, + 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 c3d3a7795dc..79009849cca 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 onCodexPtySpawned = 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, + { onCodexPtySpawned } + ) const spawnPromise = handlers.get('pty:spawn')!(null, { cols: 80, @@ -3094,10 +3103,15 @@ describe('registerPtyHandlers', () => { expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME) ) expect(spawnMock).not.toHaveBeenCalled() + expect(onCodexPtySpawned).not.toHaveBeenCalled() releaseRecovery() - await spawnPromise + const result = (await spawnPromise) as { id: string } expect(spawnMock).toHaveBeenCalledTimes(1) + expect(onCodexPtySpawned).toHaveBeenCalledWith({ + id: result.id, + codexHomePath: TEST_CODEX_HOME + }) }) it('does not gate a bare local shell on managed Codex auth', async () => { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 54ce28b6825..b4d73a31a24 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -2243,6 +2243,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 + onCodexPtySpawned?: (args: { id: string; codexHomePath: string | null }) => void + onPtyExit?: (id: string) => 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 +3565,7 @@ export function registerPtyHandlers( } function sendPtyExitToRenderer(payload: { id: string; code: number }): void { + options?.onPtyExit?.(payload.id) const release = preparePtyExitForRenderer(payload) if (!release) { return @@ -4976,6 +4979,9 @@ export function registerPtyHandlers( leafId: owner.surface.leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (args.launchAgent === 'codex' && !args.connectionId) { + options?.onCodexPtySpawned?.({ id: result.id, codexHomePath: selectedCodexHomePath }) + } return { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -5140,6 +5146,9 @@ export function registerPtyHandlers( } // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. sendPtySpawnedToRenderer(result.id) + if (args.launchAgent === 'codex' && !args.connectionId) { + options?.onCodexPtySpawned?.({ id: result.id, codexHomePath: selectedCodexHomePath }) + } const response = { id: result.id, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), @@ -6601,6 +6610,9 @@ export function registerPtyHandlers( } // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. sendPtySpawnedToRenderer(result.id) + if (args.launchAgent === 'codex' && !args.connectionId) { + options?.onCodexPtySpawned?.({ id: result.id, codexHomePath: selectedCodexHomePath }) + } return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) } catch (err) { if (pendingRegistrationPtyId) { @@ -7387,7 +7399,11 @@ export function registerHeadlessPtyRuntime( getSettings?: () => GlobalSettings, prepareClaudeAuth?: PrepareClaudeAuth, store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexPtySpawned?: (args: { id: string; codexHomePath: string | null }) => void + onPtyExit?: (id: string) => 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 +7421,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 aeaca19da4a..70647fa3844 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -98,6 +98,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 + onCodexPtySpawned?: (args: { id: string; codexHomePath: string | null }) => void + onPtyExit?: (id: string) => void onBeforeUpdateQuit?: () => void | Promise updateInstallMode?: UpdateInstallMode onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void @@ -125,7 +127,9 @@ export function attachMainWindowServices( prepareCodexSessionResume: options?.prepareCodexSessionResume, awaitLocalPtyStartup: options?.awaitLocalPtyStartup, awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup, - isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight + isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight, + onCodexPtySpawned: options?.onCodexPtySpawned, + onPtyExit: options?.onPtyExit } ) // Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1).