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