fix(codex): close session migration launch races

This commit is contained in:
Brennan Benson
2026-08-07 14:53:24 -07:00
parent 99cde04452
commit 81dde06a98
17 changed files with 424 additions and 378 deletions
@@ -1004,13 +1004,11 @@ describe('CodexRuntimeHomeService', () => {
const store = createStore(createSettings())
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const scheduleSessionMigration = vi.fn()
service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration)
expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath())
expect(existsSync(markerPath)).toBe(false)
expect(scheduleSessionMigration).toHaveBeenCalledOnce()
expect(scheduleSessionMigration).toHaveBeenCalledWith(true)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
true
)
service.finishHostSystemDefaultSessionMigrationPass()
writeFileSync(
markerPath,
@@ -1022,15 +1020,20 @@ describe('CodexRuntimeHomeService', () => {
'utf-8'
)
service.prepareForCodexLaunch()
expect(scheduleSessionMigration).toHaveBeenLastCalledWith(false)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
false
)
service.prepareForCodexLaunch()
expect(scheduleSessionMigration).toHaveBeenLastCalledWith(false)
expect(scheduleSessionMigration).toHaveBeenCalledTimes(3)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
false
)
store.updateSettings({
codexSessionSourceHome: { host: join(testState.fakeHomeDir, 'moved-history'), wsl: {} }
})
service.prepareForCodexLaunch()
expect(scheduleSessionMigration).toHaveBeenLastCalledWith(true)
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
true
)
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
@@ -1060,17 +1063,21 @@ 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')
const scheduleSessionMigration = vi.fn()
service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration)
writeFileSync(markerPath, '{}\n', 'utf-8')
expect(service.isHostSystemDefaultRealHome({ CODEX_HOME: perSpawnCustomHome })).toBe(false)
expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: perSpawnCustomHome })).toBe(
getRuntimeCodexHomePath()
)
expect(existsSync(markerPath)).toBe(true)
expect(scheduleSessionMigration).not.toHaveBeenCalled()
expect(
service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())
).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.
@@ -1742,14 +1749,14 @@ describe('CodexRuntimeHomeService', () => {
)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
const scheduleSessionMigration = vi.fn()
service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration)
// 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(scheduleSessionMigration).not.toHaveBeenCalled()
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(
+12 -10
View File
@@ -184,7 +184,6 @@ export class CodexRuntimeHomeService {
private sharedAuthRefreshBlockedByManagedTransition = false
// Why: transient auth.json read/parse failures must not deselect an account.
private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace()
private scheduleHostSystemDefaultSessionMigration: (fullScanRequired: boolean) => void = () => {}
private hostSystemDefaultSessionMigrationPending = false
private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
private pendingHostSystemDefaultSessionMigrationTarget: string | null = null
@@ -248,8 +247,7 @@ export class CodexRuntimeHomeService {
this.reconcileLegacySharedHomeForRetainedPanes()
return null
}
const sessionMigrationFullScanRequired =
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
this.syncForCurrentSelection(target, launchEnv)
syncSystemCodexResourcesIntoManagedHome()
syncSystemConfigIntoManagedCodexHome()
@@ -258,16 +256,20 @@ export class CodexRuntimeHomeService {
{},
resolveHostCodexSessionSourceHome(this.store.getSettings())
)
if (sessionMigrationFullScanRequired !== null) {
this.scheduleHostSystemDefaultSessionMigration(sessionMigrationFullScanRequired)
}
return this.getRuntimeHomePath()
}
setHostSystemDefaultSessionMigrationScheduler(
schedule: (fullScanRequired: boolean) => void
): void {
this.scheduleHostSystemDefaultSessionMigration = schedule
beginHostSystemDefaultSessionMigrationLaunch(codexHomePath: string | null): boolean | null {
if (
!this.hostSystemDefaultSessionMigrationPending ||
!this.isHostSystemDefaultSessionMigrationEligible() ||
!codexHomePath ||
normalizeRuntimePathForComparison(codexHomePath) !==
normalizeRuntimePathForComparison(this.getRuntimeHomePath())
) {
return null
}
return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan
}
isHostSystemDefaultSessionMigrationEligible(): boolean {
@@ -2,8 +2,6 @@ import type { Stats } from 'node:fs'
import { lstat } from 'node:fs/promises'
import {
appendCodexSessionHealAuditRecord,
addOwnedCopyEventId,
createCodexSessionBackfillCopyOwnershipKey,
createCodexSessionBackfillAuditWriter,
createCodexSessionBackfillDiagnosticEventId,
createCodexSessionBackfillFileEventId,
@@ -15,7 +13,6 @@ import type { CodexSessionBackfillSummary } from './codex-session-backfill-types
export type CodexSessionBackfillAuditPass = {
appendRecord: CodexSessionBackfillAuditWriter
isOwnedCopy(source: string, target: string, targetStat: Stats): boolean
recordExisting(
summary: CodexSessionBackfillSummary,
source: string,
@@ -57,12 +54,6 @@ export async function createCodexSessionBackfillAuditPass(
return {
appendRecord,
isOwnedCopy(source, target, targetStat): boolean {
const eventIds = coverage.ownedCopyEventIds.get(
createCodexSessionBackfillCopyOwnershipKey(source, target)
)
return eventIds?.has(createCodexSessionBackfillFileEventId(target, targetStat)) === true
},
async recordExisting(summary, source, target, targetStat): Promise<void> {
const fileEventId = targetStat
? createCodexSessionBackfillFileEventId(target, targetStat)
@@ -94,9 +85,6 @@ export async function createCodexSessionBackfillAuditPass(
) {
if (fileEventId) {
coverage.fileEventIds.add(fileEventId)
if (action === 'copy') {
addOwnedCopyEventId(coverage, source, target, fileEventId)
}
}
}
},
@@ -11,7 +11,6 @@ export type CodexSessionBackfillAuditWriter = (record: Record<string, unknown>)
export type CodexSessionBackfillAuditCoverage = {
fileEventIds: Set<string>
diagnosticEventIds: Set<string>
ownedCopyEventIds: Map<string, Set<string>>
hasRunSummary: boolean
}
@@ -67,7 +66,6 @@ export async function readCodexSessionBackfillAuditCoverage(
const coverage: CodexSessionBackfillAuditCoverage = {
fileEventIds: new Set<string>(),
diagnosticEventIds: new Set<string>(),
ownedCopyEventIds: new Map<string, Set<string>>(),
hasRunSummary: false
}
const input = createReadStream(auditLogPath, { encoding: 'utf-8' })
@@ -87,13 +85,6 @@ export async function readCodexSessionBackfillAuditCoverage(
typeof record.fileEventId === 'string'
) {
coverage.fileEventIds.add(record.fileEventId)
if (
record.action === 'copy' &&
typeof record.source === 'string' &&
typeof record.target === 'string'
) {
addOwnedCopyEventId(coverage, record.source, record.target, record.fileEventId)
}
}
if (
typeof record.action === 'string' &&
@@ -130,22 +121,6 @@ export function createCodexSessionBackfillFileEventId(targetPath: string, stat:
.digest('hex')
}
export function createCodexSessionBackfillCopyOwnershipKey(source: string, target: string): string {
return `${normalizeRuntimePathForComparison(source)}\0${normalizeRuntimePathForComparison(target)}`
}
export function addOwnedCopyEventId(
coverage: CodexSessionBackfillAuditCoverage,
source: string,
target: string,
fileEventId: string
): void {
const key = createCodexSessionBackfillCopyOwnershipKey(source, target)
const eventIds = coverage.ownedCopyEventIds.get(key) ?? new Set<string>()
eventIds.add(fileEventId)
coverage.ownedCopyEventIds.set(key, eventIds)
}
export function createCodexSessionBackfillDiagnosticEventId(args: {
action: 'copy-unsupported' | 'failed'
source: string
+2 -46
View File
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto'
import type { Stats } from 'node:fs'
import { copyFile, link, lstat, rename, rm, writeFile } from 'node:fs/promises'
import { copyFile, link, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
const ATOMIC_NO_REPLACE_UNSUPPORTED_CODE = 'ORCA_ATOMIC_NO_REPLACE_UNSUPPORTED'
@@ -14,7 +13,7 @@ export async function copySessionFileWithoutOverwrite(
// copy cannot strand a truncated session that a later retry would skip.
await writeFile(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 })
try {
await copyStableSessionFile(sourcePath, temporaryPath)
await copyFile(sourcePath, temporaryPath)
try {
// Why: this same-volume hardlink atomically installs the staged copy
// without risking a collision overwrite after an EXDEV fallback.
@@ -41,49 +40,6 @@ export async function copySessionFileWithoutOverwrite(
}
}
export async function replaceOwnedSessionCopy(
sourcePath: string,
targetPath: string,
expectedTargetStat: Stats
): Promise<void> {
const temporaryPath = join(dirname(targetPath), `.orca-backfill-${randomUUID()}.tmp`)
await writeFile(temporaryPath, '', { encoding: 'utf-8', flag: 'wx', mode: 0o600 })
try {
await copyStableSessionFile(sourcePath, temporaryPath)
const currentTargetStat = await lstat(targetPath)
if (!fileStatsMatch(currentTargetStat, expectedTargetStat)) {
const error = new Error(`Owned backfill target changed before refresh: ${targetPath}`)
;(error as NodeJS.ErrnoException).code = 'EEXIST'
throw error
}
await rename(temporaryPath, targetPath)
} finally {
await rm(temporaryPath, { force: true }).catch(() => undefined)
}
}
async function copyStableSessionFile(sourcePath: string, targetPath: string): Promise<void> {
const sourceStatBefore = await lstat(sourcePath)
await copyFile(sourcePath, targetPath)
const sourceStatAfter = await lstat(sourcePath)
if (!fileStatsMatch(sourceStatAfter, sourceStatBefore)) {
const error = new Error(`Session changed while it was copied: ${sourcePath}`)
;(error as NodeJS.ErrnoException).code = 'EBUSY'
throw error
}
}
function fileStatsMatch(left: Stats, right: Stats): boolean {
return (
left.dev === right.dev &&
left.ino === right.ino &&
left.birthtimeMs === right.birthtimeMs &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs
)
}
function isExistsError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
@@ -1,46 +0,0 @@
import type { Stats } from 'node:fs'
import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit'
import {
readCodexSessionTargetStat,
type CodexSessionBackfillAuditPass
} from './codex-session-backfill-audit-pass'
import { replaceOwnedSessionCopy } from './codex-session-backfill-copy'
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
export async function refreshOwnedCodexSessionCopy(args: {
source: string
target: string
targetStat: Stats
summary: CodexSessionBackfillSummary
auditPass: CodexSessionBackfillAuditPass
}): Promise<boolean> {
if (!args.auditPass.isOwnedCopy(args.source, args.target, args.targetStat)) {
return false
}
const sourceStat = await readCodexSessionTargetStat(args.source)
if (!sourceStat || sourceStat.size <= args.targetStat.size) {
return false
}
try {
await replaceOwnedSessionCopy(args.source, args.target, args.targetStat)
args.summary.copiedFiles += 1
await args.auditPass.recordPublished(args.summary, 'copy', args.source, args.target)
} catch (error) {
args.summary.failedFiles += 1
await args.auditPass.recordDiagnostic(
{
action: 'failed',
source: args.source,
target: args.target,
error: describeError(error),
errorCode: describeCodexSessionBackfillErrorCode(error)
},
sourceStat
)
}
return true
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -28,6 +28,10 @@ export type CodexSessionBackfillOptions = CodexSessionBridgeIncrementalOptions &
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
}
export type CodexSessionBackfillDate = readonly [year: string, month: string, day: string]
+66 -118
View File
@@ -24,13 +24,11 @@ const { homedirMock } = vi.hoisted(() => ({
const { fsMockState } = vi.hoisted(() => ({
fsMockState: {
failLink: false,
failInstallLink: false,
failInstallLinkTransiently: false,
growSourceDuringCopy: false,
failLinkTransiently: false,
raceTargetIntoExistence: false,
failCopy: false,
failAuditMkdirOnce: false,
failAuditWrites: false,
failMkdirPath: null as string | null,
failDirectoryPath: null as string | null,
failLstatPath: null as string | null
}
@@ -54,6 +52,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(
@@ -93,37 +96,13 @@ 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, '${String(args[1])}'`
) as NodeJS.ErrnoException
error.code = 'EACCES'
throw error
}
await actual.copyFile(...args)
if (fsMockState.growSourceDuringCopy) {
fsMockState.growSourceDuringCopy = false
await actual.appendFile(args[0], '{"event":"during-copy"}\n', 'utf-8')
}
},
opendir: (...args: Parameters<typeof actual.opendir>) => {
if (args[0] === fsMockState.failDirectoryPath) {
const error = new Error('EACCES: directory unreadable') as NodeJS.ErrnoException
@@ -203,13 +182,11 @@ function readAuditActions(): string[] {
beforeEach(() => {
fsMockState.failLink = false
fsMockState.failInstallLink = false
fsMockState.failInstallLinkTransiently = false
fsMockState.growSourceDuringCopy = false
fsMockState.failLinkTransiently = false
fsMockState.raceTargetIntoExistence = false
fsMockState.failCopy = false
fsMockState.failAuditMkdirOnce = false
fsMockState.failAuditWrites = false
fsMockState.failMkdirPath = null
fsMockState.failDirectoryPath = null
fsMockState.failLstatPath = null
fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-backfill-home-'))
@@ -388,73 +365,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 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'])
})
it('refreshes an unchanged owned copy after its source grows', async () => {
fsMockState.failLink = true
const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl')
const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n')
const paths = resolveCodexSessionBackfillPaths()
await backfillManagedCodexSessionsIntoSystemHome(paths)
appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8')
const refreshed = await backfillManagedCodexSessionsIntoSystemHome(paths)
expect(refreshed).toMatchObject({ copiedFiles: 1, failedFiles: 0 })
expect(readFileSync(join(getSystemSessionsRoot(), relativePath), 'utf-8')).toBe(
'{"id":"a"}\n{"event":"later"}\n'
)
})
it('does not publish a cross-volume snapshot while its source grows', async () => {
fsMockState.failLink = true
fsMockState.growSourceDuringCopy = true
const relativePath = join('2026', '05', '26', 'rollout-active.jsonl')
const relativePath = join('2026', '05', '26', 'rollout-a ü.jsonl')
writeManagedSession(relativePath, '{"id":"a"}\n')
const summary = await backfillManagedCodexSessionsIntoSystemHome(
resolveCodexSessionBackfillPaths()
)
expect(summary).toMatchObject({ copiedFiles: 0, failedFiles: 1 })
expect(summary).toMatchObject({ copiedFiles: 0, skippedUnsupportedFilesystemFiles: 1 })
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
})
it('does not refresh an owned copy after the target changes', async () => {
fsMockState.failLink = true
const relativePath = join('2026', '05', '26', 'rollout-diverged.jsonl')
const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n')
const targetPath = join(getSystemSessionsRoot(), relativePath)
const paths = resolveCodexSessionBackfillPaths()
await backfillManagedCodexSessionsIntoSystemHome(paths)
writeFileSync(targetPath, 'user-owned continuation\n', 'utf-8')
appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8')
const repeated = await backfillManagedCodexSessionsIntoSystemHome(paths)
expect(repeated).toMatchObject({ copiedFiles: 0, skippedExistingFiles: 1 })
expect(readFileSync(targetPath, 'utf-8')).toBe('user-owned continuation\n')
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(
@@ -473,9 +399,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(
@@ -489,9 +414,21 @@ 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('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(
@@ -573,6 +510,20 @@ 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('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
@@ -643,6 +594,23 @@ describe('startCodexSessionBackfillInBackground', () => {
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')
@@ -783,7 +751,6 @@ 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()
@@ -801,8 +768,7 @@ describe('startCodexSessionBackfillInBackground', () => {
})
it('keeps repeated unchanged per-file failures audit-stable', 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()
@@ -816,8 +782,7 @@ describe('startCodexSessionBackfillInBackground', () => {
})
it('records a new failure event when the source file changes', async () => {
fsMockState.failLink = true
fsMockState.failCopy = true
fsMockState.failLinkTransiently = true
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
writeManagedSession(relativePath, '{"id":"a"}\n')
await startCodexSessionBackfillInBackground()
@@ -830,24 +795,8 @@ describe('startCodexSessionBackfillInBackground', () => {
expect(new Set(failedRecords.map((record) => record.diagnosticEventId)).size).toBe(2)
})
it('records a new failure event when the filesystem outcome changes', async () => {
fsMockState.failLink = true
fsMockState.failCopy = true
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
await startCodexSessionBackfillInBackground()
fsMockState.failCopy = false
fsMockState.failInstallLinkTransiently = true
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()
@@ -856,8 +805,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')
+37 -61
View File
@@ -11,10 +11,6 @@ import {
type CodexSessionBackfillAuditPass
} from './codex-session-backfill-audit-pass'
import { describeCodexSessionBackfillErrorCode } from './codex-session-backfill-audit'
import {
copySessionFileWithoutOverwrite,
isAtomicNoReplaceUnsupportedError
} from './codex-session-backfill-copy'
import {
isCodexSessionRolloutPath,
listCodexSessionBackfillFilesForDates
@@ -24,7 +20,6 @@ import {
hasCompletedCodexSessionBackfillMarker,
writeCodexSessionBackfillMarker as writeBackfillMarker
} from './codex-session-backfill-marker'
import { refreshOwnedCodexSessionCopy } from './codex-session-backfill-owned-copy'
import type {
CodexSessionBackfillOptions,
CodexSessionBackfillPaths,
@@ -105,7 +100,8 @@ async function runCodexSessionBackfillOncePerHost(
if (
!summary.stopped &&
options.shouldStop?.() !== true &&
options.scanDates === undefined &&
options.writeCompletionMarker !== false &&
(options.scanDates === undefined || options.writeBoundedCompletionMarker === true) &&
summary.failedFiles === 0 &&
summary.failedDirectories === 0 &&
summary.failedHealAuditRecords === 0
@@ -119,8 +115,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,
@@ -228,17 +224,6 @@ async function backfillOneManagedSessionFile(
const systemSessionFilePath = join(paths.systemSessionsRoot, relativePath)
const existingTargetStat = await readCodexSessionTargetStat(systemSessionFilePath)
if (existingTargetStat) {
if (
await refreshOwnedCodexSessionCopy({
source: managedSessionFilePath,
target: systemSessionFilePath,
targetStat: existingTargetStat,
summary,
auditPass
})
) {
return
}
await auditPass.recordExisting(
summary,
managedSessionFilePath,
@@ -248,6 +233,7 @@ async function backfillOneManagedSessionFile(
return
}
let linkAttempted = false
try {
const targetDirectory = dirname(systemSessionFilePath)
if (!ensuredTargetDirectories.has(targetDirectory)) {
@@ -256,6 +242,7 @@ async function backfillOneManagedSessionFile(
await mkdir(targetDirectory, { recursive: true })
ensuredTargetDirectories.add(targetDirectory)
}
linkAttempted = true
await link(managedSessionFilePath, systemSessionFilePath)
summary.linkedFiles += 1
await auditPass.recordPublished(
@@ -265,7 +252,7 @@ async function backfillOneManagedSessionFile(
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 auditPass.recordExisting(
@@ -279,55 +266,32 @@ async function backfillOneManagedSessionFile(
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 auditPass.recordPublished(
summary,
'copy',
managedSessionFilePath,
systemSessionFilePath
)
} catch (copyError) {
if (isExistsError(copyError)) {
await auditPass.recordExisting(
summary,
managedSessionFilePath,
systemSessionFilePath,
await readCodexSessionTargetStat(systemSessionFilePath)
)
return
}
if (isAtomicNoReplaceUnsupportedError(copyError)) {
summary.skippedUnsupportedFilesystemFiles += 1
await auditPass.recordDiagnostic(
{
action: 'copy-unsupported',
source: managedSessionFilePath,
target: systemSessionFilePath,
errorCode: describeCodexSessionBackfillErrorCode(copyError),
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
},
await readCodexSessionTargetStat(managedSessionFilePath)
)
return
}
summary.failedFiles += 1
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: 'failed',
action: 'copy-unsupported',
source: managedSessionFilePath,
target: systemSessionFilePath,
error: describeError(copyError),
linkError: describeError(linkError),
errorCode: describeCodexSessionBackfillErrorCode(copyError),
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
},
await readCodexSessionTargetStat(managedSessionFilePath)
sourceStat
)
return
}
summary.failedFiles += 1
await auditPass.recordDiagnostic(
{
action: 'failed',
source: managedSessionFilePath,
target: systemSessionFilePath,
linkError: describeError(linkError),
linkErrorCode: describeCodexSessionBackfillErrorCode(linkError)
},
sourceStat
)
}
}
@@ -347,6 +311,18 @@ 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 === 'EPERM' ||
code === 'EACCES' ||
code === 'ENOTSUP' ||
code === 'EOPNOTSUPP' ||
code === 'ENOSYS'
)
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -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(
@@ -255,6 +255,28 @@ describe('runCodexSessionIndexHeal', () => {
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') }]
@@ -9,7 +9,7 @@ describe('createCodexSessionMigrationScheduler', () => {
it('runs after a managed-account startup switches to host system default', async () => {
let eligible = false
const prepareScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => eligible,
@@ -35,7 +35,7 @@ describe('createCodexSessionMigrationScheduler', () => {
vi.setSystemTime(new Date(2026, 7, 5, 10, 0, 0))
const prepareScheduledRun = vi.fn()
const finishScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
@@ -69,7 +69,7 @@ describe('createCodexSessionMigrationScheduler', () => {
it('covers both launch and run dates when a delayed pass crosses midnight', async () => {
vi.setSystemTime(new Date(2026, 7, 5, 23, 59, 59, 500))
const startBackfill = vi.fn().mockResolvedValue(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
@@ -94,7 +94,7 @@ describe('createCodexSessionMigrationScheduler', () => {
})
it('keeps launch passes full when no completed baseline can cover older failures', async () => {
const startBackfill = vi.fn().mockResolvedValue(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
@@ -114,7 +114,7 @@ describe('createCodexSessionMigrationScheduler', () => {
})
it('upgrades a bounded pass when its target changed before the timer fired', async () => {
const startBackfill = vi.fn().mockResolvedValue(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
isQuitting: () => false,
@@ -136,7 +136,7 @@ describe('createCodexSessionMigrationScheduler', () => {
it('delays the startup run from the latest shared-home launch', async () => {
const prepareScheduledRun = vi.fn()
const startBackfill = vi.fn().mockResolvedValue(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => true,
@@ -164,7 +164,7 @@ describe('createCodexSessionMigrationScheduler', () => {
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(null)
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
const startIndexHeal = vi
.fn()
.mockImplementationOnce(
@@ -354,4 +354,83 @@ describe('createCodexSessionMigrationScheduler', () => {
)
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, 7, 5, 23, 59, 59))
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, 7, 7, 1, 0, 0))
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())
})
})
@@ -10,6 +10,8 @@ type MigrationRun = (
) => Promise<unknown>
export type CodexSessionMigrationScheduler = {
beginLaunch(leaseId: string, fullScanRequired?: boolean): void
finishLaunch(leaseId: string): void
scheduleInitialRun(): void
scheduleRun(fullScanRequired?: boolean): void
requestRun(): void
@@ -33,6 +35,7 @@ export function createCodexSessionMigrationScheduler(args: {
let scheduledFullScan = false
let pendingFullScan = false
let migrationTask: Promise<void> | null = null
const activeLaunches = new Map<string, Date>()
let activeRunStopObserved = false
let rerunRequested = false
@@ -82,13 +85,22 @@ export function createCodexSessionMigrationScheduler(args: {
}
const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride()
let stoppedBackfill = false
let incompleteBackfill = true
const task = args
.startBackfill(
{ shouldStop, scanDates, ignoreCompletionMarker: isScheduledRun },
{
shouldStop,
scanDates,
ignoreCompletionMarker: isScheduledRun,
writeCompletionMarker: activeLaunches.size === 0,
writeBoundedCompletionMarker:
isScheduledRun && activeLaunches.size === 0 && !fullScanRequired
},
systemCodexHomePathOverride
)
.then((result) => {
stoppedBackfill = isStoppedMigrationResult(result)
incompleteBackfill = isIncompleteBackfillResult(result)
if (stoppedBackfill || shouldStop()) {
return
}
@@ -106,7 +118,7 @@ export function createCodexSessionMigrationScheduler(args: {
const shouldRerun = rerunRequested || scheduledRunIncomplete
rerunRequested = false
activeRunStopObserved = false
if (shouldRerun && isScheduledRun) {
if ((shouldRerun || incompleteBackfill) && isScheduledRun) {
pendingScheduledRunGeneration = Math.max(
activeScheduledRunGeneration!,
pendingScheduledRunGeneration ?? activeScheduledRunGeneration!
@@ -116,7 +128,13 @@ export function createCodexSessionMigrationScheduler(args: {
pendingScanDates.set(scanDate.join('-'), scanDate)
}
}
if (isScheduledRun && scheduledTimer === null && pendingScheduledRunGeneration === null) {
if (
isScheduledRun &&
!incompleteBackfill &&
activeLaunches.size === 0 &&
scheduledTimer === null &&
pendingScheduledRunGeneration === null
) {
args.finishScheduledRun?.()
}
if (shouldRerun) {
@@ -142,27 +160,80 @@ export function createCodexSessionMigrationScheduler(args: {
}, 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 {
beginLaunch(leaseId, fullScanRequired = false): void {
if (args.isQuitting() || activeLaunches.has(leaseId)) {
return
}
activeLaunches.set(leaseId, new Date())
scheduleRun(fullScanRequired)
},
finishLaunch(leaseId): void {
const startedAt = activeLaunches.get(leaseId)
if (!startedAt) {
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(fullScanRequired = false): void {
// Why: delay from the latest launch so Codex has time to create its rollout.
if (scheduledTimer) {
clearTimeout(scheduledTimer)
}
scheduledRunGeneration += 1
scheduledFullScan ||= fullScanRequired
const launchDate = getCodexSessionBackfillDate()
scheduledScanDates.set(launchDate.join('-'), launchDate)
armScheduledRun(scheduledRunGeneration)
},
scheduleRun,
requestRun: () => requestRun()
}
}
function getCodexSessionBackfillDatesBetween(
startedAt: Date,
finishedAt: Date
): CodexSessionBackfillDate[] {
const dates: CodexSessionBackfillDate[] = []
const cursor = new Date(startedAt.getFullYear(), startedAt.getMonth(), startedAt.getDate())
const last = new Date(finishedAt.getFullYear(), finishedAt.getMonth(), finishedAt.getDate())
while (cursor <= last) {
dates.push(getCodexSessionBackfillDate(cursor))
cursor.setDate(cursor.getDate() + 1)
}
return dates
}
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
}
+21 -3
View File
@@ -335,6 +335,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
@@ -388,6 +389,18 @@ 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 handleCodexPtySpawned(args: { id: string; codexHomePath: string | null }): void {
const fullScanRequired =
codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath) ?? null
if (fullScanRequired !== null) {
codexSessionMigration?.beginLaunch(args.id, fullScanRequired)
}
}
function handlePtyExit(id: string): void {
codexSessionMigration?.finishLaunch(id)
}
// 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
@@ -1396,6 +1409,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,
onCodexPtySpawned: handleCodexPtySpawned,
onPtyExit: handlePtyExit,
onBeforeUpdateQuit: () =>
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
updateInstallMode: resolveUpdateInstallMode(isServeMode),
@@ -2250,7 +2265,7 @@ void app.whenReady().then(async () => {
codexRuntimeHome.isHostSystemDefaultRealHome() &&
isAgentStatusHooksEnabled(store?.getSettings())
)
const codexSessionMigration = createCodexSessionMigrationScheduler({
codexSessionMigration = createCodexSessionMigrationScheduler({
isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true,
isQuitting: () => isQuitting,
resolveSystemCodexHomePathOverride: () =>
@@ -2260,7 +2275,6 @@ void app.whenReady().then(async () => {
startBackfill: startCodexSessionBackfillInBackground,
startIndexHeal: startCodexSessionIndexHealInBackground
})
codexRuntimeHome.setHostSystemDefaultSessionMigrationScheduler(codexSessionMigration.scheduleRun)
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, {
onHostSystemDefaultSelected: codexSessionMigration.requestRun
})
@@ -2880,7 +2894,11 @@ void app.whenReady().then(async () => {
() => store!.getSettings(),
(target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target),
store,
prepareCodexSessionResumeForLaunch
prepareCodexSessionResumeForLaunch,
{
onCodexPtySpawned: handleCodexPtySpawned,
onPtyExit: handlePtyExit
}
)
await runtime.refreshRestoredOrchestrationAuthority()
await runtime.reconcileLegacyWorkerTerminals()
+24 -10
View File
@@ -3074,16 +3074,25 @@ describe('registerPtyHandlers', () => {
new Promise<void>((resolve) => (releaseRecovery = resolve))
)
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const onCodexPtySpawned = 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,
{ onCodexPtySpawned }
)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
@@ -3094,10 +3103,15 @@ describe('registerPtyHandlers', () => {
expect(ensureCodexBackfillRecoveryMock).toHaveBeenCalledWith(TEST_CODEX_HOME)
)
expect(spawnMock).not.toHaveBeenCalled()
expect(onCodexPtySpawned).not.toHaveBeenCalled()
releaseRecovery()
await spawnPromise
const result = (await spawnPromise) as { id: string }
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(onCodexPtySpawned).toHaveBeenCalledWith({
id: result.id,
codexHomePath: TEST_CODEX_HOME
})
})
it('does not gate a bare local shell on managed Codex auth', async () => {
+18 -2
View File
@@ -2243,6 +2243,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
onCodexPtySpawned?: (args: { id: string; codexHomePath: string | null }) => void
onPtyExit?: (id: string) => 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 +3565,7 @@ export function registerPtyHandlers(
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
options?.onPtyExit?.(payload.id)
const release = preparePtyExitForRenderer(payload)
if (!release) {
return
@@ -4976,6 +4979,9 @@ export function registerPtyHandlers(
leafId: owner.surface.leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
if (args.launchAgent === 'codex' && !args.connectionId) {
options?.onCodexPtySpawned?.({ id: result.id, codexHomePath: selectedCodexHomePath })
}
return {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
@@ -5140,6 +5146,9 @@ export function registerPtyHandlers(
}
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
sendPtySpawnedToRenderer(result.id)
if (args.launchAgent === 'codex' && !args.connectionId) {
options?.onCodexPtySpawned?.({ id: result.id, codexHomePath: selectedCodexHomePath })
}
const response = {
id: result.id,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
@@ -6601,6 +6610,9 @@ export function registerPtyHandlers(
}
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
sendPtySpawnedToRenderer(result.id)
if (args.launchAgent === 'codex' && !args.connectionId) {
options?.onCodexPtySpawned?.({ id: result.id, codexHomePath: selectedCodexHomePath })
}
return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response)
} catch (err) {
if (pendingRegistrationPtyId) {
@@ -7387,7 +7399,11 @@ export function registerHeadlessPtyRuntime(
getSettings?: () => GlobalSettings,
prepareClaudeAuth?: PrepareClaudeAuth,
store?: Store,
prepareCodexSessionResume?: PrepareCodexSessionResume
prepareCodexSessionResume?: PrepareCodexSessionResume,
lifecycle?: {
onCodexPtySpawned?: (args: { id: string; codexHomePath: string | null }) => void
onPtyExit?: (id: string) => 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 +7421,7 @@ export function registerHeadlessPtyRuntime(
getSettings,
prepareClaudeAuth,
store,
{ prepareCodexSessionResume }
{ prepareCodexSessionResume, ...lifecycle }
)
}
@@ -98,6 +98,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
onCodexPtySpawned?: (args: { id: string; codexHomePath: string | null }) => void
onPtyExit?: (id: string) => void
onBeforeUpdateQuit?: () => void | Promise<void>
updateInstallMode?: UpdateInstallMode
onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void
@@ -125,7 +127,9 @@ export function attachMainWindowServices(
prepareCodexSessionResume: options?.prepareCodexSessionResume,
awaitLocalPtyStartup: options?.awaitLocalPtyStartup,
awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup,
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight
isRecoveryReloadInFlight: options?.isRecoveryReloadInFlight,
onCodexPtySpawned: options?.onCodexPtySpawned,
onPtyExit: options?.onPtyExit
}
)
// Why: register after registerPtyHandlers so pty:management:* IPC re-installs on macOS re-activation (docs/daemon-staleness-ux.md §Phase 1).