fix(codex): publish Windows system-default sessions (#12611)

* fix(codex): publish Windows system-default sessions

* fix(codex): close launch-scheduling races in session migration scheduler

* fix(codex): bound repeated session migration audits

* fix(codex): preserve delayed session publication passes

* fix(codex): bound failed session audit events

* fix(codex): fence stale session migration markers

* chore: preserve main formatting after merge

* perf(codex): bound launch session migration scans

* perf(codex): preserve coalesced migration scope

* fix(codex): preserve scheduled migration recovery

* fix(codex): preserve session migration recovery

* fix(codex): close session migration launch races

* fix(codex): harden session migration completion
This commit is contained in:
Brennan Benson 2026-08-07 16:53:41 -07:00 committed by GitHub
parent 20aeb0cb99
commit 38275c2aa2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1890 additions and 230 deletions

View File

@ -1004,10 +1004,62 @@ describe('CodexRuntimeHomeService', () => {
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
// Why: a mirror launch never leaves the real home, so its backfill stays valid.
expect(existsSync(markerPath)).toBe(true)
expect(existsSync(markerPath)).toBe(false)
service.finishHostSystemDefaultSessionMigrationPass()
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
true
)
service.finishHostSystemDefaultSessionMigrationPass()
writeFileSync(
markerPath,
`${JSON.stringify({
version: 3,
systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'),
summary: { scannedFiles: 1 }
})}\n`,
'utf-8'
)
service.prepareForCodexLaunch()
writeFileSync(
markerPath,
`${JSON.stringify({
version: 3,
systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'),
summary: { scannedFiles: 1 }
})}\n`,
'utf-8'
)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
false
)
expect(existsSync(markerPath)).toBe(false)
service.prepareForCodexLaunch()
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
false
)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(null)).toBeNull()
service.finishHostSystemDefaultSessionMigrationPass()
writeFileSync(
markerPath,
`${JSON.stringify({
version: 3,
systemSessionsRoot: join(getSystemCodexHomePath(), 'sessions'),
summary: { scannedFiles: 1 }
})}\n`,
'utf-8'
)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(null, { reattached: true })).toBe(
false
)
expect(existsSync(markerPath)).toBe(false)
store.updateSettings({
codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} }
})
service.prepareForCodexLaunch()
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
true
)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
@ -1037,6 +1089,10 @@ 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')
writeFileSync(markerPath, '{}\n', 'utf-8')
@ -1045,6 +1101,11 @@ describe('CodexRuntimeHomeService', () => {
getRuntimeCodexHomePath()
)
expect(existsSync(markerPath)).toBe(true)
expect(
service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath(), {
launchEnv: { CODEX_HOME: perSpawnCustomHome }
})
).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.
@ -1719,7 +1780,11 @@ describe('CodexRuntimeHomeService', () => {
// 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(
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(

View File

@ -41,7 +41,6 @@ import {
import {
getOrcaManagedCodexHomePath,
getOrcaUserDataPath,
getCodexSessionBackfillStateDirPath,
getSystemCodexHomePath,
resolveOrcaManagedCodexHomePath,
syncCodexGlobalInstructionsIntoManagedHome,
@ -68,7 +67,11 @@ import {
} from './runtime-selection'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
import {
hasCompletedCodexSessionBackfillMarker,
invalidateCodexSessionBackfillMarker
} from '../codex/codex-session-backfill-marker'
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
import {
codexAuthCouldBelongToManagedAccount,
@ -181,6 +184,9 @@ export class CodexRuntimeHomeService {
private sharedAuthRefreshBlockedByManagedTransition = false
// Why: transient auth.json read/parse failures must not deselect an account.
private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace()
private hostSystemDefaultSessionMigrationPending = false
private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
private pendingHostSystemDefaultSessionMigrationTarget: string | null = null
constructor(private readonly store: Store) {
this.safeRecoverInterruptedRuntimeAuthOperation()
@ -253,6 +259,53 @@ export class CodexRuntimeHomeService {
return this.getRuntimeHomePath()
}
beginHostSystemDefaultSessionMigrationLaunch(
codexHomePath: string | null,
options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {}
): boolean | null {
if (
!this.isHostSystemDefaultSessionMigrationEligible() ||
(!codexHomePath && !options.reattached) ||
(codexHomePath &&
normalizeRuntimePathForComparison(codexHomePath) !==
normalizeRuntimePathForComparison(this.getRuntimeHomePath()))
) {
return null
}
// Why: an older pass can clear launch preparation while PTY spawn awaits recovery.
return this.invalidateBackfillAfterManagedSystemDefaultLaunch(
options.reattached && !codexHomePath ? undefined : options.launchEnv
)
}
isHostSystemDefaultSessionMigrationEligible(): boolean {
return (
normalizeCodexRuntimeSelection(this.store.getSettings()).host === null &&
!hasCustomCodexHomeOverrideForLaunch()
)
}
prepareHostSystemDefaultSessionMigrationPass(): boolean {
const paths = resolveCodexSessionBackfillPaths(
resolveHostCodexSessionSourceHome(this.store.getSettings())
)
if (
this.hostSystemDefaultSessionMigrationPending &&
this.pendingHostSystemDefaultSessionMigrationTarget !== paths.systemSessionsRoot
) {
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true
this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot
}
invalidateCodexSessionBackfillMarker(paths.markerPath)
return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan
}
finishHostSystemDefaultSessionMigrationPass(): void {
this.hostSystemDefaultSessionMigrationPending = false
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
this.pendingHostSystemDefaultSessionMigrationTarget = null
}
// Why: a managed HOST account runs against its own self-contained CODEX_HOME
// (codex-accounts/<id>/home) rather than the shared runtime mirror. Its
// auth.json lives there and codex refreshes it in place, so two accounts never
@ -404,18 +457,26 @@ export class CodexRuntimeHomeService {
this.lastHostAccountUsedSelfContainedHome = false
}
private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void {
private invalidateBackfillAfterManagedSystemDefaultLaunch(
launchEnv?: NodeJS.ProcessEnv
): boolean | null {
const settings = this.store.getSettings()
if (normalizeCodexRuntimeSelection(settings).host !== null) {
return
if (
normalizeCodexRuntimeSelection(settings).host !== null ||
hasCustomCodexHomeOverrideForLaunch(launchEnv)
) {
return null
}
// Why: reached only when the real-home lane is selected but its gate is off,
// so the launch runs on the mirror and the backfill marker is stale.
if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) {
invalidateCodexSessionBackfillMarker(
join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json')
if (!this.hostSystemDefaultSessionMigrationPending) {
const paths = resolveCodexSessionBackfillPaths(
resolveHostCodexSessionSourceHome(this.store.getSettings())
)
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan =
!hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
this.pendingHostSystemDefaultSessionMigrationTarget = paths.systemSessionsRoot
this.hostSystemDefaultSessionMigrationPending = true
}
return this.prepareHostSystemDefaultSessionMigrationPass()
}
private startWslSessionBridgeForLaunch(

View File

@ -29,6 +29,7 @@ describe('Windows System Default Codex home ownership', () => {
SHELL: 'powershell.exe'
})
).toBe(false)
expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true)
})
})

View File

@ -0,0 +1,118 @@
import type { Stats } from 'node:fs'
import { lstat } from 'node:fs/promises'
import {
appendCodexSessionHealAuditRecord,
createCodexSessionBackfillAuditWriter,
createCodexSessionBackfillDiagnosticEventId,
createCodexSessionBackfillFileEventId,
readCodexSessionBackfillAuditCoverage,
recordExistingCodexSessionForHeal,
type CodexSessionBackfillAuditWriter
} from './codex-session-backfill-audit'
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
export type CodexSessionBackfillAuditPass = {
appendRecord: CodexSessionBackfillAuditWriter
recordExisting(
summary: CodexSessionBackfillSummary,
source: string,
target: string,
targetStat: Stats | null
): Promise<void>
recordPublished(
summary: CodexSessionBackfillSummary,
action: 'hardlink' | 'copy',
source: string,
target: string
): Promise<void>
recordDiagnostic(
record: {
action: 'copy-unsupported' | 'failed'
source: string
target: string
error?: string
linkError?: string
errorCode?: string
linkErrorCode?: string
},
sourceStat: Stats | null
): Promise<void>
finish(summary: CodexSessionBackfillSummary): Promise<void>
}
export async function createCodexSessionBackfillAuditPass(
auditLogPath: string
): Promise<CodexSessionBackfillAuditPass> {
const coverage = await readCodexSessionBackfillAuditCoverage(auditLogPath)
const writeAuditRecord = createCodexSessionBackfillAuditWriter(auditLogPath)
let auditChanged = false
const appendRecord: CodexSessionBackfillAuditWriter = async (record) => {
const appended = await writeAuditRecord(record)
auditChanged ||= appended
return appended
}
return {
appendRecord,
async recordExisting(summary, source, target, targetStat): Promise<void> {
const fileEventId = targetStat
? createCodexSessionBackfillFileEventId(target, targetStat)
: undefined
if (fileEventId && coverage.fileEventIds.has(fileEventId)) {
summary.skippedExistingFiles += 1
return
}
if (
await recordExistingCodexSessionForHeal(appendRecord, summary, source, target, fileEventId)
) {
if (fileEventId) {
coverage.fileEventIds.add(fileEventId)
}
}
},
async recordPublished(summary, action, source, target): Promise<void> {
const targetStat = await readCodexSessionTargetStat(target)
const fileEventId = targetStat
? createCodexSessionBackfillFileEventId(target, targetStat)
: undefined
if (
await appendCodexSessionHealAuditRecord(appendRecord, summary, {
action,
source,
target,
...(fileEventId ? { fileEventId } : {})
})
) {
if (fileEventId) {
coverage.fileEventIds.add(fileEventId)
}
}
},
async recordDiagnostic(record, sourceStat): Promise<void> {
const diagnosticEventId = createCodexSessionBackfillDiagnosticEventId({
...record,
sourceStat
})
if (coverage.diagnosticEventIds.has(diagnosticEventId)) {
return
}
if (await appendRecord({ ...record, diagnosticEventId })) {
coverage.diagnosticEventIds.add(diagnosticEventId)
}
},
async finish(summary): Promise<void> {
if (auditChanged || !coverage.hasRunSummary) {
await appendRecord({ action: 'run-summary', ...summary })
}
}
}
}
/** A broken symlink at the target still counts as taken. */
export async function readCodexSessionTargetStat(entryPath: string): Promise<Stats | null> {
try {
return await lstat(entryPath)
} catch {
return null
}
}

View File

@ -1,10 +1,22 @@
import { randomUUID } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream, type Stats } from 'node:fs'
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import { createInterface } from 'node:readline'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
export type CodexSessionBackfillAuditWriter = (record: Record<string, unknown>) => Promise<boolean>
export type CodexSessionBackfillAuditCoverage = {
fileEventIds: Set<string>
diagnosticEventIds: Set<string>
hasRunSummary: boolean
}
const HEAL_AUDIT_ACTIONS = new Set(['hardlink', 'copy', 'existing'])
const DIAGNOSTIC_AUDIT_ACTIONS = new Set(['copy-unsupported', 'failed'])
export function createCodexSessionBackfillAuditWriter(
auditLogPath: string
): CodexSessionBackfillAuditWriter {
@ -48,28 +60,139 @@ export function createCodexSessionBackfillAuditWriter(
}
}
export async function readCodexSessionBackfillAuditCoverage(
auditLogPath: string
): Promise<CodexSessionBackfillAuditCoverage> {
const coverage: CodexSessionBackfillAuditCoverage = {
fileEventIds: new Set<string>(),
diagnosticEventIds: new Set<string>(),
hasRunSummary: false
}
const input = createReadStream(auditLogPath, { encoding: 'utf-8' })
const lines = createInterface({ input, crlfDelay: Infinity })
try {
for await (const raw of lines) {
try {
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
continue
}
const record = parsed as Record<string, unknown>
coverage.hasRunSummary ||= record.action === 'run-summary'
if (
typeof record.action === 'string' &&
HEAL_AUDIT_ACTIONS.has(record.action) &&
typeof record.fileEventId === 'string'
) {
coverage.fileEventIds.add(record.fileEventId)
}
if (
typeof record.action === 'string' &&
DIAGNOSTIC_AUDIT_ACTIONS.has(record.action) &&
typeof record.diagnosticEventId === 'string'
) {
coverage.diagnosticEventIds.add(record.diagnosticEventId)
}
} catch {
// Torn audit tails are quarantined by the writer's leading newline.
}
}
} catch (error) {
if (!isNotFoundError(error)) {
throw error
}
}
return coverage
}
export function createCodexSessionBackfillFileEventId(targetPath: string, stat: Stats): string {
const fileIdentity = [
stat.dev,
stat.ino,
stat.birthtimeMs,
stat.size,
stat.mtimeMs,
stat.ctimeMs
].join('\0')
return createHash('sha256')
.update(normalizeRuntimePathForComparison(targetPath))
.update('\0')
.update(fileIdentity)
.digest('hex')
}
export function createCodexSessionBackfillDiagnosticEventId(args: {
action: 'copy-unsupported' | 'failed'
source: string
target: string
sourceStat: Stats | null
errorCode?: string
linkErrorCode?: string
}): string {
const sourceIdentity = args.sourceStat
? [
args.sourceStat.dev,
args.sourceStat.ino,
args.sourceStat.birthtimeMs,
args.sourceStat.size,
args.sourceStat.mtimeMs,
args.sourceStat.ctimeMs
].join('\0')
: 'unavailable'
return createHash('sha256')
.update(args.action)
.update('\0')
.update(normalizeRuntimePathForComparison(args.source))
.update('\0')
.update(normalizeRuntimePathForComparison(args.target))
.update('\0')
.update(sourceIdentity)
.update('\0')
.update(args.errorCode ?? '')
.update('\0')
.update(args.linkErrorCode ?? '')
.digest('hex')
}
export function describeCodexSessionBackfillErrorCode(error: unknown): string {
const code = (error as NodeJS.ErrnoException | null)?.code
return typeof code === 'string' && code
? code
: error instanceof Error
? error.name
: typeof error
}
export async function appendCodexSessionHealAuditRecord(
writer: CodexSessionBackfillAuditWriter,
summary: CodexSessionBackfillSummary,
record: Record<string, unknown>
): Promise<void> {
if (!(await writer(record))) {
): Promise<boolean> {
const appended = await writer(record)
if (!appended) {
summary.failedHealAuditRecords += 1
}
return appended
}
export async function recordExistingCodexSessionForHeal(
writer: CodexSessionBackfillAuditWriter,
summary: CodexSessionBackfillSummary,
source: string,
target: string
): Promise<void> {
target: string,
fileEventId?: string
): Promise<boolean> {
summary.skippedExistingFiles += 1
// Why: this also recovers a rollout installed before a crash or audit
// failure; thread/read is idempotent for a pre-existing real-home file.
await appendCodexSessionHealAuditRecord(writer, summary, {
return appendCodexSessionHealAuditRecord(writer, summary, {
action: 'existing',
source,
target
target,
...(fileEventId ? { fileEventId } : {})
})
}
function isNotFoundError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}

View File

@ -0,0 +1,65 @@
import { join, relative, sep } from 'node:path'
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
import type {
CodexSessionBackfillDate,
CodexSessionBackfillOptions
} from './codex-session-backfill-types'
export function getCodexSessionBackfillDate(date = new Date()): CodexSessionBackfillDate {
return [
String(date.getUTCFullYear()).padStart(4, '0'),
String(date.getUTCMonth() + 1).padStart(2, '0'),
String(date.getUTCDate()).padStart(2, '0')
]
}
export function isCodexSessionRolloutPath(sessionsRoot: string, filePath: string): boolean {
const pathParts = relative(sessionsRoot, filePath).split(sep)
if (pathParts.length !== 4) {
return false
}
const [year, month, day, fileName] = pathParts
return (
/^\d{4}$/.test(year) &&
/^\d{2}$/.test(month) &&
/^\d{2}$/.test(day) &&
/^rollout-.+\.jsonl$/.test(fileName)
)
}
export async function* listCodexSessionBackfillFilesForDates(
sessionsRoot: string,
options: CodexSessionBackfillOptions,
onDirectoryError: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
const scanRoots = resolveCodexSessionBackfillDateRoots(sessionsRoot, options.scanDates)
for (const scanRoot of scanRoots) {
yield* listCodexSessionJsonlFilesIncrementally(
scanRoot,
options,
async (directoryPath, error) => {
if (directoryPath !== scanRoot || !isNotFoundError(error)) {
await onDirectoryError(directoryPath, error)
}
}
)
}
}
function resolveCodexSessionBackfillDateRoots(
sessionsRoot: string,
scanDates: readonly CodexSessionBackfillDate[] | undefined
): string[] {
if (!scanDates?.length) {
return [sessionsRoot]
}
return scanDates
.filter(
([year, month, day]) => /^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day)
)
.map(([year, month, day]) => join(sessionsRoot, year, month, day))
}
function isNotFoundError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}

View File

@ -6,6 +6,11 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types
// Why: bump to re-run the backfill for every host after a layout or semantics
// change; the run itself stays skip-existing so re-runs never overwrite.
const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3
let markerInvalidationGeneration = 0
export function captureCodexSessionBackfillMarkerGeneration(): number {
return markerInvalidationGeneration
}
export function hasCompletedCodexSessionBackfillMarker(
markerPath: string,
@ -16,13 +21,22 @@ export function hasCompletedCodexSessionBackfillMarker(
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;
// let the incremental async walk verify it without blocking the main thread.
return marker.summary?.scannedFiles !== 0
} catch {
return false
}
@ -31,8 +45,13 @@ export function hasCompletedCodexSessionBackfillMarker(
export function writeCodexSessionBackfillMarker(
markerPath: string,
systemSessionsRoot: string,
summary: CodexSessionBackfillSummary
summary: CodexSessionBackfillSummary,
expectedGeneration: number
): void {
// Why: a launch can invalidate this pass before its delayed replacement begins.
if (expectedGeneration !== markerInvalidationGeneration) {
return
}
mkdirSync(dirname(markerPath), { recursive: true })
writeFileAtomically(
markerPath,
@ -50,11 +69,23 @@ export function writeCodexSessionBackfillMarker(
}
export function invalidateCodexSessionBackfillMarker(markerPath: string): void {
markerInvalidationGeneration += 1
try {
// Why: a managed-lane system-default launch can create new source
// rollouts, so a prior one-time marker must not suppress the next opt-in.
rmSync(markerPath, { force: true })
} catch (error) {
console.warn('[codex-session-backfill] Failed to invalidate completion marker:', error)
try {
writeFileAtomically(
markerPath,
`${JSON.stringify({ version: 0, invalidatedAt: Date.now() })}\n`
)
} catch (fallbackError) {
throw new AggregateError(
[error, fallbackError],
'Failed to invalidate Codex session backfill marker'
)
}
}
}

View File

@ -24,4 +24,16 @@ export type CodexSessionBackfillPaths = {
export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions & {
/** Polled before each target mutation; true stops with progress preserved. */
shouldStop?: () => boolean
/** Limits a launch-triggered pass to the date directories that can contain its rollouts. */
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
/** Rechecks launch scheduling state immediately before marker publication. */
canWriteCompletionMarker?: () => boolean
}
export type CodexSessionBackfillDate = readonly [year: string, month: string, day: string]

View File

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
appendFileSync,
existsSync,
lstatSync,
mkdirSync,
@ -23,12 +24,14 @@ const { homedirMock } = vi.hoisted(() => ({
const { fsMockState } = vi.hoisted(() => ({
fsMockState: {
failLink: false,
failInstallLink: false,
failInstallLinkTransiently: false,
failLinkTransiently: false,
failLinkPermission: false,
raceTargetIntoExistence: false,
failCopy: false,
failMarkerRm: false,
failMarkerReplacement: false,
failAuditMkdirOnce: false,
failAuditWrites: false,
failMkdirPath: null as string | null,
failDirectoryPath: null as string | null,
failLstatPath: null as string | null
}
@ -43,6 +46,30 @@ vi.mock('node:fs', async () => {
return false
}
return actual.existsSync(...args)
},
rmSync: (...args: Parameters<typeof actual.rmSync>) => {
if (
fsMockState.failMarkerRm &&
String(args[0]).includes('codex-session-backfill') &&
String(args[0]).endsWith('backfill-complete.json')
) {
const error = new Error('EACCES: marker removal failed') as NodeJS.ErrnoException
error.code = 'EACCES'
throw error
}
return actual.rmSync(...args)
},
renameSync: (...args: Parameters<typeof actual.renameSync>) => {
if (
fsMockState.failMarkerReplacement &&
String(args[1]).includes('codex-session-backfill') &&
String(args[1]).endsWith('backfill-complete.json')
) {
const error = new Error('EACCES: marker replacement failed') as NodeJS.ErrnoException
error.code = 'EACCES'
throw error
}
return actual.renameSync(...args)
}
}
})
@ -52,6 +79,11 @@ vi.mock('node:fs/promises', async () => {
return {
...actual,
mkdir: (...args: Parameters<typeof actual.mkdir>) => {
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(
@ -91,30 +123,17 @@ 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<typeof actual.copyFile>) => {
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') as NodeJS.ErrnoException
if (fsMockState.failLinkPermission && String(args[0]).includes('codex-runtime-home')) {
const error = new Error('EACCES: hardlink permission denied') as NodeJS.ErrnoException
error.code = 'EACCES'
throw error
}
return actual.copyFile(...args)
return actual.link(...args)
},
opendir: (...args: Parameters<typeof actual.opendir>) => {
if (args[0] === fsMockState.failDirectoryPath) {
@ -140,6 +159,7 @@ import {
resolveCodexSessionBackfillPaths,
startCodexSessionBackfillInBackground
} from './codex-session-backfill'
import { invalidateCodexSessionBackfillMarker } from './codex-session-backfill-marker'
let fakeHomeDir: string
let userDataDir: string
@ -168,27 +188,40 @@ function writeManagedSession(relativePath: string, contents: string): string {
return filePath
}
function readAuditActions(): string[] {
type BackfillAuditRecord = {
action: string
target?: string
fileEventId?: string
diagnosticEventId?: string
}
function readBackfillAuditRecords(): BackfillAuditRecord[] {
return readFileSync(getAuditLogPath(), 'utf-8')
.split('\n')
.filter(Boolean)
.flatMap((line) => {
try {
return [(JSON.parse(line) as { action: string }).action]
return [JSON.parse(line) as BackfillAuditRecord]
} catch {
return []
}
})
}
function readAuditActions(): string[] {
return readBackfillAuditRecords().map((record) => record.action)
}
beforeEach(() => {
fsMockState.failLink = false
fsMockState.failInstallLink = false
fsMockState.failInstallLinkTransiently = false
fsMockState.failLinkTransiently = false
fsMockState.failLinkPermission = false
fsMockState.raceTargetIntoExistence = false
fsMockState.failCopy = false
fsMockState.failMarkerRm = false
fsMockState.failMarkerReplacement = false
fsMockState.failAuditMkdirOnce = false
fsMockState.failAuditWrites = false
fsMockState.failMkdirPath = null
fsMockState.failDirectoryPath = null
fsMockState.failLstatPath = null
fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-'))
@ -367,27 +400,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 relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl')
writeManagedSession(relativePath, '{"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'])
expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 })
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
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(
@ -406,9 +434,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(
@ -422,9 +449,33 @@ 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('keeps hardlink permission failures retryable', async () => {
fsMockState.failLinkPermission = true
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
writeManagedSession(relativePath, '{"id":"a"}\n')
const summary = await startCodexSessionBackfillInBackground()
expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 })
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
expect(existsSync(getMarkerPath())).toBe(false)
})
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(
@ -446,6 +497,33 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
expect(summary).toMatchObject({ scannedFiles: 0 })
expect(existsSync(getSystemSessionsRoot())).toBe(false)
})
it('bounds a launch pass to its rollout date directories', async () => {
const oldRelativePath = join('2025', '12', '31', 'rollout-old.jsonl')
const launchRelativePath = join('2026', '08', '05', 'rollout-launch.jsonl')
writeManagedSession(oldRelativePath, 'old\n')
writeManagedSession(launchRelativePath, 'launch\n')
const summary = await backfillManagedCodexSessionsIntoSystemHome(
resolveCodexSessionBackfillPaths(),
{ scanDates: [['2026', '08', '05']] }
)
expect(summary).toMatchObject({ scannedFiles: 1, linkedFiles: 1, failedDirectories: 0 })
expect(existsSync(join(getSystemSessionsRoot(), launchRelativePath))).toBe(true)
expect(existsSync(join(getSystemSessionsRoot(), oldRelativePath))).toBe(false)
})
it('treats a not-yet-created launch date as an empty bounded pass', async () => {
writeManagedSession(join('2025', '12', '31', 'rollout-old.jsonl'), 'old\n')
const summary = await backfillManagedCodexSessionsIntoSystemHome(
resolveCodexSessionBackfillPaths(),
{ scanDates: [['2026', '08', '05']] }
)
expect(summary).toMatchObject({ scannedFiles: 0, linkedFiles: 0, failedDirectories: 0 })
})
})
describe('startCodexSessionBackfillInBackground', () => {
@ -479,6 +557,81 @@ 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('rechecks launch state before publishing completion', async () => {
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
const summary = await startCodexSessionBackfillInBackground({
canWriteCompletionMarker: () => false
})
expect(summary).toMatchObject({ linkedFiles: 1 })
expect(existsSync(getMarkerPath())).toBe(false)
})
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
const raced = await startCodexSessionBackfillInBackground({
shouldStop: () => {
if (!invalidated) {
invalidated = true
invalidateCodexSessionBackfillMarker(getMarkerPath())
}
return false
}
})
expect(raced).toMatchObject({ linkedFiles: 1, stopped: false })
expect(existsSync(getMarkerPath())).toBe(false)
const racedAudit = readFileSync(getAuditLogPath(), 'utf-8')
const recovered = await startCodexSessionBackfillInBackground()
expect(recovered).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 })
expect(existsSync(getMarkerPath())).toBe(true)
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(racedAudit)
})
it('replaces a stale marker when direct removal fails', async () => {
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
await startCodexSessionBackfillInBackground()
fsMockState.failMarkerRm = true
invalidateCodexSessionBackfillMarker(getMarkerPath())
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 0 })
const recovered = await startCodexSessionBackfillInBackground()
expect(recovered).toMatchObject({ skippedExistingFiles: 1 })
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
})
it('fails launch preparation when a stale marker cannot be invalidated', async () => {
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
await startCodexSessionBackfillInBackground()
fsMockState.failMarkerRm = true
fsMockState.failMarkerReplacement = true
expect(() => invalidateCodexSessionBackfillMarker(getMarkerPath())).toThrow(
'Failed to invalidate Codex session backfill marker'
)
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
})
it('writes a completion marker and skips the walk on later runs', async () => {
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
@ -487,13 +640,160 @@ describe('startCodexSessionBackfillInBackground', () => {
expect(existsSync(getMarkerPath())).toBe(true)
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({ version: 3 })
// A file appearing after the marker must not be backfilled again.
// An ordinary call remains a no-op; only a launch-scheduled pass bypasses the marker.
writeManagedSession(join('2026', '07', '01', 'rollout-later.jsonl'), '{"id":"later"}\n')
const second = await startCodexSessionBackfillInBackground()
expect(second).toBeNull()
expect(
existsSync(join(getSystemSessionsRoot(), '2026', '07', '01', 'rollout-later.jsonl'))
).toBe(false)
const scheduled = await startCodexSessionBackfillInBackground({
ignoreCompletionMarker: true,
scanDates: [['2026', '07', '01']]
})
expect(scheduled).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
})
it('does not let a bounded pass certify older unscanned history', async () => {
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
await startCodexSessionBackfillInBackground()
invalidateCodexSessionBackfillMarker(getMarkerPath())
const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl')
writeManagedSession(missedRelativePath, 'missed\n')
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
const bounded = await startCodexSessionBackfillInBackground({
ignoreCompletionMarker: true,
scanDates: [['2026', '08', '05']]
})
expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
expect(existsSync(getMarkerPath())).toBe(false)
const recovered = await startCodexSessionBackfillInBackground()
expect(recovered).toMatchObject({ scannedFiles: 3, linkedFiles: 1 })
expect(existsSync(join(getSystemSessionsRoot(), missedRelativePath))).toBe(true)
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')
await startCodexSessionBackfillInBackground()
const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink')
invalidateCodexSessionBackfillMarker(getMarkerPath())
appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8')
await startCodexSessionBackfillInBackground()
const fileRecords = readBackfillAuditRecords().filter((record) =>
['hardlink', 'existing'].includes(record.action)
)
expect(fileRecords).toHaveLength(2)
expect(fileRecords[1]).toMatchObject({ action: 'existing' })
expect(fileRecords[1]?.fileEventId).not.toBe(firstRecord?.fileEventId)
})
it('keeps repeated launch invalidations audit-stable', async () => {
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
const first = await startCodexSessionBackfillInBackground()
expect(first).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 0 })
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
for (let pass = 0; pass < 2; pass += 1) {
invalidateCodexSessionBackfillMarker(getMarkerPath())
const repeated = await startCodexSessionBackfillInBackground()
expect(repeated).toMatchObject({
linkedFiles: 0,
copiedFiles: 0,
skippedExistingFiles: 1,
failedHealAuditRecords: 0
})
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit)
}
const fileRecords = readBackfillAuditRecords().filter((record) =>
['hardlink', 'copy', 'existing'].includes(record.action)
)
expect(fileRecords).toEqual([
expect.objectContaining({ action: 'hardlink', fileEventId: expect.any(String) })
])
})
it('recovers a post-install audit interruption without duplicating prior events', async () => {
const firstRelativePath = join('2026', '05', '26', 'rollout-a.jsonl')
const secondRelativePath = join('2026', '05', '26', 'rollout-b.jsonl')
writeManagedSession(firstRelativePath, '{"id":"a"}\n')
await startCodexSessionBackfillInBackground()
invalidateCodexSessionBackfillMarker(getMarkerPath())
writeManagedSession(secondRelativePath, '{"id":"b"}\n')
fsMockState.failAuditWrites = true
const interrupted = await startCodexSessionBackfillInBackground()
expect(interrupted).toMatchObject({ linkedFiles: 1, failedHealAuditRecords: 1 })
expect(existsSync(getMarkerPath())).toBe(false)
expect(
readBackfillAuditRecords().filter((record) =>
['hardlink', 'copy', 'existing'].includes(record.action)
)
).toHaveLength(1)
fsMockState.failAuditWrites = false
const recovered = await startCodexSessionBackfillInBackground()
expect(recovered).toMatchObject({
linkedFiles: 0,
skippedExistingFiles: 2,
failedHealAuditRecords: 0
})
expect(existsSync(getMarkerPath())).toBe(true)
const recoveredFileRecords = readBackfillAuditRecords().filter((record) =>
['hardlink', 'copy', 'existing'].includes(record.action)
)
expect(recoveredFileRecords).toHaveLength(2)
expect(new Set(recoveredFileRecords.map((record) => record.target))).toEqual(
new Set([
join(getSystemSessionsRoot(), firstRelativePath),
join(getSystemSessionsRoot(), secondRelativePath)
])
)
})
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 () => {
@ -534,19 +834,52 @@ 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()
expect(first).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 })
expect(existsSync(getMarkerPath())).toBe(true)
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
invalidateCodexSessionBackfillMarker(getMarkerPath())
const repeated = await startCodexSessionBackfillInBackground()
expect(repeated).toMatchObject({ skippedUnsupportedFilesystemFiles: 1, failedFiles: 0 })
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit)
expect(await startCodexSessionBackfillInBackground()).toBeNull()
})
it('keeps repeated unchanged per-file failures audit-stable', async () => {
fsMockState.failLinkTransiently = true
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
const first = await startCodexSessionBackfillInBackground()
expect(first).toMatchObject({ failedFiles: 1 })
const firstAudit = readFileSync(getAuditLogPath(), 'utf-8')
const repeated = await startCodexSessionBackfillInBackground()
expect(repeated).toMatchObject({ failedFiles: 1 })
expect(readFileSync(getAuditLogPath(), 'utf-8')).toBe(firstAudit)
})
it('records a new failure event when the source file changes', async () => {
fsMockState.failLinkTransiently = true
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
writeManagedSession(relativePath, '{"id":"a"}\n')
await startCodexSessionBackfillInBackground()
writeManagedSession(relativePath, '{"id":"a","changed":true}\n')
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()
@ -555,8 +888,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')

View File

@ -1,24 +1,24 @@
import { link, lstat, mkdir } from 'node:fs/promises'
import { dirname, join, relative, sep } from 'node:path'
import { dirname, join, relative } from 'node:path'
import {
getCodexSessionBackfillStateDirPath,
getOrcaManagedCodexHomePath,
getSystemCodexHomePath
} from './codex-home-paths'
import {
appendCodexSessionHealAuditRecord,
createCodexSessionBackfillAuditWriter,
recordExistingCodexSessionForHeal,
type CodexSessionBackfillAuditWriter
} from './codex-session-backfill-audit'
createCodexSessionBackfillAuditPass,
readCodexSessionTargetStat,
type CodexSessionBackfillAuditPass
} from './codex-session-backfill-audit-pass'
import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit'
import {
copySessionFileWithoutOverwrite,
isAtomicNoReplaceUnsupportedError
} from './codex-session-backfill-copy'
import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing'
isCodexSessionRolloutPath,
listCodexSessionBackfillFilesForDates
} from './codex-session-backfill-date'
import {
captureCodexSessionBackfillMarkerGeneration,
hasCompletedCodexSessionBackfillMarker,
writeCodexSessionBackfillMarker
writeCodexSessionBackfillMarker as writeBackfillMarker
} from './codex-session-backfill-marker'
import type {
CodexSessionBackfillOptions,
@ -87,7 +87,11 @@ async function runCodexSessionBackfillOncePerHost(
systemCodexHomePathOverride?: string
): Promise<CodexSessionBackfillSummary | null> {
const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride)
if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) {
const markerGeneration = captureCodexSessionBackfillMarkerGeneration()
if (
!options.ignoreCompletionMarker &&
hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
) {
return null
}
const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options)
@ -96,11 +100,14 @@ async function runCodexSessionBackfillOncePerHost(
if (
!summary.stopped &&
options.shouldStop?.() !== true &&
options.writeCompletionMarker !== false &&
options.canWriteCompletionMarker?.() !== false &&
(options.scanDates === undefined || options.writeBoundedCompletionMarker === true) &&
summary.failedFiles === 0 &&
summary.failedDirectories === 0 &&
summary.failedHealAuditRecords === 0
) {
writeCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary)
writeBackfillMarker(paths.markerPath, paths.systemSessionsRoot, summary, markerGeneration)
}
return summary
}
@ -109,8 +116,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,
@ -129,22 +136,18 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
failedFiles: 0,
failedHealAuditRecords: 0
}
const appendAuditRecord = createCodexSessionBackfillAuditWriter(paths.auditLogPath)
const auditPass = await createCodexSessionBackfillAuditPass(paths.auditLogPath)
const ensuredTargetDirectories = new Set<string>()
const managedSessionsRootExists = await checkManagedSessionsRoot(
paths,
summary,
appendAuditRecord
)
const managedSessionsRootExists = await checkManagedSessionsRoot(paths, summary, auditPass)
if (managedSessionsRootExists) {
for await (const managedSessionFilePath of listCodexSessionJsonlFilesIncrementally(
for await (const managedSessionFilePath of listCodexSessionBackfillFilesForDates(
paths.managedSessionsRoot,
options,
async (directoryPath, error) => {
// Why: a partial walk must remain retryable; otherwise an unreadable
// date directory would be silently omitted behind a completion marker.
summary.failedDirectories += 1
await appendAuditRecord({
await auditPass.appendRecord({
action: 'scan-failed',
source: directoryPath,
error: describeError(error)
@ -158,7 +161,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
break
}
summary.scannedFiles += 1
if (!isCodexRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) {
if (!isCodexSessionRolloutPath(paths.managedSessionsRoot, managedSessionFilePath)) {
summary.skippedUnexpectedFiles += 1
continue
}
@ -168,13 +171,13 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
paths,
managedSessionFilePath,
summary,
appendAuditRecord,
ensuredTargetDirectories
ensuredTargetDirectories,
auditPass
)
}
}
summary.stopped ||= options.shouldStop?.() === true
await appendAuditRecord({ action: 'run-summary', ...summary })
await auditPass.finish(summary)
// Why: opt-out can land while the async summary append is pending; carry it
// back to the marker gate so a managed launch cannot be hidden by stale completion.
summary.stopped ||= options.shouldStop?.() === true
@ -184,7 +187,7 @@ export async function backfillManagedCodexSessionsIntoSystemHome(
async function checkManagedSessionsRoot(
paths: CodexSessionBackfillPaths,
summary: CodexSessionBackfillSummary,
appendAuditRecord: CodexSessionBackfillAuditWriter
auditPass: CodexSessionBackfillAuditPass
): Promise<boolean> {
try {
await lstat(paths.managedSessionsRoot)
@ -196,7 +199,7 @@ async function checkManagedSessionsRoot(
// Why: existsSync collapses access failures into "missing," which could
// permanently hide sessions behind an incorrect completion marker.
summary.failedDirectories += 1
await appendAuditRecord({
await auditPass.appendRecord({
action: 'scan-failed',
source: paths.managedSessionsRoot,
error: describeError(error)
@ -205,26 +208,12 @@ async function checkManagedSessionsRoot(
}
}
function isCodexRolloutPath(sessionsRoot: string, filePath: string): boolean {
const pathParts = relative(sessionsRoot, filePath).split(sep)
if (pathParts.length !== 4) {
return false
}
const [year, month, day, fileName] = pathParts
return (
/^\d{4}$/.test(year) &&
/^\d{2}$/.test(month) &&
/^\d{2}$/.test(day) &&
/^rollout-.+\.jsonl$/.test(fileName)
)
}
async function backfillOneManagedSessionFile(
paths: CodexSessionBackfillPaths,
managedSessionFilePath: string,
summary: CodexSessionBackfillSummary,
appendAuditRecord: CodexSessionBackfillAuditWriter,
ensuredTargetDirectories: Set<string>
ensuredTargetDirectories: Set<string>,
auditPass: CodexSessionBackfillAuditPass
): Promise<void> {
if (await isSymbolicLink(managedSessionFilePath)) {
// Why: bridge-created symlinks already point at a file in the user's own
@ -234,16 +223,18 @@ async function backfillOneManagedSessionFile(
}
const relativePath = relative(paths.managedSessionsRoot, managedSessionFilePath)
const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath)
if (await pathEntryExists(systemSessionFilePath)) {
await recordExistingCodexSessionForHeal(
appendAuditRecord,
const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath)
if (existingTargetStat) {
await auditPass.recordExisting(
summary,
managedSessionFilePath,
systemSessionFilePath
systemSessionFilePath,
existingTargetStat
)
return
}
let linkAttempted = false
try {
const targetDirectory = dirname(systemSessionFilePath)
if (!ensuredTargetDirectories.has(targetDirectory)) {
@ -252,66 +243,56 @@ async function backfillOneManagedSessionFile(
await mkdir(targetDirectory, { recursive: true })
ensuredTargetDirectories.add(targetDirectory)
}
linkAttempted = true
await link(managedSessionFilePath, systemSessionFilePath)
summary.linkedFiles += 1
await appendCodexSessionHealAuditRecord(appendAuditRecord, summary, {
action: 'hardlink',
source: managedSessionFilePath,
target: systemSessionFilePath
})
await auditPass.recordPublished(
summary,
'hardlink',
managedSessionFilePath,
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 recordExistingCodexSessionForHeal(
appendAuditRecord,
await auditPass.recordExisting(
summary,
managedSessionFilePath,
systemSessionFilePath
systemSessionFilePath,
await readCodexSessionTargetStat(systemSessionFilePath)
)
return
}
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 appendCodexSessionHealAuditRecord(appendAuditRecord, summary, {
action: 'copy',
source: managedSessionFilePath,
target: systemSessionFilePath
})
} catch (copyError) {
if (isExistsError(copyError)) {
await recordExistingCodexSessionForHeal(
appendAuditRecord,
summary,
managedSessionFilePath,
systemSessionFilePath
)
return
}
if (isAtomicNoReplaceUnsupportedError(copyError)) {
summary.skippedUnsupportedFilesystemFiles += 1
await appendAuditRecord({
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: 'copy-unsupported',
source: managedSessionFilePath,
target: systemSessionFilePath
})
return
}
summary.failedFiles += 1
await appendAuditRecord({
target: systemSessionFilePath,
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
},
sourceStat
)
return
}
summary.failedFiles += 1
await auditPass.recordDiagnostic(
{
action: 'failed',
source: managedSessionFilePath,
target: systemSessionFilePath,
error: describeError(copyError),
linkError: describeError(linkError)
})
}
linkError: describeError(linkError),
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
},
sourceStat
)
}
}
@ -323,16 +304,6 @@ async function isSymbolicLink(filePath: string): Promise<boolean> {
}
}
/** Existence via lstat so a broken symlink at the target still counts as taken. */
async function pathEntryExists(entryPath: string): Promise<boolean> {
try {
await lstat(entryPath)
return true
} catch {
return false
}
}
function isExistsError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
@ -341,6 +312,11 @@ 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 === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'ENOSYS'
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

View File

@ -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<string>
healedAuditRecords: Set<string>
legacyHealedThreadIds: Set<string>
missingAuditRecords: Set<string>
legacyMissingThreadIds: Set<string>
} {
const healedThreadIds = new Set<string>()
const healedAuditRecords = new Set<string>()
const legacyHealedThreadIds = new Set<string>()
const missingAuditRecords = new Set<string>()
const legacyMissingThreadIds = new Set<string>()
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(

View File

@ -232,7 +232,7 @@ describe('runCodexSessionIndexHeal', () => {
expect(marker.healedThreads).toBe(3)
})
it('is a no-op when the marker matches the audit ledger size', async () => {
it('keeps second and later passes cheap when the audit ledger is unchanged', async () => {
const rig = createHealRig({
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }]
})
@ -245,11 +245,38 @@ describe('runCodexSessionIndexHeal', () => {
buildInvocation: rig.buildInvocation,
interBatchDelayMs: 0
})
const third = await runCodexSessionIndexHeal(rig.paths, {
buildInvocation: rig.buildInvocation,
interBatchDelayMs: 0
})
expect(second.outcome).toBe('up-to-date')
// One spawn from the first run only — the no-op run must not hit the CLI.
expect(third.outcome).toBe('up-to-date')
// One spawn from the first run only — no-op runs must not hit the CLI.
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') }]

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler'
describe('createCodexSessionMigrationScheduler', () => {
@ -8,12 +9,14 @@ describe('createCodexSessionMigrationScheduler', () => {
it('runs after a managed-account startup switches to host system default', async () => {
let eligible = false
const startBackfill = vi.fn().mockResolvedValue(null)
const prepareScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => eligible,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
prepareScheduledRun,
startBackfill,
startIndexHeal
})
@ -26,6 +29,222 @@ describe('createCodexSessionMigrationScheduler', () => {
scheduler.requestRun()
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
expect(startBackfill).toHaveBeenCalledOnce()
expect(prepareScheduledRun).not.toHaveBeenCalled()
})
it('schedules a delayed rerun after a shared-home launch', async () => {
vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0))
const prepareScheduledRun = vi.fn()
const finishScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
prepareScheduledRun,
finishScheduledRun,
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()
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({
scanDates: [['2026', '08', '05']],
ignoreCompletionMarker: true
}),
undefined
)
expect(prepareScheduledRun).toHaveBeenCalledOnce()
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
})
it('covers both launch and run dates when a delayed pass crosses midnight', async () => {
vi.setSystemTime(new Date('2026-08-05T23:59:59.500Z'))
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
})
scheduler.scheduleRun()
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({
scanDates: [
['2026', '08', '05'],
['2026', '08', '06']
]
}),
undefined
)
})
it('keeps launch passes full when no completed baseline can cover older failures', 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
})
scheduler.scheduleRun(true)
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({ scanDates: undefined }),
undefined
)
})
it('upgrades a bounded pass when its target changed before the timer fired', async () => {
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => '/moved-history',
prepareScheduledRun: () => true,
startBackfill,
startIndexHeal: vi.fn().mockResolvedValue(null),
initialDelayMs: 1_000
})
scheduler.scheduleRun()
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({ scanDates: undefined }),
'/moved-history'
)
})
it('delays the startup run from the latest shared-home launch', async () => {
const prepareScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
prepareScheduledRun,
startBackfill,
startIndexHeal,
initialDelayMs: 1_000
})
scheduler.scheduleInitialRun()
await vi.advanceTimersByTimeAsync(999)
scheduler.scheduleRun()
await vi.advanceTimersByTimeAsync(1)
expect(startBackfill).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(999)
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
expect(startBackfill).toHaveBeenCalledOnce()
expect(prepareScheduledRun).toHaveBeenCalledOnce()
})
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({ stopped: false })
const startIndexHeal = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseFirstIndexHeal = resolve
})
)
.mockResolvedValueOnce(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
prepareScheduledRun,
startBackfill,
startIndexHeal,
initialDelayMs: 1_000
})
scheduler.requestRun()
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
scheduler.scheduleRun()
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledOnce()
expect(prepareScheduledRun).not.toHaveBeenCalled()
releaseFirstIndexHeal?.()
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledTimes(2))
expect(prepareScheduledRun).toHaveBeenCalledOnce()
expect(prepareScheduledRun.mock.invocationCallOrder[0]).toBeLessThan(
startBackfill.mock.invocationCallOrder[1]!
)
})
it('prepares a delayed launch pass after an earlier migration settles before the timer', async () => {
let releaseFirstBackfill: (() => void) | undefined
let markerPresent = false
const prepareScheduledRun = vi.fn(() => {
markerPresent = false
})
const startBackfill = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseFirstBackfill = () => {
markerPresent = true
resolve()
}
})
)
.mockImplementationOnce(async () => {
expect(markerPresent).toBe(false)
})
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
prepareScheduledRun,
startBackfill,
startIndexHeal,
initialDelayMs: 1_000
})
scheduler.requestRun()
scheduler.scheduleRun()
releaseFirstBackfill?.()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(markerPresent).toBe(true)
expect(startIndexHeal).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1_000)
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
expect(prepareScheduledRun).toHaveBeenCalledOnce()
})
it('coalesces concurrent run requests and stops before index heal after opt-out', async () => {
@ -90,4 +309,233 @@ describe('createCodexSessionMigrationScheduler', () => {
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
})
it('preserves scheduled identity and target recovery after a stopped pass', async () => {
let eligible = true
let target = '/old-history'
let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined
const prepareScheduledRun = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true)
const finishScheduledRun = vi.fn()
const startBackfill = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ stopped: boolean }>((resolve) => {
releaseFirstBackfill = resolve
})
)
.mockResolvedValueOnce({ stopped: false })
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => eligible,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => target,
prepareScheduledRun,
finishScheduledRun,
startBackfill,
startIndexHeal: vi.fn().mockResolvedValue(null),
initialDelayMs: 1_000
})
scheduler.scheduleRun()
await vi.advanceTimersByTimeAsync(1_000)
const firstOptions = startBackfill.mock.calls[0]?.[0]
eligible = false
expect(firstOptions?.shouldStop()).toBe(true)
target = '/new-history'
releaseFirstBackfill?.({ stopped: true })
await vi.waitFor(() => expect(prepareScheduledRun).toHaveBeenCalledOnce())
expect(finishScheduledRun).not.toHaveBeenCalled()
eligible = true
scheduler.requestRun()
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
expect(startBackfill).toHaveBeenLastCalledWith(
expect.objectContaining({ ignoreCompletionMarker: true, scanDates: undefined }),
'/new-history'
)
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-08-05T23:59:59Z'))
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-08-07T01:00:00Z'))
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())
})
it('blocks marker publication when a newer launch pass is pending', async () => {
let releaseBackfill: ((result: { stopped: boolean }) => void) | undefined
const startBackfill = vi.fn(
(_options: CodexSessionBackfillOptions) =>
new Promise<{ stopped: boolean }>((resolve) => {
releaseBackfill = resolve
})
)
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
startBackfill,
startIndexHeal,
initialDelayMs: 1_000
})
scheduler.scheduleRun()
await vi.advanceTimersByTimeAsync(1_000)
const firstOptions = startBackfill.mock.calls[0]?.[0]
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(true)
scheduler.beginLaunch('pty-2')
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false)
scheduler.finishLaunch('pty-2')
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false)
releaseBackfill?.({ stopped: false })
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
})
it('turns an exit-before-begin race into a full recovery pass', async () => {
const finishScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
finishScheduledRun,
startBackfill,
startIndexHeal: vi.fn().mockResolvedValue(null),
initialDelayMs: 1_000
})
scheduler.finishLaunch('pty-fast-exit', 2)
scheduler.beginLaunch('pty-fast-exit', false, new Date(), 1)
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({ scanDates: undefined, writeBoundedCompletionMarker: false }),
undefined
)
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
})
it('does not consume an exit from an earlier stable-id incarnation', 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
})
scheduler.finishLaunch('stable-pty', 1)
scheduler.beginLaunch('stable-pty', false, new Date(), 2)
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({ writeCompletionMarker: false }),
undefined
)
})
it('preserves the pre-spawn UTC date when launch setup crosses midnight', async () => {
vi.setSystemTime(new Date('2026-08-06T00:00:01Z'))
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
})
scheduler.beginLaunch('pty-midnight', false, new Date('2026-08-05T23:59:59Z'))
scheduler.finishLaunch('pty-midnight')
await vi.advanceTimersByTimeAsync(1_000)
expect(startBackfill).toHaveBeenCalledWith(
expect.objectContaining({
scanDates: [
['2026', '08', '05'],
['2026', '08', '06']
]
}),
undefined
)
})
})

