From 0e24367c557efafed2bc22e7b7a03eed404d6944 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:36:13 -0700 Subject: [PATCH] fix(codex): owe a full history scan after launches while the source is in WSL (#35703) A launch made while the Codex history source is a \\wsl path records no rollout dates, so after switching back to a plain path the next migration pass only scanned the old pending dates and missed those sessions. Such a launch now sets the local backfill marker's full-scan flag (Orca user data, no 9P access), and the next eligible pass walks all history. Also checks the resolved source home (setting, else the system home) when deciding whether the history source is in WSL. --- ...ntime-home-history-sharing-opt-out.test.ts | 95 +++++++++++++++++-- .../runtime-home-service-launch.ts | 12 ++- .../runtime-home-service-managed-home.ts | 19 +++- .../codex-session-backfill-marker.test.ts | 21 ++++ .../codex/codex-session-backfill-marker.ts | 17 ++++ 5 files changed, 150 insertions(+), 14 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-history-sharing-opt-out.test.ts b/src/main/codex-accounts/runtime-home-history-sharing-opt-out.test.ts index 65b9d156988..73a4b2a200f 100644 --- a/src/main/codex-accounts/runtime-home-history-sharing-opt-out.test.ts +++ b/src/main/codex-accounts/runtime-home-history-sharing-opt-out.test.ts @@ -29,12 +29,7 @@ const bridges = vi.hoisted(() => ({ wsl: vi.fn(async () => {}) })) -// Every lane that links Codex sessions between homes, for one launch each. -async function launchWith( - share: boolean | undefined, - lane: 'real-home' | 'per-account' | 'shared-mirror', - codexSessionSourceHome?: { host: string } -): Promise { +function mockBridges(): void { vi.doMock('../codex/codex-account-session-bridge', async (importOriginal) => ({ ...(await importOriginal()), startCodexAccountSessionBridgeInBackground: bridges.account @@ -43,6 +38,15 @@ async function launchWith( ...(await importOriginal()), startSystemCodexSessionBridgeInBackground: bridges.system })) +} + +// Every lane that links Codex sessions between homes, for one launch each. +async function launchWith( + share: boolean | undefined, + lane: 'real-home' | 'per-account' | 'shared-mirror', + codexSessionSourceHome?: { host: string } +): Promise { + mockBridges() const managedHomePath = createManagedAuth( testState.userDataDir, 'account-1', @@ -214,4 +218,83 @@ describe('the Codex history-sharing opt-out (#24511)', () => { vi.unstubAllEnvs() } ) + + it('a launch while the source is \\\\wsl makes the next plain-source pass a full scan', async () => { + vi.stubEnv('HOME', testState.fakeHomeDir) + const plain = { host: join(testState.fakeHomeDir, 'custom-codex') } + const store = createStore( + createSettings({ realHomeRoutable: true, codexSessionSourceHome: plain }) + ) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { resolveCodexSessionBackfillPaths } = await import('../codex/codex-session-backfill') + const { writeCodexSessionBackfillMarker, captureCodexSessionBackfillMarkerGeneration } = + await import('../codex/codex-session-backfill-marker') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the getSettings/updateSettings surface this service reads. + const service = new CodexRuntimeHomeService(store as never) + const paths = resolveCodexSessionBackfillPaths(plain.host) + writeCodexSessionBackfillMarker( + paths.markerPath, + paths.systemSessionsRoot, + { + stopped: false, + scannedFiles: 3, + linkedFiles: 3, + copiedFiles: 0, + skippedExistingFiles: 0, + skippedUnexpectedFiles: 0, + skippedSymlinkFiles: 0, + skippedUnsupportedFilesystemFiles: 0, + failedDirectories: 0, + failedFiles: 0, + failedHealAuditRecords: 0 + }, + captureCodexSessionBackfillMarkerGeneration(), + { coverage: 'full', coveredScanDates: [] } + ) + // Certified plain-source history only owes a bounded pass. + expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(false) + service.finishHostSystemDefaultSessionMigrationPass() + + store.updateSettings({ codexSessionSourceHome: WSL_SOURCE }) + expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe( + null + ) + store.updateSettings({ codexSessionSourceHome: plain }) + + expect(service.prepareHostSystemDefaultSessionMigrationPass()).toBe(true) + vi.unstubAllEnvs() + }) + + it('a \\\\wsl CODEX_HOME never becomes the shared-mirror source', async () => { + const store = createStore(createSettings({ codexManagedAccounts: [] })) + // After createSettings, which sets the harness CODEX_HOME. + vi.stubEnv('CODEX_HOME', WSL_SOURCE.host) + mockBridges() + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the getSettings/updateSettings surface this service reads. + new CodexRuntimeHomeService(store as never).prepareForCodexLaunch() + expect(bridges.system).toHaveBeenCalledWith({}, undefined) + expect(bridges.system.mock.calls.flat()).not.toContain(WSL_SOURCE.host) + } finally { + vi.unstubAllEnvs() + } + }) + + it('a system home that is itself a \\\\wsl path blocks migration and backfill', async () => { + vi.stubEnv('HOME', testState.fakeHomeDir) + const store = createStore(createSettings({ realHomeRoutable: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness store implements the getSettings/updateSettings surface this service reads. + const service = new CodexRuntimeHomeService(store as never) + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true) + const realHome = testState.fakeHomeDir + testState.fakeHomeDir = '\\\\wsl.localhost\\Ubuntu\\home\\me' + try { + expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false) + } finally { + testState.fakeHomeDir = realHome + vi.unstubAllEnvs() + } + }) }) diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts index dd0868af564..254fc2b5daf 100644 --- a/src/main/codex-accounts/runtime-home-service-launch.ts +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -145,8 +145,9 @@ export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { codexHomePath: string | null, options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} ): boolean | null { + // A WSL source still reaches invalidation, which records the full scan its launch will owe. if ( - !this.isHostSystemDefaultSessionMigrationEligible() || + !this.isHostSystemDefaultSessionMigrationRouteSelected() || (!codexHomePath && !options.reattached) || (codexHomePath && normalizeRuntimePathForComparison(codexHomePath) !== @@ -161,10 +162,15 @@ export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { } isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + this.isHostSystemDefaultSessionMigrationRouteSelected() && !this.isHostHistorySourceInWsl() + ) + } + + private isHostSystemDefaultSessionMigrationRouteSelected(): boolean { return ( normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && - !hasCustomCodexHomeOverrideForLaunch() && - !this.isHostHistorySourceInWsl() + !hasCustomCodexHomeOverrideForLaunch() ) } diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts index 1dbe7b9aba4..f03f27b2e5b 100644 --- a/src/main/codex-accounts/runtime-home-service-managed-home.ts +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -22,7 +22,10 @@ import { } from './host-codex-managed-home-ownership' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { + hasCompletedCodexSessionBackfillMarker, + markCodexSessionBackfillMarkerNeedsFullScan +} from '../codex/codex-session-backfill-marker' import { getDefaultWslDistro } from '../wsl' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' import type { CodexAccountSelectionTarget } from './runtime-selection' @@ -147,8 +150,10 @@ export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { // Reading a distro's history over 9P would boot it (E9); the WSL lane owns those homes. protected isHostHistorySourceInWsl(): boolean { - const sourceHome = resolveHostCodexSessionSourceHome(this.store.getSettings()) - return Boolean(sourceHome && parseWslUncPath(sourceHome)) + // The home the lanes actually read: the setting, else the system home they fall back to. + const sourceHome = + resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath() + return parseWslUncPath(sourceHome) !== null } protected getSelfContainedSessionBridgeSourceHomes(): string[] { @@ -242,11 +247,15 @@ export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { const settings = this.store.getSettings() if ( normalizeCodexRuntimeSelection(settings).host !== null || - hasCustomCodexHomeOverrideForLaunch(launchEnv) || - this.isHostHistorySourceInWsl() + hasCustomCodexHomeOverrideForLaunch(launchEnv) ) { return null } + if (this.isHostHistorySourceInWsl()) { + // This launch's dates go unrecorded, so a later plain source must walk all history. + markCodexSessionBackfillMarkerNeedsFullScan(resolveCodexSessionBackfillPaths().markerPath) + return null + } if (!this.hostSystemDefaultSessionMigrationPending) { const paths = resolveCodexSessionBackfillPaths( resolveHostCodexSessionSourceHome(this.store.getSettings()) diff --git a/src/main/codex/codex-session-backfill-marker.test.ts b/src/main/codex/codex-session-backfill-marker.test.ts index 9b5332c933e..2c4ecd8deeb 100644 --- a/src/main/codex/codex-session-backfill-marker.test.ts +++ b/src/main/codex/codex-session-backfill-marker.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { captureCodexSessionBackfillMarkerGeneration, hasCompletedCodexSessionBackfillMarker, + markCodexSessionBackfillMarkerNeedsFullScan, markCodexSessionBackfillMarkerPending, readCodexSessionBackfillBaseline, writeCodexSessionBackfillMarker @@ -244,4 +245,24 @@ describe('codex session backfill marker', () => { expect(readMarker()).toMatchObject({ coverage: 'bounded', pendingScanDates: [TODAY] }) expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false) }) + + it('a full-scan demand outlives later launches and only a full pass clears it', () => { + writeFullBaseline(WINDOWS_ROOT) + markCodexSessionBackfillMarkerNeedsFullScan(markerPath) + + expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toBeNull() + expect(markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE])).toBe( + true + ) + writeFullBaseline(WINDOWS_ROOT) + expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toEqual({ + pendingScanDates: [] + }) + }) + + it('a full-scan demand without a marker writes nothing', () => { + markCodexSessionBackfillMarkerNeedsFullScan(markerPath) + + expect(() => readMarker()).toThrow() + }) }) diff --git a/src/main/codex/codex-session-backfill-marker.ts b/src/main/codex/codex-session-backfill-marker.ts index 4bdea034bff..660f03ebd72 100644 --- a/src/main/codex/codex-session-backfill-marker.ts +++ b/src/main/codex/codex-session-backfill-marker.ts @@ -153,6 +153,23 @@ export function markCodexSessionBackfillMarkerPending( return pending.needsFullScan } +/** Owe a full walk for launches whose rollout dates could not be recorded. */ +export function markCodexSessionBackfillMarkerNeedsFullScan(markerPath: string): void { + // Prevent older in-flight passes from clearing this demand. + markerInvalidationGeneration += 1 + const record = readMarkerRecord(markerPath) + // No marker already means no baseline, so the next pass walks everything. + if (!record || record.needsFullScan) { + return + } + try { + writeMarkerRecord(markerPath, { ...record.raw, pendingScanDates: [], needsFullScan: true }) + } catch (error) { + console.warn('[codex-session-backfill] Failed to record full-scan demand:', error) + rmSync(markerPath, { force: true }) + } +} + type CodexSessionBackfillMarkerRecord = { raw: Record systemSessionsRoot: string