mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(codex): close session migration launch races
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user