From 0f73b814a05c2c66f64b41608028719d4d4cc2fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:08:19 -0700 Subject: [PATCH] fix(codex): publish Windows system-default sessions --- .../runtime-home-service.test.ts | 10 ++++ .../codex-accounts/runtime-home-service.ts | 39 +++++++++---- ...ime-home-windows-profile-ownership.test.ts | 1 + .../codex/codex-session-backfill-marker.ts | 57 +++++++++++++++++-- src/main/codex/codex-session-backfill.test.ts | 16 ++++++ src/main/codex/codex-session-backfill.ts | 8 ++- .../codex-session-migration-scheduler.test.ts | 22 +++++++ .../codex-session-migration-scheduler.ts | 24 ++++---- src/main/index.ts | 9 ++- 9 files changed, 154 insertions(+), 32 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 693ac8db7cf..2eb91df370b 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1758,9 +1758,12 @@ 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(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) @@ -1792,12 +1795,15 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(markerPath)).toBe(false) 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() 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. @@ -2506,10 +2512,14 @@ describe('CodexRuntimeHomeService', () => { ) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) + const scheduleSessionMigration = vi.fn() + service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration) // Flag ON + host managed account = the account's own home is CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) + expect(scheduleSessionMigration).not.toHaveBeenCalled() // 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 fa2f27798b6..0d6d2dada41 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -182,6 +182,7 @@ 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: () => void = () => {} constructor(private readonly store: Store) { this.safeRecoverInterruptedRuntimeAuthOperation() @@ -242,7 +243,8 @@ export class CodexRuntimeHomeService { this.reconcileLegacySharedHomeForRetainedPanes() return null } - this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) + const shouldScheduleSessionMigration = + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) this.syncForCurrentSelection(target, launchEnv) syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() @@ -251,9 +253,23 @@ export class CodexRuntimeHomeService { {}, resolveHostCodexSessionSourceHome(this.store.getSettings()) ) + if (shouldScheduleSessionMigration) { + this.scheduleHostSystemDefaultSessionMigration() + } return this.getRuntimeHomePath() } + setHostSystemDefaultSessionMigrationScheduler(schedule: () => void): void { + this.scheduleHostSystemDefaultSessionMigration = schedule + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + // Why: with the real-home flag ON, a managed HOST account runs against its own // self-contained CODEX_HOME (codex-accounts//home) instead of the shared // runtime mirror. Its auth.json lives there and codex refreshes it in place, @@ -415,17 +431,20 @@ export class CodexRuntimeHomeService { this.lastHostAccountUsedSelfContainedHome = false } - private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void { + private invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean { const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== null) { - return - } - const realHomeSelected = this.isHostSystemDefaultRealHomeSelected(launchEnv) - if (realHomeSelected || !isCodexSystemDefaultRealHomeEnabled()) { - invalidateCodexSessionBackfillMarker( - join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') - ) + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return false } + invalidateCodexSessionBackfillMarker( + join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') + ) + return true } 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 a1a66a847d8..0265d689071 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 @@ -32,6 +32,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-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index d3d5a07e5f7..3eeb4d126c6 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -1,5 +1,5 @@ -import { mkdirSync, readFileSync, rmSync } from 'node:fs' -import { dirname } from 'node:path' +import { mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import type { CodexSessionBackfillSummary } from './codex-session-backfill-types' @@ -9,25 +9,70 @@ const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3 export function hasCompletedCodexSessionBackfillMarker( markerPath: string, - systemSessionsRoot: string + systemSessionsRoot: string, + managedSessionsRoot: string ): boolean { try { const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8')) 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. + return marker.summary?.scannedFiles !== 0 || !managedSessionsRootHasRollout(managedSessionsRoot) } catch { return false } } +function managedSessionsRootHasRollout(managedSessionsRoot: string): boolean { + try { + for (const year of readdirSync(managedSessionsRoot, { withFileTypes: true })) { + if (!year.isDirectory() || !/^\d{4}$/.test(year.name)) { + continue + } + const yearPath = join(managedSessionsRoot, year.name) + for (const month of readdirSync(yearPath, { withFileTypes: true })) { + if (!month.isDirectory() || !/^\d{2}$/.test(month.name)) { + continue + } + const monthPath = join(yearPath, month.name) + for (const day of readdirSync(monthPath, { withFileTypes: true })) { + if (!day.isDirectory() || !/^\d{2}$/.test(day.name)) { + continue + } + const dayPath = join(monthPath, day.name) + if ( + readdirSync(dayPath, { withFileTypes: true }).some( + (entry) => entry.isFile() && /^rollout-.+\.jsonl$/.test(entry.name) + ) + ) { + return true + } + } + } + } + return false + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return false + } + throw error + } +} + export function writeCodexSessionBackfillMarker( markerPath: string, systemSessionsRoot: string, diff --git a/src/main/codex/codex-session-backfill.test.ts b/src/main/codex/codex-session-backfill.test.ts index bd5e0bca680..f8427e8b40f 100644 --- a/src/main/codex/codex-session-backfill.test.ts +++ b/src/main/codex/codex-session-backfill.test.ts @@ -496,6 +496,22 @@ describe('startCodexSessionBackfillInBackground', () => { ).toBe(false) }) + 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 () => { writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n') mkdirSync(getMarkerPath(), { recursive: true }) diff --git a/src/main/codex/codex-session-backfill.ts b/src/main/codex/codex-session-backfill.ts index e0294a9a72a..403c4135b52 100644 --- a/src/main/codex/codex-session-backfill.ts +++ b/src/main/codex/codex-session-backfill.ts @@ -87,7 +87,13 @@ async function runCodexSessionBackfillOncePerHost( systemCodexHomePathOverride?: string ): Promise { const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride) - if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) { + if ( + hasCompletedCodexSessionBackfillMarker( + paths.markerPath, + paths.systemSessionsRoot, + paths.managedSessionsRoot + ) + ) { return null } const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options) diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index 54094e022f1..bb7ae877b38 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -28,6 +28,28 @@ describe('createCodexSessionMigrationScheduler', () => { expect(startBackfill).toHaveBeenCalledOnce() }) + it('schedules a delayed rerun after a shared-home launch', async () => { + const startBackfill = vi.fn().mockResolvedValue(null) + const startIndexHeal = vi.fn().mockResolvedValue(null) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + 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() + }) + it('coalesces concurrent run requests and stops before index heal after opt-out', async () => { let eligible = true let releaseBackfill: (() => void) | undefined diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 908f77a6a83..392b733fe84 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -7,6 +7,7 @@ type MigrationRun = ( export type CodexSessionMigrationScheduler = { scheduleInitialRun(): void + scheduleRun(): void requestRun(): void } @@ -18,7 +19,7 @@ export function createCodexSessionMigrationScheduler(args: { startIndexHeal: MigrationRun initialDelayMs?: number }): CodexSessionMigrationScheduler { - let initialTimer: ReturnType | null = null + let scheduledTimer: ReturnType | null = null let migrationTask: Promise | null = null let activeRunStopObserved = false let rerunRequested = false @@ -68,16 +69,19 @@ export function createCodexSessionMigrationScheduler(args: { }) } + const scheduleRun = (): void => { + if (scheduledTimer) { + return + } + scheduledTimer = setTimeout(() => { + scheduledTimer = null + requestRun() + }, args.initialDelayMs ?? 15_000) + } + return { - scheduleInitialRun(): void { - if (initialTimer) { - return - } - initialTimer = setTimeout(() => { - initialTimer = null - requestRun() - }, args.initialDelayMs ?? 15_000) - }, + scheduleInitialRun: scheduleRun, + scheduleRun, requestRun } } diff --git a/src/main/index.ts b/src/main/index.ts index 3ea7a53a184..2412493021c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2221,20 +2221,19 @@ void app.whenReady().then(async () => { isAgentStatusHooksEnabled(store?.getSettings()) ) const codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true, + isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, isQuitting: () => isQuitting, resolveSystemCodexHomePathOverride: () => resolveHostCodexSessionSourceHome(store!.getSettings()), startBackfill: startCodexSessionBackfillInBackground, startIndexHeal: startCodexSessionIndexHealInBackground }) + codexRuntimeHome.setHostSystemDefaultSessionMigrationScheduler(codexSessionMigration.scheduleRun) 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)