fix(runtime): preserve recovered store and cmd launch paths

This commit is contained in:
Brennan Benson
2026-08-23 18:20:31 -07:00
parent 330e4d836e
commit 849b83f6c5
6 changed files with 129 additions and 11 deletions
@@ -9,6 +9,16 @@ const IDENTITY = { sessionId: SESSION_ID } as Parameters<
ReturnType<typeof createCodexStructuredLaunchResolver>
>[0]['identity']
async function withPlatform<T>(platform: NodeJS.Platform, run: () => Promise<T>): Promise<T> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
function record(overrides: Partial<AgentSessionRecord> = {}): AgentSessionRecord {
return {
sessionId: SESSION_ID,
@@ -51,6 +61,23 @@ describe('codex structured launch resolution', () => {
})
})
it('passes a Windows .cmd path containing cmd syntax directly to the safe spawn layer', async () => {
const command = String.raw`C:\Users\r&d\npm-prefix\codex.cmd`
await withPlatform('win32', async () => {
const resolveLaunch = createCodexStructuredLaunchResolver({
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`,
resolveCommand: () => command
})
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
command,
args: ['app-server']
})
})
})
it('resumes the last thread this session actually proved, not one a caller names', async () => {
const launch = await resolverFor(
record({
@@ -11,7 +11,6 @@ import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { resolveCodexCommand } from '../codex-cli/command'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import { getSpawnArgsForWindows } from '../win32-utils'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof'
@@ -59,12 +58,11 @@ export function createCodexStructuredLaunchResolver(
pathEnv,
...(homePath ? { homePath } : {})
})
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, CODEX_APP_SERVER_ARGS)
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)
const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null
return {
command: spawnCmd,
args: spawnArgs,
command,
args: CODEX_APP_SERVER_ARGS,
cwd: await deps.resolveWorkspacePath(location.workspaceId),
codexHome: accountHome.path,
...(environment ? { env: { ...environment } as Record<string, string> } : {}),
@@ -142,7 +142,11 @@ describe('crash-safe store writes', () => {
const { mkdir } = await import('node:fs/promises')
await mkdir(`${storePath}.bak`)
await expect(saveAgentSessionStore(storePath, loaded.state)).rejects.toBeTruthy()
await expect(
saveAgentSessionStore(storePath, loaded.state, {
primaryStatus: 'validated'
})
).rejects.toBeTruthy()
expect(await readFile(storePath, 'utf-8')).toBe(committed)
expect((await stat(`${storePath}.bak`)).isDirectory()).toBe(true)
})
@@ -309,7 +309,8 @@ function serializeState(state: AgentSessionStoreState): string {
/**
* Commit the whole state. The live path is never absent: the new content is made durable in a temp
* file first, the old content is COPIED to the backup, and only then does the rename publish it.
* file first, a validated primary is COPIED to the backup, and only then does the rename publish it.
* Backup recovery keeps the known-good backup in place while publishing the repaired primary.
*
* The old ordering renamed the live file aside before writing the new one, so a death in that
* window left the profile with a backup and no primary — which is exactly the state that wedged a
@@ -317,7 +318,8 @@ function serializeState(state: AgentSessionStoreState): string {
*/
export async function saveAgentSessionStore(
filePath: string,
state: AgentSessionStoreState
state: AgentSessionStoreState,
options: { primaryStatus: 'validated' | 'unusable-or-absent' }
): Promise<void> {
const directory = dirname(filePath)
await mkdir(directory, { recursive: true, mode: 0o700 })
@@ -325,9 +327,11 @@ export async function saveAgentSessionStore(
const tmpPath = durableWriteTempPath(filePath)
try {
await writeTempFileDurable(tmpPath, serializeState(state), 0o600)
// A failed rotation aborts the save. Recovery's fence floor assumes the backup is at most one
// committed generation behind; letting the primary advance past a stale backup breaks that.
await copyFileDurable(filePath, backupPath(filePath))
// Only a primary parsed under the transaction lock may replace the backup. During recovery the
// primary is corrupt or absent, so the known-good backup must survive until publication.
if (options.primaryStatus === 'validated') {
await copyFileDurable(filePath, backupPath(filePath))
}
await renameDurable(tmpPath, filePath)
} catch (error) {
await rm(tmpPath, { force: true }).catch(() => {})
@@ -0,0 +1,83 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as DurableFileWrite from '../durable-file-write'
import {
agentSessionStoreRevision,
loadAgentSessionStore,
saveAgentSessionStore,
type AgentSessionStoreState
} from './agent-session-record-store-file'
import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue'
const publishFault = vi.hoisted(() => ({ armed: false }))
vi.mock('../durable-file-write', async (importOriginal) => {
const actual = await importOriginal<typeof DurableFileWrite>()
return {
...actual,
renameDurable: async (tmpPath: string, finalPath: string) => {
if (publishFault.armed) {
publishFault.armed = false
throw new Error('simulated death before primary publish')
}
return actual.renameDurable(tmpPath, finalPath)
}
}
})
let root: string
let storePath: string
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-publish-'))
storePath = join(root, 'agent-sessions.json')
})
afterEach(async () => {
publishFault.armed = false
await rm(root, { recursive: true, force: true })
})
function state(generation: number): AgentSessionStoreState {
return {
schemaVersion: 2,
hostId: 'local',
records: new Map(),
operations: new Map(),
retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }],
unreadableRecords: new Map()
}
}
describe('backup recovery publication', () => {
it('keeps the valid backup when publication fails after a corrupt primary was recovered', async () => {
await saveAgentSessionStore(storePath, state(1), { primaryStatus: 'unusable-or-absent' })
await saveAgentSessionStore(storePath, state(2), {
primaryStatus: 'validated'
})
await writeFile(storePath, '{corrupt-primary', 'utf-8')
const knownGoodBackup = await readFile(`${storePath}.bak`, 'utf-8')
const recovered = await loadAgentSessionStore(storePath, 'local')
expect(recovered.recoveredFromBackup).toBe(true)
expect(recovered.state.retiredClaimKeys[0]?.keyId).toBe('generation-1')
const queue = AgentSessionStoreTransactionQueue.fromLoadedStore(
storePath,
'local',
recovered,
agentSessionStoreRevision(recovered.state)
)
publishFault.armed = true
await expect(queue.persistLoadedMigration()).rejects.toThrow(
'simulated death before primary publish'
)
expect(await readFile(`${storePath}.bak`, 'utf-8')).toBe(knownGoodBackup)
const afterFault = await loadAgentSessionStore(storePath, 'local')
expect(afterFault.recoveredFromBackup).toBe(true)
expect(afterFault.state.retiredClaimKeys[0]?.keyId).toBe('generation-1')
})
})
@@ -116,7 +116,9 @@ export class AgentSessionStoreTransactionQueue {
) {
return result
}
await saveAgentSessionStore(this.filePath, this.state)
await saveAgentSessionStore(this.filePath, this.state, {
primaryStatus: this.diskStoreFound && !recovering ? 'validated' : 'unusable-or-absent'
})
this.state.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION
this.diskRevision = agentSessionStoreRevision(this.state)
this.diskRecoveredFromBackup = false