diff --git a/src/main/codex/codex-session-migration-ignored-launches.ts b/src/main/codex/codex-session-migration-ignored-launches.ts new file mode 100644 index 00000000000..fcddb0393bf --- /dev/null +++ b/src/main/codex/codex-session-migration-ignored-launches.ts @@ -0,0 +1,35 @@ +import { + CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS, + evictStaleLeaseEntries +} from './codex-session-migration-recent-exits' + +type IgnoredLaunch = { recordedAt: number } + +/** Lease ids whose reattach was proven away from the shared home, so their exit owes no scan. */ +export class CodexSessionMigrationIgnoredLaunches { + private readonly launches = new Map() + + add(leaseId: string): void { + const now = Date.now() + this.launches.delete(leaseId) + this.launches.set(leaseId, { recordedAt: now }) + evictStaleLeaseEntries(this.launches, now) + } + + has(leaseId: string): boolean { + const launch = this.launches.get(leaseId) + if (!launch) { + return false + } + // Why: an entry whose exit never arrived must age out, or it absorbs a later exit and strands that launch active. + if (Date.now() - launch.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + this.launches.delete(leaseId) + return false + } + return true + } + + delete(leaseId: string): boolean { + return this.has(leaseId) && this.launches.delete(leaseId) + } +} diff --git a/src/main/codex/codex-session-migration-recent-exits.ts b/src/main/codex/codex-session-migration-recent-exits.ts index 58038e7f795..2a92441537b 100644 --- a/src/main/codex/codex-session-migration-recent-exits.ts +++ b/src/main/codex/codex-session-migration-recent-exits.ts @@ -1,27 +1,35 @@ -const RETENTION_MS = 60_000 +export const CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS = 60_000 const MAX_ENTRIES = 256 type RecentPtyExit = { sequence: number; recordedAt: number } +// Why: one aging policy for every lease-keyed migration map, so none can grow for the process lifetime. +export function evictStaleLeaseEntries( + entries: Map, + now: number +): void { + for (const [leaseId, entry] of entries) { + if (now - entry.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { + entries.delete(leaseId) + } + } + while (entries.size > MAX_ENTRIES) { + const oldestLeaseId = entries.keys().next().value + if (oldestLeaseId === undefined) { + break + } + entries.delete(oldestLeaseId) + } +} + export class CodexSessionMigrationRecentExits { private readonly exits = new Map() record(leaseId: string, sequence: number): void { const now = Date.now() - for (const [id, exit] of this.exits) { - if (now - exit.recordedAt > RETENTION_MS) { - this.exits.delete(id) - } - } this.exits.delete(leaseId) this.exits.set(leaseId, { sequence, recordedAt: now }) - while (this.exits.size > MAX_ENTRIES) { - const oldestLeaseId = this.exits.keys().next().value - if (oldestLeaseId === undefined) { - break - } - this.exits.delete(oldestLeaseId) - } + evictStaleLeaseEntries(this.exits, now) } consumeAfter(leaseId: string, startedSequence: number | undefined): RecentPtyExit | null { @@ -31,7 +39,7 @@ export class CodexSessionMigrationRecentExits { !exit || startedSequence === undefined || exit.sequence <= startedSequence || - Date.now() - exit.recordedAt > RETENTION_MS + Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS ) { return null } @@ -43,7 +51,7 @@ export class CodexSessionMigrationRecentExits { if (!exit) { return false } - if (Date.now() - exit.recordedAt > RETENTION_MS) { + if (Date.now() - exit.recordedAt > CODEX_SESSION_MIGRATION_LEASE_RETENTION_MS) { this.exits.delete(leaseId) return false } diff --git a/src/main/codex/codex-session-migration-scheduler.test.ts b/src/main/codex/codex-session-migration-scheduler.test.ts index a1a8d4e8d7a..b495afb8e0e 100644 --- a/src/main/codex/codex-session-migration-scheduler.test.ts +++ b/src/main/codex/codex-session-migration-scheduler.test.ts @@ -572,7 +572,35 @@ describe('createCodexSessionMigrationScheduler', () => { ) }) - it('matches every late duplicate ignored callback without poisoning reuse', async () => { + it('releases a stranded active launch once the ignored incarnation ages out', async () => { + const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) + const scheduler = createCodexSessionMigrationScheduler({ + isEligible: () => true, + isQuitting: () => false, + resolveSystemCodexHomePathOverride: () => undefined, + startBackfill, + startIndexHeal: vi.fn().mockResolvedValue(null), + initialDelayMs: 1_000 + }) + + // The ignored incarnation never reports its exit, so only the newer launch ever finishes. + scheduler.ignoreLaunch('stable-pty', 1) + scheduler.beginLaunch('stable-pty', false, new Date(), 2) + await vi.advanceTimersByTimeAsync(61_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: false }), + undefined + ) + + scheduler.finishLaunch('stable-pty', 3) + await vi.advanceTimersByTimeAsync(1_000) + expect(startBackfill).toHaveBeenLastCalledWith( + expect.objectContaining({ writeCompletionMarker: true }), + undefined + ) + }) + + it('keeps late duplicate ignored callbacks from poisoning reuse', async () => { const startBackfill = vi.fn().mockResolvedValue({ stopped: false }) const scheduler = createCodexSessionMigrationScheduler({ isEligible: () => true, @@ -585,8 +613,8 @@ describe('createCodexSessionMigrationScheduler', () => { scheduler.ignoreLaunch('stable-pty', 1) scheduler.finishLaunch('stable-pty', 3) - expect(scheduler.matchesIgnoredLaunchExit('stable-pty', 2)).toBe(true) - expect(scheduler.matchesIgnoredLaunchExit('stable-pty', 2)).toBe(true) + scheduler.ignoreLaunch('stable-pty', 2) + scheduler.ignoreLaunch('stable-pty', 2) scheduler.beginLaunch('stable-pty', false, new Date(), 4) await vi.advanceTimersByTimeAsync(1_000) expect(startBackfill).toHaveBeenCalledTimes(1) diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index 7e493c31cbd..9f08d83c5ea 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -1,4 +1,5 @@ import { getCodexSessionBackfillDate } from './codex-session-backfill-date' +import { CodexSessionMigrationIgnoredLaunches } from './codex-session-migration-ignored-launches' import { CodexSessionMigrationRecentExits } from './codex-session-migration-recent-exits' import type { CodexSessionBackfillDate, @@ -18,7 +19,6 @@ export type CodexSessionMigrationScheduler = { startedSequence?: number ): void ignoreLaunch(leaseId: string, startedSequence: number): void - matchesIgnoredLaunchExit(leaseId: string, startedSequence: number): boolean finishLaunch(leaseId: string, exitSequence?: number): void scheduleInitialRun(): void scheduleRun(fullScanRequired?: boolean): void @@ -45,7 +45,7 @@ export function createCodexSessionMigrationScheduler(args: { let migrationTask: Promise | null = null const activeLaunches = new Map() // Why: non-shared reattach exits must not masquerade as exit-before-begin races for a reused id. - const ignoredLaunches = new Set() + const ignoredLaunches = new CodexSessionMigrationIgnoredLaunches() const ignoredPtyExits = new CodexSessionMigrationRecentExits() const earlyPtyExits = new CodexSessionMigrationRecentExits() let activeRunStopObserved = false @@ -216,9 +216,6 @@ export function createCodexSessionMigrationScheduler(args: { } ignoredLaunches.add(leaseId) }, - matchesIgnoredLaunchExit(leaseId, startedSequence): boolean { - return ignoredPtyExits.matchesAfter(leaseId, startedSequence) - }, finishLaunch(leaseId, exitSequence): void { if (ignoredLaunches.delete(leaseId)) { if (exitSequence !== undefined) { diff --git a/src/main/index.ts b/src/main/index.ts index 922480e205e..f49e07988e9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -420,13 +420,6 @@ function handleCodexHomePtySpawned(args: { codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) return } - if ( - !paneAccount && - homeRoute === undefined && - codexSessionMigration?.matchesIgnoredLaunchExit(args.id, args.startedSequence) - ) { - return - } } const fullScanRequired = codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, {