mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
fix(codex): carry /goal state and resume the session across account-switch restarts
An account-switch restart always launched a brand-new codex thread, which stranded the pane's /goal: Codex keys thread_goals by thread id inside the launch CODEX_HOME's sqlite, so after the switch the goal was doubly unreachable (new thread, other home). Main now prepares the restart: it resolves the pane's launch home from the pane-account registry, probes the app-server goal RPCs behind a capability cache, hardlinks the thread's rollout into the new home ahead of the background bridge, proves it resumable there via thread/read, and carries the goal row with thread/goal/get -> thread/goal/set. Only then does the renderer swap the restart startup to `codex resume <thread-id>`. Codex's sqlite is never touched directly - the app-server owns the DB and its WAL. Every uncertain path - unrecorded panes, WSL/SSH/remote lanes, custom CODEX_HOME overrides, older codex CLIs without the goal RPCs, unresolvable rollouts, any RPC failure - degrades to exactly today's fresh `codex` startup, because resuming the wrong session is worse than losing the goal.
This commit is contained in:
@@ -1371,6 +1371,12 @@ export class CodexAccountService {
|
||||
return root
|
||||
}
|
||||
|
||||
/** Root that owns every managed account home; lets callers validate a pane's
|
||||
* recorded launch home without reaching into private state. */
|
||||
getManagedAccountsRootPath(): string {
|
||||
return this.getManagedAccountsRoot()
|
||||
}
|
||||
|
||||
private ensureManagedHomeForReauthentication(account: CodexManagedAccount): string {
|
||||
const wslInfo = parseWslUncPath(account.managedHomePath)
|
||||
if (wslInfo && process.platform === 'win32') {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path'
|
||||
import {
|
||||
_internals,
|
||||
bridgeCodexSessionsIntoAccountHome,
|
||||
ensureCodexRolloutBridgedIntoAccountHome,
|
||||
startCodexAccountSessionBridgeInBackground
|
||||
} from './codex-account-session-bridge'
|
||||
|
||||
@@ -123,6 +124,91 @@ describe('bridgeCodexSessionsIntoAccountHome', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureCodexRolloutBridgedIntoAccountHome', () => {
|
||||
const THREAD_ID = '123e4567-e89b-42d3-a456-426614174000'
|
||||
const THREAD_ROLLOUT = join('2026', '07', '20', `rollout-2026-07-20T10-00-00-${THREAD_ID}.jsonl`)
|
||||
|
||||
it('links the located thread rollout into the target home at the same relative path', async () => {
|
||||
const sourceHome = join(workspaceRoot, 'old-account')
|
||||
const targetHome = join(workspaceRoot, 'new-account')
|
||||
const sourcePath = writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
|
||||
|
||||
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
|
||||
threadId: THREAD_ID,
|
||||
sourceCodexHomePath: sourceHome,
|
||||
targetCodexHomePath: targetHome
|
||||
})
|
||||
|
||||
expect(targetPath).toBe(rolloutPath(targetHome, THREAD_ROLLOUT))
|
||||
// Why one inode: the resumed session appends to the same physical log.
|
||||
expect(statSync(targetPath!).ino).toBe(statSync(sourcePath).ino)
|
||||
})
|
||||
|
||||
it('accepts the hook-reported transcript path when it lives under the source home', async () => {
|
||||
const sourceHome = join(workspaceRoot, 'old-account')
|
||||
const targetHome = join(workspaceRoot, 'new-account')
|
||||
const sourcePath = writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
|
||||
|
||||
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
|
||||
threadId: THREAD_ID,
|
||||
transcriptPath: sourcePath,
|
||||
sourceCodexHomePath: sourceHome,
|
||||
targetCodexHomePath: targetHome
|
||||
})
|
||||
|
||||
expect(targetPath).toBe(rolloutPath(targetHome, THREAD_ROLLOUT))
|
||||
})
|
||||
|
||||
it('returns the existing target without relinking when the background bridge won', async () => {
|
||||
const sourceHome = join(workspaceRoot, 'old-account')
|
||||
const targetHome = join(workspaceRoot, 'new-account')
|
||||
writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
|
||||
writeRollout(targetHome, THREAD_ROLLOUT, 'already bridged\n')
|
||||
|
||||
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
|
||||
threadId: THREAD_ID,
|
||||
sourceCodexHomePath: sourceHome,
|
||||
targetCodexHomePath: targetHome
|
||||
})
|
||||
|
||||
expect(targetPath).toBe(rolloutPath(targetHome, THREAD_ROLLOUT))
|
||||
expect(readFileSync(targetPath!, 'utf-8')).toBe('already bridged\n')
|
||||
})
|
||||
|
||||
it('declines when the thread has no rollout under the source home', async () => {
|
||||
const sourceHome = join(workspaceRoot, 'old-account')
|
||||
const targetHome = join(workspaceRoot, 'new-account')
|
||||
writeRollout(sourceHome, ROLLOUT_A, 'someone else\n')
|
||||
|
||||
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
|
||||
threadId: THREAD_ID,
|
||||
sourceCodexHomePath: sourceHome,
|
||||
targetCodexHomePath: targetHome
|
||||
})
|
||||
|
||||
expect(targetPath).toBeNull()
|
||||
})
|
||||
|
||||
it('declines when the reported transcript path lies outside the source home', async () => {
|
||||
const sourceHome = join(workspaceRoot, 'old-account')
|
||||
const foreignHome = join(workspaceRoot, 'foreign')
|
||||
const targetHome = join(workspaceRoot, 'new-account')
|
||||
// Why: a same-id rollout also exists under the source home, but rejected
|
||||
// provenance must not fall back to the id scan and resume it anyway.
|
||||
writeRollout(sourceHome, THREAD_ROLLOUT, 'thread\n')
|
||||
const foreignPath = writeRollout(foreignHome, THREAD_ROLLOUT, 'foreign\n')
|
||||
|
||||
const targetPath = await ensureCodexRolloutBridgedIntoAccountHome({
|
||||
threadId: THREAD_ID,
|
||||
transcriptPath: foreignPath,
|
||||
sourceCodexHomePath: sourceHome,
|
||||
targetCodexHomePath: targetHome
|
||||
})
|
||||
|
||||
expect(targetPath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startCodexAccountSessionBridgeInBackground', () => {
|
||||
it('shares one in-flight task per target home', () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-p
|
||||
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
|
||||
import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing'
|
||||
import { linkCodexSessionFile } from './codex-session-link'
|
||||
import { findTrustedCodexSessionResume } from './codex-session-resume-home'
|
||||
|
||||
/**
|
||||
* Bridges Codex history between Orca-managed Codex homes.
|
||||
@@ -81,6 +82,56 @@ export async function bridgeCodexSessionsIntoAccountHome(args: {
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges ONE thread's rollout into the target home ahead of `codex resume`.
|
||||
*
|
||||
* Why: the whole-tree bridge above runs in the background, and an account-switch
|
||||
* restart must not race it — `codex resume` fails outright when the rollout is
|
||||
* not yet linked under the launch home. Locating the rollout under the SOURCE
|
||||
* home is also the attribution check: a thread that does not resolve there
|
||||
* cannot be this pane's, so the caller drops the resume. Returns the target
|
||||
* rollout path, or null when the thread cannot be bridged.
|
||||
*/
|
||||
export async function ensureCodexRolloutBridgedIntoAccountHome(args: {
|
||||
threadId: string
|
||||
transcriptPath?: string
|
||||
sourceCodexHomePath: string
|
||||
targetCodexHomePath: string
|
||||
}): Promise<string | null> {
|
||||
const located = await findTrustedCodexSessionResume({
|
||||
sessionId: args.threadId,
|
||||
transcriptPath: args.transcriptPath,
|
||||
trustedCodexHomes: [args.sourceCodexHomePath],
|
||||
getSelectedAccountCodexHome: () => null,
|
||||
systemCodexHomePath: null,
|
||||
sharedRuntimeCodexHomePath: null
|
||||
})
|
||||
if (!located) {
|
||||
return null
|
||||
}
|
||||
const sourceSessionsRoot = join(args.sourceCodexHomePath, 'sessions')
|
||||
const targetSessionsRoot = join(args.targetCodexHomePath, 'sessions')
|
||||
const targetFilePath = join(
|
||||
targetSessionsRoot,
|
||||
relative(sourceSessionsRoot, located.transcriptPath)
|
||||
)
|
||||
if (existsSync(targetFilePath)) {
|
||||
return targetFilePath
|
||||
}
|
||||
try {
|
||||
mkdirSync(dirname(targetFilePath), { recursive: true })
|
||||
} catch (error) {
|
||||
console.warn('[codex-account-session-bridge] Failed to create session directory:', error)
|
||||
return null
|
||||
}
|
||||
if (linkCodexSessionFile(located.transcriptPath, targetFilePath)) {
|
||||
return targetFilePath
|
||||
}
|
||||
// Why: the background bridge may have linked the same rollout between the
|
||||
// existence check and the link attempt; a lost race is still a success.
|
||||
return existsSync(targetFilePath) ? targetFilePath : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Links one rollout into the target sessions tree at the same relative path.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CodexManagedAccount } from '../../shared/types'
|
||||
import { CodexAppServerCapabilityCache } from './codex-app-server-capability-cache'
|
||||
import {
|
||||
CodexAppServerUnsupportedError,
|
||||
type CodexAppServerInvocation,
|
||||
type CodexAppServerRpc,
|
||||
type runCodexAppServerSession
|
||||
} from './codex-app-server-session'
|
||||
import {
|
||||
CODEX_GOAL_RPC_HOST_KEY,
|
||||
prepareCodexAccountSwitchResume,
|
||||
resolveCodexAccountSwitchHomes,
|
||||
type CodexAccountSwitchHomes,
|
||||
type PrepareCodexAccountSwitchResumeDeps
|
||||
} from './codex-account-switch-resume'
|
||||
import type { CodexPaneAccountRecord } from './codex-pane-account-registry'
|
||||
|
||||
const THREAD_ID = '123e4567-e89b-42d3-a456-426614174000'
|
||||
const OLD_HOME = '/managed/account-a/home'
|
||||
const NEW_HOME = '/managed/account-b/home'
|
||||
const SYSTEM_HOME = '/users/dev/.codex'
|
||||
const SHARED_HOME = '/orca/codex-runtime-home'
|
||||
const ACCOUNTS_ROOT = '/managed'
|
||||
|
||||
function managedAccount(overrides: Partial<CodexManagedAccount> = {}): CodexManagedAccount {
|
||||
return {
|
||||
id: 'account-a',
|
||||
email: 'a@example.com',
|
||||
managedHomePath: OLD_HOME,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function hostRecord(overrides: Partial<CodexPaneAccountRecord> = {}): CodexPaneAccountRecord {
|
||||
return { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home', ...overrides }
|
||||
}
|
||||
|
||||
function resolveHomes(args: {
|
||||
record: CodexPaneAccountRecord | null
|
||||
accounts?: CodexManagedAccount[]
|
||||
selectedHostAccountCodexHomePath?: string | null
|
||||
hostSystemDefaultRealHome?: boolean
|
||||
assertOwnedManagedHome?: () => string
|
||||
}): ReturnType<typeof resolveCodexAccountSwitchHomes> {
|
||||
return resolveCodexAccountSwitchHomes({
|
||||
record: args.record,
|
||||
settings: { codexManagedAccounts: args.accounts ?? [managedAccount()] },
|
||||
managedAccountsRoot: ACCOUNTS_ROOT,
|
||||
systemCodexHomePath: SYSTEM_HOME,
|
||||
sharedRuntimeCodexHomePath: SHARED_HOME,
|
||||
selectedHostAccountCodexHomePath:
|
||||
args.selectedHostAccountCodexHomePath === undefined
|
||||
? NEW_HOME
|
||||
: args.selectedHostAccountCodexHomePath,
|
||||
hostSystemDefaultRealHome: args.hostSystemDefaultRealHome ?? false,
|
||||
assertOwnedManagedHome: args.assertOwnedManagedHome ?? (() => OLD_HOME)
|
||||
})
|
||||
}
|
||||
|
||||
describe('resolveCodexAccountSwitchHomes', () => {
|
||||
it('resolves account-home to new-selection homes for a recorded host pane', () => {
|
||||
expect(resolveHomes({ record: hostRecord() })).toEqual({
|
||||
outcome: 'homes',
|
||||
homes: { oldCodexHomePath: OLD_HOME, newCodexHomePath: NEW_HOME }
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a real-home launch and a system-default new selection', () => {
|
||||
expect(
|
||||
resolveHomes({
|
||||
record: hostRecord({ accountId: null, homeRoute: 'real-home' }),
|
||||
selectedHostAccountCodexHomePath: null,
|
||||
hostSystemDefaultRealHome: true
|
||||
})
|
||||
).toEqual({
|
||||
outcome: 'homes',
|
||||
homes: { oldCodexHomePath: SYSTEM_HOME, newCodexHomePath: SYSTEM_HOME }
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the legacy shared mirror on both sides when no lane routes elsewhere', () => {
|
||||
expect(
|
||||
resolveHomes({
|
||||
record: hostRecord({ homeRoute: 'shared-home' }),
|
||||
selectedHostAccountCodexHomePath: null
|
||||
})
|
||||
).toEqual({
|
||||
outcome: 'homes',
|
||||
homes: { oldCodexHomePath: SHARED_HOME, newCodexHomePath: SHARED_HOME }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unrecorded pane', null, 'pane-launch-unrecorded'],
|
||||
['WSL lane', hostRecord({ selectionKey: 'wsl:Ubuntu' }), 'non-host-lane'],
|
||||
[
|
||||
'shell-startup home override',
|
||||
hostRecord({
|
||||
shellStartupHomeOverride: { home: '/users/dev', codexHome: '/custom' }
|
||||
}),
|
||||
'custom-home-override'
|
||||
],
|
||||
[
|
||||
'environment home override',
|
||||
hostRecord({ environmentHomeOverride: { codexHome: '/custom' } }),
|
||||
'custom-home-override'
|
||||
],
|
||||
['custom-home route', hostRecord({ homeRoute: 'custom-home' }), 'unattributable-home-route'],
|
||||
['wsl-home route', hostRecord({ homeRoute: 'wsl-home' }), 'unattributable-home-route'],
|
||||
[
|
||||
'record without route provenance',
|
||||
hostRecord({ homeRoute: undefined }),
|
||||
'unattributable-home-route'
|
||||
],
|
||||
['removed launch account', hostRecord({ accountId: 'gone' }), 'launch-account-removed']
|
||||
] as const)('declines with fresh on %s', (_label, record, reason) => {
|
||||
expect(resolveHomes({ record: record as CodexPaneAccountRecord | null })).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason
|
||||
})
|
||||
})
|
||||
|
||||
it('declines when a WSL account owns the recorded account id', () => {
|
||||
expect(
|
||||
resolveHomes({
|
||||
record: hostRecord(),
|
||||
accounts: [managedAccount({ managedHomeRuntime: 'wsl', wslDistro: 'Ubuntu' })]
|
||||
})
|
||||
).toEqual({ outcome: 'fresh', reason: 'launch-account-removed' })
|
||||
})
|
||||
|
||||
it('declines when the recorded managed home fails the ownership check', () => {
|
||||
expect(
|
||||
resolveHomes({
|
||||
record: hostRecord(),
|
||||
assertOwnedManagedHome: () => {
|
||||
throw new Error('escapes the managed root')
|
||||
}
|
||||
})
|
||||
).toEqual({ outcome: 'fresh', reason: 'untrusted-managed-home' })
|
||||
})
|
||||
})
|
||||
|
||||
type RecordedCall = { home: string; method: string; params: Record<string, unknown> | undefined }
|
||||
|
||||
function createFakeSessionRunner(script: (call: RecordedCall) => unknown): {
|
||||
runner: typeof runCodexAppServerSession
|
||||
calls: RecordedCall[]
|
||||
} {
|
||||
const calls: RecordedCall[] = []
|
||||
const runner = (async <T>(
|
||||
invocation: CodexAppServerInvocation,
|
||||
body: (rpc: CodexAppServerRpc) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const home = invocation.env?.CODEX_HOME ?? ''
|
||||
return body({
|
||||
request: async (method, params) => {
|
||||
const call = { home, method, params }
|
||||
calls.push(call)
|
||||
return script(call)
|
||||
},
|
||||
notify: () => {}
|
||||
})
|
||||
}) as typeof runCodexAppServerSession
|
||||
return { runner, calls }
|
||||
}
|
||||
|
||||
const CROSS_HOME: CodexAccountSwitchHomes = {
|
||||
oldCodexHomePath: OLD_HOME,
|
||||
newCodexHomePath: NEW_HOME
|
||||
}
|
||||
|
||||
function prepareDeps(args: {
|
||||
script: (call: RecordedCall) => unknown
|
||||
homes?: CodexAccountSwitchHomes
|
||||
bridged?: boolean
|
||||
cache?: CodexAppServerCapabilityCache
|
||||
nowMs?: () => number
|
||||
}): {
|
||||
deps: PrepareCodexAccountSwitchResumeDeps
|
||||
calls: RecordedCall[]
|
||||
bridgedHomes: CodexAccountSwitchHomes[]
|
||||
} {
|
||||
const { runner, calls } = createFakeSessionRunner(args.script)
|
||||
const bridgedHomes: CodexAccountSwitchHomes[] = []
|
||||
return {
|
||||
deps: {
|
||||
resolveHomes: () => ({ outcome: 'homes', homes: args.homes ?? CROSS_HOME }),
|
||||
ensureRolloutBridged: async (homes) => {
|
||||
bridgedHomes.push(homes)
|
||||
return args.bridged ?? true
|
||||
},
|
||||
runAppServerSession: runner,
|
||||
buildInvocation: (codexHomePath, timeoutMs) => ({
|
||||
command: 'codex',
|
||||
args: ['app-server'],
|
||||
env: { CODEX_HOME: codexHomePath },
|
||||
timeoutMs
|
||||
}),
|
||||
capabilityCache: args.cache ?? new CodexAppServerCapabilityCache(),
|
||||
...(args.nowMs ? { nowMs: args.nowMs } : {})
|
||||
},
|
||||
calls,
|
||||
bridgedHomes
|
||||
}
|
||||
}
|
||||
|
||||
const GOAL = {
|
||||
threadId: THREAD_ID,
|
||||
objective: 'ship the fix',
|
||||
status: 'usageLimited',
|
||||
tokenBudget: 250_000,
|
||||
tokensUsed: 1000,
|
||||
timeUsedSeconds: 60,
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
|
||||
describe('prepareCodexAccountSwitchResume', () => {
|
||||
it('reads the goal from the old home and writes it after thread/read in the new home', async () => {
|
||||
const { deps, calls, bridgedHomes } = prepareDeps({
|
||||
script: (call) => (call.method === 'thread/goal/get' ? { goal: GOAL } : {})
|
||||
})
|
||||
|
||||
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
|
||||
expect(decision).toEqual({ outcome: 'resume', threadId: THREAD_ID })
|
||||
expect(bridgedHomes).toEqual([CROSS_HOME])
|
||||
expect(calls).toEqual([
|
||||
{ home: OLD_HOME, method: 'thread/goal/get', params: { threadId: THREAD_ID } },
|
||||
{ home: NEW_HOME, method: 'thread/read', params: { threadId: THREAD_ID } },
|
||||
{
|
||||
home: NEW_HOME,
|
||||
method: 'thread/goal/set',
|
||||
params: {
|
||||
threadId: THREAD_ID,
|
||||
objective: 'ship the fix',
|
||||
status: 'usageLimited',
|
||||
tokenBudget: 250_000
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('still resumes a goalless thread, validating it via thread/read alone', async () => {
|
||||
const { deps, calls } = prepareDeps({
|
||||
script: (call) => (call.method === 'thread/goal/get' ? { goal: null } : {})
|
||||
})
|
||||
|
||||
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
|
||||
expect(decision).toEqual({ outcome: 'resume', threadId: THREAD_ID })
|
||||
expect(calls.map((call) => call.method)).toEqual(['thread/goal/get', 'thread/read'])
|
||||
})
|
||||
|
||||
it('normalizes a missing token budget to null for the write', async () => {
|
||||
const { deps, calls } = prepareDeps({
|
||||
script: (call) =>
|
||||
call.method === 'thread/goal/get' ? { goal: { ...GOAL, tokenBudget: undefined } } : {}
|
||||
})
|
||||
|
||||
await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
|
||||
expect(calls.at(-1)?.params).toMatchObject({ tokenBudget: null })
|
||||
})
|
||||
|
||||
it('skips the bridge entirely when the launch home is also the new home', async () => {
|
||||
const { deps, calls, bridgedHomes } = prepareDeps({
|
||||
script: () => ({ goal: GOAL }),
|
||||
homes: { oldCodexHomePath: OLD_HOME, newCodexHomePath: OLD_HOME }
|
||||
})
|
||||
|
||||
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
|
||||
// Why: one home means one goals DB and one rollout tree; resume carries both.
|
||||
expect(decision).toEqual({ outcome: 'resume', threadId: THREAD_ID })
|
||||
expect(bridgedHomes).toEqual([])
|
||||
expect(calls.map((call) => call.method)).toEqual(['thread/goal/get'])
|
||||
})
|
||||
|
||||
it('declines a thread id that is not a bare rollout UUID without touching anything', async () => {
|
||||
const { deps, calls } = prepareDeps({ script: () => ({}) })
|
||||
|
||||
const decision = await prepareCodexAccountSwitchResume({ threadId: '$(rm -rf ~); echo' }, deps)
|
||||
|
||||
expect(decision).toEqual({ outcome: 'fresh', reason: 'invalid-thread-id' })
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('passes a homes decline through untouched', async () => {
|
||||
const { deps, calls } = prepareDeps({ script: () => ({}) })
|
||||
deps.resolveHomes = () => ({ outcome: 'fresh', reason: 'non-host-lane' })
|
||||
|
||||
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason: 'non-host-lane'
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('declines when the rollout cannot be bridged, before any new-home RPC', async () => {
|
||||
const { deps, calls } = prepareDeps({
|
||||
script: () => ({ goal: GOAL }),
|
||||
bridged: false
|
||||
})
|
||||
|
||||
const decision = await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
|
||||
expect(decision).toEqual({ outcome: 'fresh', reason: 'rollout-not-bridged' })
|
||||
expect(calls.map((call) => call.method)).toEqual(['thread/goal/get'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['thread/read', 'thread/read'],
|
||||
['thread/goal/set', 'thread/goal/set']
|
||||
])('declines when %s fails in the new home', async (_label, failingMethod) => {
|
||||
const { deps } = prepareDeps({
|
||||
script: (call) => {
|
||||
if (call.method === failingMethod) {
|
||||
throw new Error(`codex app-server ${failingMethod} failed: boom`)
|
||||
}
|
||||
return call.method === 'thread/goal/get' ? { goal: GOAL } : {}
|
||||
}
|
||||
})
|
||||
|
||||
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason: 'goal-write-failed'
|
||||
})
|
||||
})
|
||||
|
||||
it('declines on an unrecognizable goal payload instead of guessing', async () => {
|
||||
const { deps } = prepareDeps({
|
||||
script: () => ({ goal: { status: 'usageLimited' } })
|
||||
})
|
||||
|
||||
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason: 'goal-shape-unexpected'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a transient read failure as fresh without poisoning the capability', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
const { deps } = prepareDeps({
|
||||
script: () => {
|
||||
throw new Error('spawn ENOENT')
|
||||
},
|
||||
cache
|
||||
})
|
||||
|
||||
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason: 'goal-read-failed'
|
||||
})
|
||||
expect(cache.shouldTry(CODEX_GOAL_RPC_HOST_KEY)).toBe(true)
|
||||
})
|
||||
|
||||
describe('capability gate', () => {
|
||||
it('remembers an unsupported CLI and stops probing until the retry interval', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
let now = 1_000
|
||||
const { deps, calls } = prepareDeps({
|
||||
script: () => {
|
||||
throw new CodexAppServerUnsupportedError('thread/goal/get: method not found')
|
||||
},
|
||||
cache,
|
||||
nowMs: () => now
|
||||
})
|
||||
|
||||
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason: 'goal-rpc-unsupported'
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
// Second attempt inside the interval never spawns an app-server.
|
||||
expect(await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)).toEqual({
|
||||
outcome: 'fresh',
|
||||
reason: 'goal-rpc-unsupported'
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
// Why: an in-place codex upgrade must self-heal after the interval.
|
||||
now += 31 * 60_000
|
||||
await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('marks the capability supported after a successful goal read', async () => {
|
||||
const cache = new CodexAppServerCapabilityCache()
|
||||
const { deps } = prepareDeps({ script: () => ({ goal: null }), cache })
|
||||
|
||||
await prepareCodexAccountSwitchResume({ threadId: THREAD_ID }, deps)
|
||||
|
||||
expect(cache.isKnownSupported(CODEX_GOAL_RPC_HOST_KEY)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,334 @@
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { assertOwnedHostCodexManagedHomePath } from '../codex-accounts/host-codex-managed-home-ownership'
|
||||
import { getCodexSelectionTargetForAccount } from '../codex-accounts/runtime-selection'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { ensureCodexRolloutBridgedIntoAccountHome } from './codex-account-session-bridge'
|
||||
import { CodexAppServerCapabilityCache } from './codex-app-server-capability-cache'
|
||||
import {
|
||||
isCodexAppServerUnsupportedError,
|
||||
runCodexAppServerSession,
|
||||
type CodexAppServerInvocation
|
||||
} from './codex-app-server-session'
|
||||
import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from './codex-home-paths'
|
||||
import { getCodexPaneAccount, type CodexPaneAccountRecord } from './codex-pane-account-registry'
|
||||
|
||||
// Why: an account-switch restart used to always start a brand-new Codex thread,
|
||||
// which stranded the pane's /goal — Codex keys thread_goals by thread id inside
|
||||
// the launch CODEX_HOME's sqlite, so the goal became doubly unreachable (new
|
||||
// thread, other home). This module decides whether the restarted pane may
|
||||
// `codex resume` its live thread instead, and carries the goal row across homes
|
||||
// through the sanctioned app-server RPCs (thread/goal/get → thread/goal/set).
|
||||
// Orca never reads or writes Codex's sqlite itself: the app-server owns the DB
|
||||
// and its WAL. Any doubt — unrecorded pane, non-host lane, custom home, missing
|
||||
// rollout, old CLI without the goal RPCs — degrades to today's fresh `codex`
|
||||
// startup, because resuming the wrong session is worse than losing the goal.
|
||||
|
||||
const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
|
||||
|
||||
const GOAL_READ_TIMEOUT_MS = 10_000
|
||||
const GOAL_WRITE_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Goal RPCs are probed on the native host only; WSL/remote lanes never reach
|
||||
* the probe (their panes degrade to a fresh startup in resolveHomes). */
|
||||
export const CODEX_GOAL_RPC_HOST_KEY = 'native' as const
|
||||
|
||||
/** Separate cache from the trust-grant pair: the goal RPCs shipped later, so a
|
||||
* CLI can support one surface and not the other. */
|
||||
export const codexGoalRpcCapabilityCache = new CodexAppServerCapabilityCache()
|
||||
|
||||
export type CodexAccountSwitchResumeDecision =
|
||||
| { outcome: 'resume'; threadId: string }
|
||||
| { outcome: 'fresh'; reason: string }
|
||||
|
||||
export type CodexAccountSwitchHomes = {
|
||||
oldCodexHomePath: string
|
||||
newCodexHomePath: string
|
||||
}
|
||||
|
||||
export type CodexAccountSwitchHomesDecision =
|
||||
| { outcome: 'homes'; homes: CodexAccountSwitchHomes }
|
||||
| { outcome: 'fresh'; reason: string }
|
||||
|
||||
type CodexThreadGoalSnapshot = {
|
||||
objective: string
|
||||
status: string
|
||||
tokenBudget: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the CODEX_HOME the pane launched under and the one the restart will
|
||||
* inject, from the pane-account registry record and the current host selection.
|
||||
*
|
||||
* Declines (fresh) whenever either home cannot be named with certainty: the
|
||||
* record is what the spawn actually resolved, so nothing here re-derives from
|
||||
* live settings except the new selection itself.
|
||||
*/
|
||||
export function resolveCodexAccountSwitchHomes(args: {
|
||||
record: CodexPaneAccountRecord | null
|
||||
settings: Pick<GlobalSettings, 'codexManagedAccounts'>
|
||||
managedAccountsRoot: string
|
||||
systemCodexHomePath: string
|
||||
sharedRuntimeCodexHomePath: string
|
||||
selectedHostAccountCodexHomePath: string | null
|
||||
hostSystemDefaultRealHome: boolean
|
||||
assertOwnedManagedHome?: typeof assertOwnedHostCodexManagedHomePath
|
||||
}): CodexAccountSwitchHomesDecision {
|
||||
const record = args.record
|
||||
if (!record) {
|
||||
return { outcome: 'fresh', reason: 'pane-launch-unrecorded' }
|
||||
}
|
||||
if (record.selectionKey !== 'host') {
|
||||
return { outcome: 'fresh', reason: 'non-host-lane' }
|
||||
}
|
||||
// Why: an observed override means the shell, not Orca, picked the launch
|
||||
// home — the same shell can redirect the restarted spawn too, so neither
|
||||
// side of the bridge can be predicted.
|
||||
if (record.shellStartupHomeOverride || record.environmentHomeOverride) {
|
||||
return { outcome: 'fresh', reason: 'custom-home-override' }
|
||||
}
|
||||
const oldHome = resolveRecordedLaunchHome(args, record)
|
||||
if (oldHome.outcome === 'fresh') {
|
||||
return oldHome
|
||||
}
|
||||
const newCodexHomePath =
|
||||
args.selectedHostAccountCodexHomePath ??
|
||||
(args.hostSystemDefaultRealHome ? args.systemCodexHomePath : args.sharedRuntimeCodexHomePath)
|
||||
return {
|
||||
outcome: 'homes',
|
||||
homes: { oldCodexHomePath: oldHome.path, newCodexHomePath }
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecordedLaunchHome(
|
||||
args: {
|
||||
settings: Pick<GlobalSettings, 'codexManagedAccounts'>
|
||||
managedAccountsRoot: string
|
||||
systemCodexHomePath: string
|
||||
sharedRuntimeCodexHomePath: string
|
||||
assertOwnedManagedHome?: typeof assertOwnedHostCodexManagedHomePath
|
||||
},
|
||||
record: CodexPaneAccountRecord
|
||||
): { outcome: 'home'; path: string } | { outcome: 'fresh'; reason: string } {
|
||||
switch (record.homeRoute) {
|
||||
case 'real-home':
|
||||
return { outcome: 'home', path: args.systemCodexHomePath }
|
||||
case 'shared-home':
|
||||
return { outcome: 'home', path: args.sharedRuntimeCodexHomePath }
|
||||
case 'account-home': {
|
||||
const account = args.settings.codexManagedAccounts?.find(
|
||||
(candidate) =>
|
||||
candidate.id === record.accountId &&
|
||||
getCodexSelectionTargetForAccount(candidate).runtime === 'host'
|
||||
)
|
||||
if (!account) {
|
||||
return { outcome: 'fresh', reason: 'launch-account-removed' }
|
||||
}
|
||||
try {
|
||||
;(args.assertOwnedManagedHome ?? assertOwnedHostCodexManagedHomePath)({
|
||||
candidatePath: account.managedHomePath,
|
||||
managedAccountsRoot: args.managedAccountsRoot,
|
||||
systemCodexHomePath: args.systemCodexHomePath,
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
} catch {
|
||||
return { outcome: 'fresh', reason: 'untrusted-managed-home' }
|
||||
}
|
||||
return { outcome: 'home', path: account.managedHomePath }
|
||||
}
|
||||
// Why: a custom CODEX_HOME lives outside Orca's management, and a record
|
||||
// without route provenance predates it entirely — neither names a home.
|
||||
case 'custom-home':
|
||||
case 'wsl-home':
|
||||
case undefined:
|
||||
return { outcome: 'fresh', reason: 'unattributable-home-route' }
|
||||
}
|
||||
}
|
||||
|
||||
export type PrepareCodexAccountSwitchResumeDeps = {
|
||||
resolveHomes: () => CodexAccountSwitchHomesDecision
|
||||
/** Guarantees the thread's rollout is linked under the new home before Orca
|
||||
* commits to `codex resume`; false drops the resume. */
|
||||
ensureRolloutBridged: (homes: CodexAccountSwitchHomes) => Promise<boolean>
|
||||
runAppServerSession?: typeof runCodexAppServerSession
|
||||
buildInvocation?: (codexHomePath: string, timeoutMs: number) => CodexAppServerInvocation
|
||||
capabilityCache?: CodexAppServerCapabilityCache
|
||||
nowMs?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether an account-switch restart may resume the pane's thread, and
|
||||
* performs the goal bridge as part of deciding: `resume` is only answered once
|
||||
* the rollout is present in the new home, `thread/read` proved it resumable
|
||||
* there, and any goal row was written through `thread/goal/set`.
|
||||
*/
|
||||
export async function prepareCodexAccountSwitchResume(
|
||||
request: { threadId: string },
|
||||
deps: PrepareCodexAccountSwitchResumeDeps
|
||||
): Promise<CodexAccountSwitchResumeDecision> {
|
||||
const { threadId } = request
|
||||
if (!CODEX_THREAD_ID_PATTERN.test(threadId)) {
|
||||
return { outcome: 'fresh', reason: 'invalid-thread-id' }
|
||||
}
|
||||
const homesDecision = deps.resolveHomes()
|
||||
if (homesDecision.outcome === 'fresh') {
|
||||
return homesDecision
|
||||
}
|
||||
const { homes } = homesDecision
|
||||
const cache = deps.capabilityCache ?? codexGoalRpcCapabilityCache
|
||||
const nowMs = deps.nowMs ?? ((): number => Date.now())
|
||||
if (
|
||||
!cache.isKnownSupported(CODEX_GOAL_RPC_HOST_KEY) &&
|
||||
!cache.shouldTry(CODEX_GOAL_RPC_HOST_KEY, nowMs())
|
||||
) {
|
||||
return { outcome: 'fresh', reason: 'goal-rpc-unsupported' }
|
||||
}
|
||||
const runSession = deps.runAppServerSession ?? runCodexAppServerSession
|
||||
const buildInvocation = deps.buildInvocation ?? buildCodexGoalRpcInvocation
|
||||
|
||||
let goal: CodexThreadGoalSnapshot | null
|
||||
try {
|
||||
// Why: the get doubles as the capability probe — a method-not-found reply or
|
||||
// a missing app-server subcommand is the only signal that marks unsupported.
|
||||
const result = await runSession(
|
||||
buildInvocation(homes.oldCodexHomePath, GOAL_READ_TIMEOUT_MS),
|
||||
(rpc) => rpc.request('thread/goal/get', { threadId })
|
||||
)
|
||||
const parsed = parseThreadGoalSnapshot(result)
|
||||
if (parsed.outcome === 'malformed') {
|
||||
return { outcome: 'fresh', reason: 'goal-shape-unexpected' }
|
||||
}
|
||||
goal = parsed.goal
|
||||
cache.rememberSupported(CODEX_GOAL_RPC_HOST_KEY)
|
||||
} catch (error) {
|
||||
if (isCodexAppServerUnsupportedError(error)) {
|
||||
cache.rememberUnsupported(CODEX_GOAL_RPC_HOST_KEY, nowMs())
|
||||
return { outcome: 'fresh', reason: 'goal-rpc-unsupported' }
|
||||
}
|
||||
return { outcome: 'fresh', reason: 'goal-read-failed' }
|
||||
}
|
||||
|
||||
if (
|
||||
normalizeRuntimePathForComparison(homes.oldCodexHomePath) ===
|
||||
normalizeRuntimePathForComparison(homes.newCodexHomePath)
|
||||
) {
|
||||
// Same home keeps the same goals DB and rollout tree; resume alone carries both.
|
||||
return { outcome: 'resume', threadId }
|
||||
}
|
||||
|
||||
if (!(await deps.ensureRolloutBridged(homes))) {
|
||||
return { outcome: 'fresh', reason: 'rollout-not-bridged' }
|
||||
}
|
||||
|
||||
try {
|
||||
await runSession(
|
||||
buildInvocation(homes.newCodexHomePath, GOAL_WRITE_TIMEOUT_MS),
|
||||
async (rpc) => {
|
||||
// Why: thread/read is Codex's sanctioned lazy indexing — it parses the
|
||||
// bridged rollout and upserts the thread row, proving the thread is
|
||||
// resumable in this home before Orca commits the pane to `codex resume`.
|
||||
await rpc.request('thread/read', { threadId })
|
||||
if (goal) {
|
||||
await rpc.request('thread/goal/set', {
|
||||
threadId,
|
||||
objective: goal.objective,
|
||||
status: goal.status,
|
||||
tokenBudget: goal.tokenBudget
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (isCodexAppServerUnsupportedError(error)) {
|
||||
cache.rememberUnsupported(CODEX_GOAL_RPC_HOST_KEY, nowMs())
|
||||
}
|
||||
console.warn('[codex-account-switch-resume] Goal bridge into new home failed:', error)
|
||||
return { outcome: 'fresh', reason: 'goal-write-failed' }
|
||||
}
|
||||
return { outcome: 'resume', threadId }
|
||||
}
|
||||
|
||||
/** Tolerates future status values on purpose: both homes run the same codex
|
||||
* binary, so whatever the old home reported the new home can store. */
|
||||
function parseThreadGoalSnapshot(
|
||||
result: unknown
|
||||
): { outcome: 'parsed'; goal: CodexThreadGoalSnapshot | null } | { outcome: 'malformed' } {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { outcome: 'malformed' }
|
||||
}
|
||||
const goal = (result as { goal?: unknown }).goal
|
||||
if (goal === null || goal === undefined) {
|
||||
return { outcome: 'parsed', goal: null }
|
||||
}
|
||||
if (typeof goal !== 'object') {
|
||||
return { outcome: 'malformed' }
|
||||
}
|
||||
const { objective, status, tokenBudget } = goal as Record<string, unknown>
|
||||
if (typeof objective !== 'string' || typeof status !== 'string' || status.length === 0) {
|
||||
return { outcome: 'malformed' }
|
||||
}
|
||||
return {
|
||||
outcome: 'parsed',
|
||||
goal: {
|
||||
objective,
|
||||
status,
|
||||
tokenBudget:
|
||||
typeof tokenBudget === 'number' && Number.isFinite(tokenBudget) ? tokenBudget : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexGoalRpcInvocation(
|
||||
codexHomePath: string,
|
||||
timeoutMs: number
|
||||
): CodexAppServerInvocation {
|
||||
const command = resolveCodexCommand()
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(command, ['app-server'])
|
||||
return {
|
||||
command: spawnCmd,
|
||||
args: spawnArgs,
|
||||
// Why: pin the bridged home explicitly — nested Orca launches can inherit a
|
||||
// managed CODEX_HOME, which would read or write the wrong goals DB.
|
||||
env: { CODEX_HOME: codexHomePath },
|
||||
timeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC-facing wiring: assembles the real registry, home and bridge dependencies
|
||||
* for one pane's restart. Kept apart from the decision logic so tests can fake
|
||||
* every seam.
|
||||
*/
|
||||
export async function prepareCodexAccountSwitchResumeForPane(args: {
|
||||
ptyId: string
|
||||
threadId: string
|
||||
transcriptPath?: string
|
||||
settings: GlobalSettings
|
||||
managedAccountsRoot: string
|
||||
selectedHostAccountCodexHomePath: string | null
|
||||
hostSystemDefaultRealHome: boolean
|
||||
}): Promise<CodexAccountSwitchResumeDecision> {
|
||||
return prepareCodexAccountSwitchResume(
|
||||
{ threadId: args.threadId },
|
||||
{
|
||||
resolveHomes: () =>
|
||||
resolveCodexAccountSwitchHomes({
|
||||
record: getCodexPaneAccount(args.ptyId),
|
||||
settings: args.settings,
|
||||
managedAccountsRoot: args.managedAccountsRoot,
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
sharedRuntimeCodexHomePath: resolveOrcaManagedCodexHomePath(),
|
||||
selectedHostAccountCodexHomePath: args.selectedHostAccountCodexHomePath,
|
||||
hostSystemDefaultRealHome: args.hostSystemDefaultRealHome
|
||||
}),
|
||||
ensureRolloutBridged: async (homes) =>
|
||||
(await ensureCodexRolloutBridgedIntoAccountHome({
|
||||
threadId: args.threadId,
|
||||
...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
|
||||
sourceCodexHomePath: homes.oldCodexHomePath,
|
||||
targetCodexHomePath: homes.newCodexHomePath
|
||||
})) !== null
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import { prepareCodexAccountSwitchResumeForPane } from '../codex/codex-account-switch-resume'
|
||||
import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry'
|
||||
import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
@@ -34,6 +35,35 @@ export function registerCodexAccountHandlers(
|
||||
}
|
||||
forgetStaleCodexPanes(args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'))
|
||||
})
|
||||
ipcMain.handle(
|
||||
'codexAccounts:prepareAccountSwitchResume',
|
||||
(_event, args: { ptyId?: unknown; threadId?: unknown; transcriptPath?: unknown }) => {
|
||||
const settings = getSettings?.()
|
||||
if (
|
||||
!settings ||
|
||||
typeof args?.ptyId !== 'string' ||
|
||||
typeof args.threadId !== 'string' ||
|
||||
(args.transcriptPath !== undefined && typeof args.transcriptPath !== 'string')
|
||||
) {
|
||||
return { outcome: 'fresh', reason: 'invalid-request' }
|
||||
}
|
||||
return prepareCodexAccountSwitchResumeForPane({
|
||||
ptyId: args.ptyId,
|
||||
threadId: args.threadId,
|
||||
...(typeof args.transcriptPath === 'string' ? { transcriptPath: args.transcriptPath } : {}),
|
||||
settings,
|
||||
managedAccountsRoot: codexAccounts.getManagedAccountsRootPath(),
|
||||
selectedHostAccountCodexHomePath:
|
||||
codexAccounts.runtimeHomeService.getSelectedHostAccountCodexHomePath(),
|
||||
hostSystemDefaultRealHome: codexAccounts.runtimeHomeService.isHostSystemDefaultRealHome()
|
||||
}).catch((error: unknown) => {
|
||||
// Why: a failed preparation must degrade to today's fresh startup, never
|
||||
// break the restart that is about to run.
|
||||
console.warn('[codex-accounts] Account-switch resume preparation failed:', error)
|
||||
return { outcome: 'fresh', reason: 'preparation-failed' }
|
||||
})
|
||||
}
|
||||
)
|
||||
ipcMain.handle('codexAccounts:list', () => codexAccounts.listAccounts())
|
||||
ipcMain.handle('codexAccounts:add', (_event, args?: CodexAccountAddTarget) =>
|
||||
codexAccounts.addAccount(args)
|
||||
|
||||
@@ -2395,6 +2395,14 @@ export type PreloadApi = {
|
||||
listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise<Record<string, string>>
|
||||
/** Drops launch records so a dismissed prompt stays dismissed across restarts. */
|
||||
forgetStalePanes: (args: { ptyIds: string[] }) => Promise<void>
|
||||
/** Decides whether an account-switch restart may `codex resume` the pane's
|
||||
* thread. Main bridges the rollout and the /goal row into the new home
|
||||
* before answering `resume`; every failure degrades to `fresh`. */
|
||||
prepareAccountSwitchResume: (args: {
|
||||
ptyId: string
|
||||
threadId: string
|
||||
transcriptPath?: string
|
||||
}) => Promise<{ outcome: 'resume'; threadId: string } | { outcome: 'fresh'; reason: string }>
|
||||
}
|
||||
claudeAccounts: {
|
||||
list: () => Promise<ClaudeRateLimitAccountsState>
|
||||
|
||||
@@ -2054,7 +2054,13 @@ const api = {
|
||||
listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise<Record<string, string>> =>
|
||||
ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args),
|
||||
forgetStalePanes: (args: { ptyIds: string[] }): Promise<void> =>
|
||||
ipcRenderer.invoke('codexAccounts:forgetStalePanes', args)
|
||||
ipcRenderer.invoke('codexAccounts:forgetStalePanes', args),
|
||||
prepareAccountSwitchResume: (args: {
|
||||
ptyId: string
|
||||
threadId: string
|
||||
transcriptPath?: string
|
||||
}): Promise<{ outcome: 'resume'; threadId: string } | { outcome: 'fresh'; reason: string }> =>
|
||||
ipcRenderer.invoke('codexAccounts:prepareAccountSwitchResume', args)
|
||||
},
|
||||
|
||||
claudeAccounts: {
|
||||
|
||||
@@ -144,7 +144,11 @@ import {
|
||||
readPrimarySelectionText
|
||||
} from '@/lib/primary-selection'
|
||||
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
|
||||
import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart'
|
||||
import {
|
||||
CODEX_ACCOUNT_RESTART_STARTUP,
|
||||
resolveCodexAccountRestartStartup,
|
||||
type CodexAccountRestartStartup
|
||||
} from '@/lib/codex-session-restart'
|
||||
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
|
||||
import { isTerminalSessionStateSaveFailure } from '../../../../shared/terminal-session-state-save-failure'
|
||||
import { isTerminalZeroDimensionsDiagnostic } from '../../../../shared/terminal-zero-dimensions-diagnostic'
|
||||
@@ -321,6 +325,8 @@ function TerminalPane(
|
||||
const paneKittyKeyboardModesRef = useRef<Map<number, TerminalKittyKeyboardModeTracker>>(new Map())
|
||||
const paneLastThemeModeRef = useRef<Map<number, 'dark' | 'light'>>(new Map())
|
||||
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
|
||||
// Why: an account-switch restart awaits resume preparation before teardown; a second click mid-await must not tear the same pane down twice.
|
||||
const restartingCodexPaneIdsRef = useRef<Set<number>>(new Set())
|
||||
// Why: panes replaying recorded PTY bytes; while non-zero, pty-connection drops xterm onData so auto-replies don't leak to the shell. See replay-guard.ts.
|
||||
const replayingPanesRef = useRef<Map<number, number>>(new Map())
|
||||
const isActiveRef = useRef(isActive)
|
||||
@@ -1614,17 +1620,40 @@ function TerminalPane(
|
||||
}, [])
|
||||
|
||||
const handleRestartCodexPane = useCallback(
|
||||
(paneId: number) => {
|
||||
async (paneId: number) => {
|
||||
const manager = managerRef.current
|
||||
const pane = manager?.getPanes().find((candidate) => candidate.id === paneId)
|
||||
if (!manager || !pane) {
|
||||
if (!manager || !pane || restartingCodexPaneIdsRef.current.has(paneId)) {
|
||||
return
|
||||
}
|
||||
restartingCodexPaneIdsRef.current.add(paneId)
|
||||
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
const panePtyBinding = panePtyBindingsRef.current.get(paneId)
|
||||
const existingPtyId = transport?.getPtyId()
|
||||
|
||||
// Why before teardown: resume preparation reads the live session's /goal
|
||||
// through the old home's app-server and bridges it into the new home; a
|
||||
// fresh decision leaves the restart byte-identical to today's relaunch.
|
||||
let startup: CodexAccountRestartStartup = CODEX_ACCOUNT_RESTART_STARTUP
|
||||
try {
|
||||
if (existingPtyId) {
|
||||
startup = await resolveCodexAccountRestartStartup({
|
||||
ptyId: existingPtyId,
|
||||
paneKey: makePaneKey(tabId, pane.leafId)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Why release here: everything after the await is synchronous, so no
|
||||
// second restart can interleave before this one finishes.
|
||||
restartingCodexPaneIdsRef.current.delete(paneId)
|
||||
}
|
||||
if (paneTransportsRef.current.get(paneId) !== transport) {
|
||||
// Why: the pane reconnected while preparation ran; a second teardown
|
||||
// would kill the replacement PTY.
|
||||
return
|
||||
}
|
||||
|
||||
if (existingPtyId) {
|
||||
suppressPtyExit(existingPtyId)
|
||||
clearCodexRestartNotice(existingPtyId)
|
||||
@@ -1644,7 +1673,7 @@ function TerminalPane(
|
||||
tabId,
|
||||
worktreeId,
|
||||
cwd,
|
||||
startup: CODEX_ACCOUNT_RESTART_STARTUP,
|
||||
startup,
|
||||
paneTransportsRef,
|
||||
paneMode2031Ref,
|
||||
paneKittyKeyboardModesRef,
|
||||
@@ -1723,7 +1752,7 @@ function TerminalPane(
|
||||
}
|
||||
// Why: the status-bar switcher requests a global Codex restart, but execution stays pane-scoped so a split tab doesn't lose unrelated non-Codex panes.
|
||||
if (consumePendingCodexPaneRestart(ptyId)) {
|
||||
handleRestartCodexPane(pane.id)
|
||||
void handleRestartCodexPane(pane.id)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import {
|
||||
CODEX_ACCOUNT_RESTART_STARTUP,
|
||||
resolveCodexAccountRestartStartup
|
||||
} from './codex-session-restart'
|
||||
|
||||
describe('resolveCodexAccountRestartStartup', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
const PANE_KEY = 'tab-1:leaf-1'
|
||||
const THREAD_ID = '123e4567-e89b-42d3-a456-426614174000'
|
||||
const prepareAccountSwitchResume = vi.fn()
|
||||
|
||||
function seedCodexStatus(overrides: Partial<AgentStatusEntry> = {}): void {
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
[PANE_KEY]: {
|
||||
state: 'working',
|
||||
prompt: 'ship it',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: PANE_KEY,
|
||||
stateHistory: [],
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: THREAD_ID },
|
||||
...overrides
|
||||
} as AgentStatusEntry
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prepareAccountSwitchResume.mockReset()
|
||||
prepareAccountSwitchResume.mockResolvedValue({ outcome: 'resume', threadId: THREAD_ID })
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
codexAccounts: {
|
||||
...originalWindow?.api?.codexAccounts,
|
||||
prepareAccountSwitchResume
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes the pane thread once main confirms the bridge', async () => {
|
||||
seedCodexStatus({
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: THREAD_ID,
|
||||
transcriptPath: '/old/home/sessions/2026/07/20/rollout-x.jsonl'
|
||||
}
|
||||
})
|
||||
|
||||
const startup = await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })
|
||||
|
||||
expect(startup).toEqual({
|
||||
command: `codex resume ${THREAD_ID}`,
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
})
|
||||
expect(prepareAccountSwitchResume).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
threadId: THREAD_ID,
|
||||
transcriptPath: '/old/home/sessions/2026/07/20/rollout-x.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits an absent transcript path from the preparation request', async () => {
|
||||
seedCodexStatus()
|
||||
|
||||
await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })
|
||||
|
||||
expect(prepareAccountSwitchResume).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
threadId: THREAD_ID
|
||||
})
|
||||
})
|
||||
|
||||
// Why toEqual against the literal: the fallback contract is BYTE-identical to
|
||||
// today's fresh startup — plain `codex`, delivered once the shell is ready.
|
||||
const TODAYS_STARTUP = { command: 'codex', startupCommandDelivery: 'shell-ready' }
|
||||
|
||||
it.each([
|
||||
['the pane has no agent status', (): void => {}],
|
||||
['the pane runs a different agent', (): void => seedCodexStatus({ agentType: 'claude' })],
|
||||
[
|
||||
'the status came over a remote connection',
|
||||
(): void => seedCodexStatus({ connectionId: 'conn-1' })
|
||||
],
|
||||
['the session has no provider id', (): void => seedCodexStatus({ providerSession: undefined })],
|
||||
[
|
||||
'the provider id is not a session id',
|
||||
(): void =>
|
||||
seedCodexStatus({
|
||||
providerSession: { key: 'conversation_id', id: THREAD_ID }
|
||||
})
|
||||
],
|
||||
[
|
||||
'the session id is not a bare rollout UUID',
|
||||
(): void => seedCodexStatus({ providerSession: { key: 'session_id', id: 'nested); rm -rf' } })
|
||||
]
|
||||
])("falls back to today's exact startup when %s", async (_label, seed) => {
|
||||
seed()
|
||||
|
||||
const startup = await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })
|
||||
|
||||
expect(startup).toEqual(TODAYS_STARTUP)
|
||||
expect(startup).toBe(CODEX_ACCOUNT_RESTART_STARTUP)
|
||||
expect(prepareAccountSwitchResume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never asks main about a pane on another machine', async () => {
|
||||
seedCodexStatus()
|
||||
|
||||
const startup = await resolveCodexAccountRestartStartup({
|
||||
ptyId: 'ssh:my-box@@pty-7',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
|
||||
expect(startup).toEqual(TODAYS_STARTUP)
|
||||
expect(prepareAccountSwitchResume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back when the preload predates the preparation handler', async () => {
|
||||
seedCodexStatus()
|
||||
;(
|
||||
window.api.codexAccounts as unknown as { prepareAccountSwitchResume?: unknown }
|
||||
).prepareAccountSwitchResume = undefined
|
||||
|
||||
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
|
||||
TODAYS_STARTUP
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back when the preparation IPC rejects', async () => {
|
||||
seedCodexStatus()
|
||||
prepareAccountSwitchResume.mockRejectedValue(new Error('no handler'))
|
||||
|
||||
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
|
||||
TODAYS_STARTUP
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back when main declines the resume', async () => {
|
||||
seedCodexStatus()
|
||||
prepareAccountSwitchResume.mockResolvedValue({ outcome: 'fresh', reason: 'non-host-lane' })
|
||||
|
||||
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
|
||||
TODAYS_STARTUP
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back when main answers for a different thread', async () => {
|
||||
seedCodexStatus()
|
||||
prepareAccountSwitchResume.mockResolvedValue({ outcome: 'resume', threadId: 'other-thread' })
|
||||
|
||||
expect(await resolveCodexAccountRestartStartup({ ptyId: 'pty-1', paneKey: PANE_KEY })).toEqual(
|
||||
TODAYS_STARTUP
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,61 @@ export const CODEX_ACCOUNT_RESTART_STARTUP = {
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
} as const
|
||||
|
||||
export type CodexAccountRestartStartup = {
|
||||
command: string
|
||||
startupCommandDelivery: 'shell-ready'
|
||||
}
|
||||
|
||||
const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Chooses the startup command for a Codex account-switch restart.
|
||||
*
|
||||
* Resumes the pane's live thread (`codex resume <id>`) only when main confirms
|
||||
* the whole bridge: thread attribution to the pane's launch home, the rollout
|
||||
* linked into the new home, and the /goal row carried over via the app-server
|
||||
* goal RPCs. Every other path — older codex CLIs, WSL/remote panes, unknown
|
||||
* sessions, an older preload, any IPC failure — yields exactly today's fresh
|
||||
* startup, because resuming the wrong session is worse than losing the goal.
|
||||
*/
|
||||
export async function resolveCodexAccountRestartStartup(args: {
|
||||
ptyId: string
|
||||
paneKey: string
|
||||
}): Promise<CodexAccountRestartStartup> {
|
||||
if (isForeignMachineCodexPtyId(args.ptyId)) {
|
||||
return CODEX_ACCOUNT_RESTART_STARTUP
|
||||
}
|
||||
const entry = useAppStore.getState().agentStatusByPaneKey[args.paneKey]
|
||||
const threadId = entry?.providerSession?.id
|
||||
if (
|
||||
entry?.agentType !== 'codex' ||
|
||||
entry.connectionId != null ||
|
||||
entry.providerSession?.key !== 'session_id' ||
|
||||
!threadId ||
|
||||
// Why the strict match: the id is embedded in a shell command; only a bare
|
||||
// rollout UUID may ever pass, and main re-validates it independently.
|
||||
!CODEX_THREAD_ID_PATTERN.test(threadId)
|
||||
) {
|
||||
return CODEX_ACCOUNT_RESTART_STARTUP
|
||||
}
|
||||
const prepareAccountSwitchResume = window.api.codexAccounts.prepareAccountSwitchResume
|
||||
// Why the shape check: a preload older than this handler has no such method,
|
||||
// and that must read as "resume unavailable", not as a restart failure.
|
||||
if (typeof prepareAccountSwitchResume !== 'function') {
|
||||
return CODEX_ACCOUNT_RESTART_STARTUP
|
||||
}
|
||||
const transcriptPath = entry.providerSession.transcriptPath?.trim()
|
||||
const decision = await prepareAccountSwitchResume({
|
||||
ptyId: args.ptyId,
|
||||
threadId,
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
}).catch(() => null)
|
||||
if (decision?.outcome !== 'resume' || decision.threadId !== threadId) {
|
||||
return CODEX_ACCOUNT_RESTART_STARTUP
|
||||
}
|
||||
return { command: `codex resume ${threadId}`, startupCommandDelivery: 'shell-ready' }
|
||||
}
|
||||
|
||||
export type CodexPaneScanResult = {
|
||||
ptyId: string
|
||||
/** The pane may be shown a restart prompt (see isCodexRestartEligiblePane). */
|
||||
|
||||
Reference in New Issue
Block a user