View File

@ -1,12 +1,29 @@
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
import { getCodexSessionBackfillDate } from './codex-session-backfill-date'
import type {
CodexSessionBackfillDate,
CodexSessionBackfillOptions
} from './codex-session-backfill-types'
type MigrationRun = (
options: CodexSessionBackfillOptions,
systemCodexHomePathOverride?: string
) => Promise<unknown>
const EARLY_PTY_EXIT_RETENTION_MS = 60_000
const MAX_EARLY_PTY_EXITS = 256
type EarlyPtyExit = { sequence: number; recordedAt: number }
export type CodexSessionMigrationScheduler = {
beginLaunch(
leaseId: string,
fullScanRequired?: boolean,
startedAt?: Date,
startedSequence?: number
): void
finishLaunch(leaseId: string, exitSequence?: number): void
scheduleInitialRun(): void
scheduleRun(fullScanRequired?: boolean): void
requestRun(): void
}
@ -14,24 +31,64 @@ export function createCodexSessionMigrationScheduler(args: {
isEligible: () => boolean
isQuitting: () => boolean
resolveSystemCodexHomePathOverride: () => string | undefined
prepareScheduledRun?: () => boolean | void
finishScheduledRun?: () => void
startBackfill: MigrationRun
startIndexHeal: MigrationRun
initialDelayMs?: number
}): CodexSessionMigrationScheduler {
let initialTimer: ReturnType<typeof setTimeout> | null = null
let scheduledTimer: ReturnType<typeof setTimeout> | null = null
let scheduledRunGeneration = 0
let pendingScheduledRunGeneration: number | null = null
const scheduledScanDates = new Map<string, CodexSessionBackfillDate>()
const pendingScanDates = new Map<string, CodexSessionBackfillDate>()
let scheduledFullScan = false
let pendingFullScan = false
let migrationTask: Promise<void> | null = null
const activeLaunches = new Map<string, Date>()
const earlyPtyExits = new Map<string, EarlyPtyExit>()
let activeRunStopObserved = false
let rerunRequested = false
const requestRun = (): void => {
const requestRun = (
rerunIfActive = false,
requestedGeneration?: number,
requestedScanDates: readonly CodexSessionBackfillDate[] = [],
requestedFullScan = false
): void => {
if (requestedGeneration !== undefined) {
pendingScheduledRunGeneration = Math.max(
requestedGeneration,
pendingScheduledRunGeneration ?? requestedGeneration
)
for (const scanDate of requestedScanDates) {
pendingScanDates.set(scanDate.join('-'), scanDate)
}
pendingFullScan ||= requestedFullScan
}
if (args.isQuitting() || !args.isEligible()) {
return
}
if (migrationTask) {
// Why: an account transition can re-enable migration while the prior run is still stopping.
rerunRequested ||= activeRunStopObserved
// Why: delayed launches and resumed account transitions must survive an older active pass.
rerunRequested ||= rerunIfActive || activeRunStopObserved
return
}
const isScheduledRun = pendingScheduledRunGeneration !== null
const activeScheduledRunGeneration = pendingScheduledRunGeneration
let preparationNeedsFullScan = false
if (isScheduledRun) {
pendingScheduledRunGeneration = null
// Why: an older active pass can rewrite the marker after launch invalidates it.
preparationNeedsFullScan = args.prepareScheduledRun?.() === true
}
const fullScanRequired = pendingFullScan || preparationNeedsFullScan
const scanDates =
!fullScanRequired && pendingScanDates.size > 0
? [...pendingScanDates.values()].sort(compareBackfillDates)
: undefined
pendingScanDates.clear()
pendingFullScan = false
activeRunStopObserved = false
rerunRequested = false
const shouldStop = (): boolean => {
@ -41,10 +98,27 @@ export function createCodexSessionMigrationScheduler(args: {
}
const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride()
let stoppedBackfill = false
let incompleteBackfill = true
const task = args
.startBackfill({ shouldStop }, systemCodexHomePathOverride)
.startBackfill(
{
shouldStop,
scanDates,
ignoreCompletionMarker: isScheduledRun,
writeCompletionMarker: activeLaunches.size === 0,
writeBoundedCompletionMarker:
isScheduledRun && activeLaunches.size === 0 && !fullScanRequired,
canWriteCompletionMarker: () =>
activeLaunches.size === 0 &&
scheduledTimer === null &&
pendingScheduledRunGeneration === null &&
(!isScheduledRun || activeScheduledRunGeneration === scheduledRunGeneration)
},
systemCodexHomePathOverride
)
.then((result) => {
stoppedBackfill = isStoppedMigrationResult(result)
incompleteBackfill = isIncompleteBackfillResult(result)
if (stoppedBackfill || shouldStop()) {
return
}
@ -58,9 +132,29 @@ export function createCodexSessionMigrationScheduler(args: {
void task.finally(() => {
if (migrationTask === task) {
migrationTask = null
const shouldRerun = rerunRequested || stoppedBackfill
const scheduledRunIncomplete = stoppedBackfill || activeRunStopObserved
const shouldRerun = rerunRequested || scheduledRunIncomplete
rerunRequested = false
activeRunStopObserved = false
if ((shouldRerun || incompleteBackfill) && isScheduledRun) {
pendingScheduledRunGeneration = Math.max(
activeScheduledRunGeneration!,
pendingScheduledRunGeneration ?? activeScheduledRunGeneration!
)
pendingFullScan ||= fullScanRequired
for (const scanDate of scanDates ?? []) {
pendingScanDates.set(scanDate.join('-'), scanDate)
}
}
if (
isScheduledRun &&
!incompleteBackfill &&
activeLaunches.size === 0 &&
scheduledTimer === null &&
pendingScheduledRunGeneration === null
) {
args.finishScheduledRun?.()
}
if (shouldRerun) {
requestRun()
}
@ -68,20 +162,150 @@ export function createCodexSessionMigrationScheduler(args: {
})
}
const armScheduledRun = (generation?: number): void => {
scheduledTimer = setTimeout(() => {
scheduledTimer = null
if (generation !== undefined) {
const currentDate = getCodexSessionBackfillDate()
scheduledScanDates.set(currentDate.join('-'), currentDate)
}
const scanDates = [...scheduledScanDates.values()].sort(compareBackfillDates)
scheduledScanDates.clear()
const fullScanRequired = scheduledFullScan
scheduledFullScan = false
// Why: a launch can invalidate the marker while a long index-heal pass is active.
requestRun(true, generation, scanDates, fullScanRequired)
}, 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 {
scheduleInitialRun(): void {
if (initialTimer) {
beginLaunch(leaseId, fullScanRequired = false, startedAt = new Date(), startedSequence): void {
if (args.isQuitting() || activeLaunches.has(leaseId)) {
return
}
initialTimer = setTimeout(() => {
initialTimer = null
requestRun()
}, args.initialDelayMs ?? 15_000)
if (consumeEarlyPtyExit(earlyPtyExits, leaseId, startedSequence)) {
scheduleRun(true)
return
}
activeLaunches.set(leaseId, startedAt)
scheduleRun(fullScanRequired)
},
requestRun
finishLaunch(leaseId, exitSequence): void {
const startedAt = activeLaunches.get(leaseId)
if (!startedAt) {
if (exitSequence !== undefined) {
recordEarlyPtyExit(earlyPtyExits, leaseId, exitSequence)
}
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,
requestRun: () => requestRun()
}
}
function getCodexSessionBackfillDatesBetween(
startedAt: Date,
finishedAt: Date
): CodexSessionBackfillDate[] {
const dates: CodexSessionBackfillDate[] = []
const cursor = new Date(
Date.UTC(startedAt.getUTCFullYear(), startedAt.getUTCMonth(), startedAt.getUTCDate())
)
const last = new Date(
Date.UTC(finishedAt.getUTCFullYear(), finishedAt.getUTCMonth(), finishedAt.getUTCDate())
)
while (cursor <= last) {
dates.push(getCodexSessionBackfillDate(cursor))
cursor.setUTCDate(cursor.getUTCDate() + 1)
}
return dates
}
function compareBackfillDates(
left: CodexSessionBackfillDate,
right: CodexSessionBackfillDate
): number {
return left.join('-').localeCompare(right.join('-'))
}
function recordEarlyPtyExit(
exits: Map<string, EarlyPtyExit>,
leaseId: string,
sequence: number
): void {
const now = Date.now()
for (const [id, exit] of exits) {
if (now - exit.recordedAt > EARLY_PTY_EXIT_RETENTION_MS) {
exits.delete(id)
}
}
exits.set(leaseId, { sequence, recordedAt: now })
while (exits.size > MAX_EARLY_PTY_EXITS) {
const oldestLeaseId = exits.keys().next().value
if (oldestLeaseId === undefined) {
break
}
exits.delete(oldestLeaseId)
}
}
function consumeEarlyPtyExit(
exits: Map<string, EarlyPtyExit>,
leaseId: string,
startedSequence: number | undefined
): boolean {
const exit = exits.get(leaseId)
exits.delete(leaseId)
return (
exit !== undefined &&
startedSequence !== undefined &&
exit.sequence > startedSequence &&
Date.now() - exit.recordedAt <= EARLY_PTY_EXIT_RETENTION_MS
)
}
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<string, unknown>)[key] : undefined
return typeof value === 'number' && value > 0
}

View File

@ -336,6 +336,7 @@ let codexUsage: CodexUsageStore | null = null
let openCodeUsage: OpenCodeUsageStore | null = null
let codexAccounts: CodexAccountService | null = null
let codexRuntimeHome: CodexRuntimeHomeService | null = null
let codexSessionMigration: ReturnType<typeof createCodexSessionMigrationScheduler> | null = null
let claudeAccounts: ClaudeAccountService | null = null
let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null
let runtime: OrcaRuntimeService | null = null
@ -389,6 +390,33 @@ let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null
let localPtyStartupReady: Promise<void> = Promise.resolve()
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
function handleCodexHomePtySpawned(args: {
id: string
codexHomePath: string | null
reattached?: boolean
launchEnv?: NodeJS.ProcessEnv
startedAt?: Date
startedSequence?: number
}): void {
const fullScanRequired =
codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, {
reattached: args.reattached,
launchEnv: args.launchEnv
}) ?? null
if (fullScanRequired !== null) {
codexSessionMigration?.beginLaunch(
args.id,
args.reattached === true || fullScanRequired,
args.startedAt,
args.startedSequence
)
}
}
function handlePtyExit(id: string, exitSequence: number): void {
codexSessionMigration?.finishLaunch(id, exitSequence)
}
// 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
@ -1400,6 +1428,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,
onCodexHomePtySpawned: handleCodexHomePtySpawned,
onPtyExit: handlePtyExit,
onBeforeUpdateQuit: () =>
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
updateInstallMode: resolveUpdateInstallMode(isServeMode),
@ -2254,21 +2284,21 @@ void app.whenReady().then(async () => {
codexRuntimeHome.isHostSystemDefaultRealHome() &&
isAgentStatusHooksEnabled(store?.getSettings())
)
const codexSessionMigration = createCodexSessionMigrationScheduler({
isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
codexSessionMigration = createCodexSessionMigrationScheduler({
isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true,
isQuitting: () => isQuitting,
resolveSystemCodexHomePathOverride: () =>
resolveHostCodexSessionSourceHome(store!.getSettings()),
prepareScheduledRun: () => codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(),
finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(),
startBackfill: startCodexSessionBackfillInBackground,
startIndexHeal: startCodexSessionIndexHealInBackground
})
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)
@ -2883,7 +2913,11 @@ void app.whenReady().then(async () => {
() => store!.getSettings(),
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
store,
prepareCodexSessionResumeForLaunch
prepareCodexSessionResumeForLaunch,
{
onCodexHomePtySpawned: handleCodexHomePtySpawned,
onPtyExit: handlePtyExit
}
)
await runtime.refreshRestoredOrchestrationAuthority()
await runtime.reconcileLegacyWorkerTerminals()

View File

@ -3074,16 +3074,25 @@ describe('registerPtyHandlers', () => {
new Promise<void>((resolve) => (releaseRecovery = resolve))
)
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const onCodexHomePtySpawned = 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,
{ onCodexHomePtySpawned }
)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
@ -3094,10 +3103,17 @@ describe('registerPtyHandlers', () => {
expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME)
)
expect(spawnMock).not.toHaveBeenCalled()
expect(onCodexHomePtySpawned).not.toHaveBeenCalled()
releaseRecovery()
await spawnPromise
const result = (await spawnPromise) as { id: string }
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(onCodexHomePtySpawned).toHaveBeenCalledWith({
id: result.id,
codexHomePath: TEST_CODEX_HOME,
startedAt: expect.any(Date),
startedSequence: expect.any(Number)
})
})
it('does not gate a bare local shell on managed Codex auth', async () => {
@ -3108,19 +3124,37 @@ describe('registerPtyHandlers', () => {
return ''
})
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const onCodexHomePtySpawned = vi.fn()
registerPtyHandlers(
mainWindow as never,
undefined,
() => TEST_CODEX_HOME,
(() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never,
undefined,
undefined,
{ onCodexHomePtySpawned }
)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(spawnMock).toHaveBeenCalledOnce()
expect(onCodexHomePtySpawned).toHaveBeenCalledWith({
id: result.id,
codexHomePath: TEST_CODEX_HOME,
startedAt: expect.any(Date),
startedSequence: expect.any(Number)
})
})
it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => {
@ -8883,6 +8917,7 @@ describe('registerPtyHandlers', () => {
const prepareClaudeAuth = vi.fn(() => {
throw new Error('replacement auth preflight must not run')
})
const onCodexHomePtySpawned = vi.fn()
let controller: RuntimeSpawnController | null = null
const runtime = {
setPtyController: vi.fn((value) => {
@ -8920,7 +8955,8 @@ describe('registerPtyHandlers', () => {
undefined,
undefined,
prepareClaudeAuth,
store as never
store as never,
{ onCodexHomePtySpawned }
)
const spawnController = controller as unknown as RuntimeSpawnController
await spawnController.spawn({
@ -9014,6 +9050,7 @@ describe('registerPtyHandlers', () => {
rows: 40,
cwd,
command: 'codex resume should-not-run',
launchAgent: 'codex',
worktreeId,
preAllocatedHandle: 'term-live-owner',
tabId,
@ -9039,6 +9076,13 @@ describe('registerPtyHandlers', () => {
incarnationId: 'inc-live-owner',
isReattach: true
})
expect(onCodexHomePtySpawned).toHaveBeenCalledWith({
id: 'pty-live-owner',
codexHomePath: null,
reattached: true,
startedAt: expect.any(Date),
startedSequence: expect.any(Number)
})
expect(claimedResult).toMatchObject({
id: 'pty-live-owner',
stablePaneOwner: { handle: 'term-live-owner', tabId, leafId }

View File

@ -1202,6 +1202,18 @@ export type PrepareCodexSessionResume = (args: {
launchEnv?: NodeJS.ProcessEnv
workspacePath?: string
}) => Promise<CodexSessionResumePreparation | null>
export type CodexHomePtySpawnedLifecycleArgs = {
id: string
codexHomePath: string | null
reattached?: boolean
launchEnv?: NodeJS.ProcessEnv
startedAt?: Date
startedSequence?: number
}
let ptyLifecycleSequence = 0
type PrepareClaudeAuth = (
target?: ClaudeAccountSelectionTarget
) => Promise<ClaudeRuntimeAuthPreparation>
@ -2243,6 +2255,8 @@ export function registerPtyHandlers(
awaitLocalPtyProviderStartup?: () => Promise<void>
// 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
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
onPtyExit?: (id: string, exitSequence: number) => 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 +3577,7 @@ export function registerPtyHandlers(
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence)
const release = preparePtyExitForRenderer(payload)
if (!release) {
return
@ -4290,6 +4305,8 @@ export function registerPtyHandlers(
},
adoptStablePane,
spawn: async (args) => {
const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined
const preAdoptedStablePane = args.adoptedStablePane ?? null
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
if (startupPromise) {
@ -4300,7 +4317,7 @@ export function registerPtyHandlers(
if (!handle) {
throw new Error('terminal_pane_owner_unknown')
}
return {
const result = {
id: preAdoptedStablePane.result.id,
...(preAdoptedStablePane.result.incarnationId
? { incarnationId: preAdoptedStablePane.result.incarnationId }
@ -4314,6 +4331,16 @@ export function registerPtyHandlers(
leafId: preAdoptedStablePane.owner.leafId
}
}
if (!args.connectionId) {
options?.onCodexHomePtySpawned?.({
id: result.id,
codexHomePath: null,
reattached: true,
startedAt: codexHomeLaunchStartedAt,
startedSequence: codexHomeLaunchStartedSequence
})
}
return result
}
if (!preAdoptedStablePane) {
await assertFolderWorkspacePtyPathUsable(args.worktreeId)
@ -4976,6 +5003,16 @@ export function registerPtyHandlers(
leafId: owner.surface.leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
if (!args.connectionId) {
options?.onCodexHomePtySpawned?.({
id: result.id,
codexHomePath: selectedCodexHomePath,
reattached: true,
startedAt: codexHomeLaunchStartedAt,
startedSequence: codexHomeLaunchStartedSequence,
...(env ? { launchEnv: env } : {})
})
}
return {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
@ -5140,6 +5177,15 @@ export function registerPtyHandlers(
}
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
sendPtySpawnedToRenderer(result.id)
if (!args.connectionId) {
options?.onCodexHomePtySpawned?.({
id: result.id,
codexHomePath: selectedCodexHomePath,
startedAt: codexHomeLaunchStartedAt,
startedSequence: codexHomeLaunchStartedSequence,
...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {})
})
}
const response = {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
@ -5623,6 +5669,8 @@ export function registerPtyHandlers(
}
}
) => {
const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined
const spawnTiming = createPtySpawnTiming()
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
if (startupPromise) {
@ -6601,6 +6649,19 @@ export function registerPtyHandlers(
}
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
sendPtySpawnedToRenderer(result.id)
if (!args.connectionId) {
options?.onCodexHomePtySpawned?.({
id: result.id,
codexHomePath: selectedCodexHomePath,
startedAt: codexHomeLaunchStartedAt,
startedSequence: codexHomeLaunchStartedSequence,
...(result.isReattach === true
? { reattached: true }
: baseEnv
? { launchEnv: baseEnv }
: {})
})
}
return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response)
} catch (err) {
if (pendingRegistrationPtyId) {
@ -7387,7 +7448,11 @@ export function registerHeadlessPtyRuntime(
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store,
prepareCodexSessionResume?: PrepareCodexSessionResume
prepareCodexSessionResume?: PrepareCodexSessionResume,
lifecycle?: {
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
onPtyExit?: (id: string, exitSequence: number) => 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 +7470,7 @@ export function registerHeadlessPtyRuntime(
getSettings,
prepareClaudeAuth,
store,
{ prepareCodexSessionResume }
{ prepareCodexSessionResume, ...lifecycle }
)
}

View File

@ -23,6 +23,7 @@ import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
import {
getLocalPtyProvider,
registerPtyHandlers,
type CodexHomePtySpawnedLifecycleArgs,
type GetSelectedCodexHomePath,
type PrepareCodexSessionResume
} from '../ipc/pty'
@ -98,6 +99,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
onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void
onPtyExit?: (id: string, exitSequence: number) => void
onBeforeUpdateQuit?: () => void | Promise<void>
updateInstallMode?: UpdateInstallMode
onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void
@ -125,7 +128,9 @@ export function attachMainWindowServices(
prepareCodexSessionResume: options?.prepareCodexSessionResume,
awaitLocalPtyStartup: options?.awaitLocalPtyStartup,
awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup,
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight,
onCodexHomePtySpawned: options?.onCodexHomePtySpawned,
onPtyExit: options?.onPtyExit
}
)
// Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1).

17
src/types/psl.ts Normal file
View File

@ -0,0 +1,17 @@
declare module 'psl' {
export type ParsedDomain = {
input: string
tld: string | null
sld: string | null
domain: string | null
subdomain: string | null
listed: boolean
}
export type ParseError = {
input: string
error: { code: string; message: string }
}
export function parse(input: string): ParsedDomain | ParseError
}