mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 00:02:54 +00:00
fix(codex): publish Windows system-default sessions
This commit is contained in:
@@ -1758,9 +1758,12 @@ 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(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
|
||||
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
|
||||
@@ -1792,12 +1795,15 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
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()
|
||||
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.
|
||||
@@ -2506,10 +2512,14 @@ describe('CodexRuntimeHomeService', () => {
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
const scheduleSessionMigration = vi.fn()
|
||||
service.setHostSystemDefaultSessionMigrationScheduler(scheduleSessionMigration)
|
||||
|
||||
// Flag ON + host managed account = the account's own home is CODEX_HOME.
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(false)
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath)
|
||||
expect(scheduleSessionMigration).not.toHaveBeenCalled()
|
||||
// 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(
|
||||
|
||||
@@ -182,6 +182,7 @@ 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: () => void = () => {}
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
this.safeRecoverInterruptedRuntimeAuthOperation()
|
||||
@@ -242,7 +243,8 @@ export class CodexRuntimeHomeService {
|
||||
this.reconcileLegacySharedHomeForRetainedPanes()
|
||||
return null
|
||||
}
|
||||
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
|
||||
const shouldScheduleSessionMigration =
|
||||
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
|
||||
this.syncForCurrentSelection(target, launchEnv)
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
@@ -251,9 +253,23 @@ export class CodexRuntimeHomeService {
|
||||
{},
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
if (shouldScheduleSessionMigration) {
|
||||
this.scheduleHostSystemDefaultSessionMigration()
|
||||
}
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
setHostSystemDefaultSessionMigrationScheduler(schedule: () => void): void {
|
||||
this.scheduleHostSystemDefaultSessionMigration = schedule
|
||||
}
|
||||
|
||||
isHostSystemDefaultSessionMigrationEligible(): boolean {
|
||||
return (
|
||||
normalizeCodexRuntimeSelection(this.store.getSettings()).host === null &&
|
||||
!hasCustomCodexHomeOverrideForLaunch()
|
||||
)
|
||||
}
|
||||
|
||||
// Why: with the real-home flag ON, a managed HOST account runs against its own
|
||||
// self-contained CODEX_HOME (codex-accounts/<id>/home) instead of the shared
|
||||
// runtime mirror. Its auth.json lives there and codex refreshes it in place,
|
||||
@@ -415,17 +431,20 @@ export class CodexRuntimeHomeService {
|
||||
this.lastHostAccountUsedSelfContainedHome = false
|
||||
}
|
||||
|
||||
private invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv?: NodeJS.ProcessEnv): void {
|
||||
private invalidateBackfillAfterManagedSystemDefaultLaunch(
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): boolean {
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== null) {
|
||||
return
|
||||
}
|
||||
const realHomeSelected = this.isHostSystemDefaultRealHomeSelected(launchEnv)
|
||||
if (realHomeSelected || !isCodexSystemDefaultRealHomeEnabled()) {
|
||||
invalidateCodexSessionBackfillMarker(
|
||||
join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json')
|
||||
)
|
||||
if (
|
||||
normalizeCodexRuntimeSelection(settings).host !== null ||
|
||||
hasCustomCodexHomeOverrideForLaunch(launchEnv)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
invalidateCodexSessionBackfillMarker(
|
||||
join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json')
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private startWslSessionBridgeForLaunch(
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('Windows System Default Codex home ownership', () => {
|
||||
SHELL: 'powershell.exe'
|
||||
})
|
||||
).toBe(false)
|
||||
expect(service.isHostSystemDefaultSessionMigrationEligible()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import type { CodexSessionBackfillSummary } from './codex-session-backfill-types'
|
||||
|
||||
@@ -9,25 +9,70 @@ const CODEX_SESSION_BACKFILL_MARKER_VERSION = 3
|
||||
|
||||
export function hasCompletedCodexSessionBackfillMarker(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string
|
||||
systemSessionsRoot: string,
|
||||
managedSessionsRoot: string
|
||||
): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(markerPath, 'utf-8'))
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return false
|
||||
}
|
||||
const marker = parsed as { version?: unknown; systemSessionsRoot?: unknown }
|
||||
const marker = parsed as {
|
||||
version?: unknown
|
||||
systemSessionsRoot?: unknown
|
||||
summary?: { scannedFiles?: unknown }
|
||||
}
|
||||
// Why: changing the configured real Codex home must backfill the new
|
||||
// target instead of honoring a marker written for a different history.
|
||||
return (
|
||||
const markerMatchesTarget =
|
||||
marker.version === CODEX_SESSION_BACKFILL_MARKER_VERSION &&
|
||||
marker.systemSessionsRoot === systemSessionsRoot
|
||||
)
|
||||
if (!markerMatchesTarget) {
|
||||
return false
|
||||
}
|
||||
// Why: an empty source can become populated after an early migration run.
|
||||
return marker.summary?.scannedFiles !== 0 || !managedSessionsRootHasRollout(managedSessionsRoot)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function managedSessionsRootHasRollout(managedSessionsRoot: string): boolean {
|
||||
try {
|
||||
for (const year of readdirSync(managedSessionsRoot, { withFileTypes: true })) {
|
||||
if (!year.isDirectory() || !/^\d{4}$/.test(year.name)) {
|
||||
continue
|
||||
}
|
||||
const yearPath = join(managedSessionsRoot, year.name)
|
||||
for (const month of readdirSync(yearPath, { withFileTypes: true })) {
|
||||
if (!month.isDirectory() || !/^\d{2}$/.test(month.name)) {
|
||||
continue
|
||||
}
|
||||
const monthPath = join(yearPath, month.name)
|
||||
for (const day of readdirSync(monthPath, { withFileTypes: true })) {
|
||||
if (!day.isDirectory() || !/^\d{2}$/.test(day.name)) {
|
||||
continue
|
||||
}
|
||||
const dayPath = join(monthPath, day.name)
|
||||
if (
|
||||
readdirSync(dayPath, { withFileTypes: true }).some(
|
||||
(entry) => entry.isFile() && /^rollout-.+\.jsonl$/.test(entry.name)
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexSessionBackfillMarker(
|
||||
markerPath: string,
|
||||
systemSessionsRoot: string,
|
||||
|
||||
@@ -496,6 +496,22 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('self-heals a zero-file marker when managed rollouts appear later', async () => {
|
||||
const empty = await startCodexSessionBackfillInBackground()
|
||||
expect(empty).toMatchObject({ scannedFiles: 0, linkedFiles: 0 })
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({
|
||||
summary: { scannedFiles: 0 }
|
||||
})
|
||||
|
||||
writeManagedSession(join('2026', '07', '28', 'rollout-later.jsonl'), '{"id":"later"}\n')
|
||||
const healed = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(healed).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(
|
||||
existsSync(join(getSystemSessionsRoot(), '2026', '07', '28', 'rollout-later.jsonl'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers an installed rollout after the completion marker write fails', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
mkdirSync(getMarkerPath(), { recursive: true })
|
||||
|
||||
@@ -87,7 +87,13 @@ async function runCodexSessionBackfillOncePerHost(
|
||||
systemCodexHomePathOverride?: string
|
||||
): Promise<CodexSessionBackfillSummary | null> {
|
||||
const paths = resolveCodexSessionBackfillPaths(systemCodexHomePathOverride)
|
||||
if (hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)) {
|
||||
if (
|
||||
hasCompletedCodexSessionBackfillMarker(
|
||||
paths.markerPath,
|
||||
paths.systemSessionsRoot,
|
||||
paths.managedSessionsRoot
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(paths, options)
|
||||
|
||||
@@ -28,6 +28,28 @@ describe('createCodexSessionMigrationScheduler', () => {
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('schedules a delayed rerun after a shared-home launch', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue(null)
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(startBackfill).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces concurrent run requests and stops before index heal after opt-out', async () => {
|
||||
let eligible = true
|
||||
let releaseBackfill: (() => void) | undefined
|
||||
|
||||
@@ -7,6 +7,7 @@ type MigrationRun = (
|
||||
|
||||
export type CodexSessionMigrationScheduler = {
|
||||
scheduleInitialRun(): void
|
||||
scheduleRun(): void
|
||||
requestRun(): void
|
||||
}
|
||||
|
||||
@@ -18,7 +19,7 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
startIndexHeal: MigrationRun
|
||||
initialDelayMs?: number
|
||||
}): CodexSessionMigrationScheduler {
|
||||
let initialTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let scheduledTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let migrationTask: Promise<void> | null = null
|
||||
let activeRunStopObserved = false
|
||||
let rerunRequested = false
|
||||
@@ -68,16 +69,19 @@ export function createCodexSessionMigrationScheduler(args: {
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleRun = (): void => {
|
||||
if (scheduledTimer) {
|
||||
return
|
||||
}
|
||||
scheduledTimer = setTimeout(() => {
|
||||
scheduledTimer = null
|
||||
requestRun()
|
||||
}, args.initialDelayMs ?? 15_000)
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleInitialRun(): void {
|
||||
if (initialTimer) {
|
||||
return
|
||||
}
|
||||
initialTimer = setTimeout(() => {
|
||||
initialTimer = null
|
||||
requestRun()
|
||||
}, args.initialDelayMs ?? 15_000)
|
||||
},
|
||||
scheduleInitialRun: scheduleRun,
|
||||
scheduleRun,
|
||||
requestRun
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -2221,20 +2221,19 @@ void app.whenReady().then(async () => {
|
||||
isAgentStatusHooksEnabled(store?.getSettings())
|
||||
)
|
||||
const codexSessionMigration = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => codexRuntimeHome?.isHostSystemDefaultRealHome() === true,
|
||||
isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true,
|
||||
isQuitting: () => isQuitting,
|
||||
resolveSystemCodexHomePathOverride: () =>
|
||||
resolveHostCodexSessionSourceHome(store!.getSettings()),
|
||||
startBackfill: startCodexSessionBackfillInBackground,
|
||||
startIndexHeal: startCodexSessionIndexHealInBackground
|
||||
})
|
||||
codexRuntimeHome.setHostSystemDefaultSessionMigrationScheduler(codexSessionMigration.scheduleRun)
|
||||
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, {
|
||||
onHostSystemDefaultSelected: codexSessionMigration.requestRun
|
||||
})
|
||||
// Why: one-time per-host backfill makes historical Orca-managed Codex
|
||||
// sessions visible to the user's own resume picker and app history (#4444,
|
||||
// #8612). Deferred so startup and first PTY spawns never compete with the
|
||||
// sessions tree walk.
|
||||
// Why: migrate historical shared-home sessions after startup; compatibility
|
||||
// launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480).
|
||||
codexSessionMigration.scheduleInitialRun()
|
||||
claudeRuntimeAuth = new ClaudeRuntimeAuthService(store)
|
||||
claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth)
|
||||
|
||||
Reference in New Issue
Block a user