From b2f6b795e8ff41c9255f22e0422356ac0b914384 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:43:12 -0700 Subject: [PATCH] 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 `. 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. --- src/main/codex-accounts/service.ts | 6 + .../codex-account-session-bridge.test.ts | 86 ++++ .../codex/codex-account-session-bridge.ts | 51 +++ .../codex/codex-account-switch-resume.test.ts | 403 ++++++++++++++++++ src/main/codex/codex-account-switch-resume.ts | 334 +++++++++++++++ src/main/ipc/codex-accounts.ts | 30 ++ src/preload/api-types.ts | 8 + src/preload/index.ts | 8 +- .../components/terminal-pane/TerminalPane.tsx | 39 +- .../lib/codex-account-restart-startup.test.ts | 173 ++++++++ src/renderer/src/lib/codex-session-restart.ts | 55 +++ 11 files changed, 1187 insertions(+), 6 deletions(-) create mode 100644 src/main/codex/codex-account-switch-resume.test.ts create mode 100644 src/main/codex/codex-account-switch-resume.ts create mode 100644 src/renderer/src/lib/codex-account-restart-startup.test.ts diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 3618f138144..2cfca60802e 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -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') { diff --git a/src/main/codex/codex-account-session-bridge.test.ts b/src/main/codex/codex-account-session-bridge.test.ts index 7cebd37c1f4..ab56ff37f27 100644 --- a/src/main/codex/codex-account-session-bridge.test.ts +++ b/src/main/codex/codex-account-session-bridge.test.ts @@ -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') diff --git a/src/main/codex/codex-account-session-bridge.ts b/src/main/codex/codex-account-session-bridge.ts index 72096103aa1..d7158be870b 100644 --- a/src/main/codex/codex-account-session-bridge.ts +++ b/src/main/codex/codex-account-session-bridge.ts @@ -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 { + 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. */ diff --git a/src/main/codex/codex-account-switch-resume.test.ts b/src/main/codex/codex-account-switch-resume.test.ts new file mode 100644 index 00000000000..64a30e53b0e --- /dev/null +++ b/src/main/codex/codex-account-switch-resume.test.ts @@ -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 { + return { + id: 'account-a', + email: 'a@example.com', + managedHomePath: OLD_HOME, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1, + ...overrides + } +} + +function hostRecord(overrides: Partial = {}): 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 { + 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 | undefined } + +function createFakeSessionRunner(script: (call: RecordedCall) => unknown): { + runner: typeof runCodexAppServerSession + calls: RecordedCall[] +} { + const calls: RecordedCall[] = [] + const runner = (async ( + invocation: CodexAppServerInvocation, + body: (rpc: CodexAppServerRpc) => Promise + ): Promise => { + 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) + }) + }) +}) diff --git a/src/main/codex/codex-account-switch-resume.ts b/src/main/codex/codex-account-switch-resume.ts new file mode 100644 index 00000000000..1275a887479 --- /dev/null +++ b/src/main/codex/codex-account-switch-resume.ts @@ -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 + 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 + 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 + 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 { + 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 + 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 { + 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 + } + ) +} diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 0c111a5f6d8..2041751b734 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -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) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 6e70b03033d..6c5f516a05d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2395,6 +2395,14 @@ export type PreloadApi = { listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> /** Drops launch records so a dismissed prompt stays dismissed across restarts. */ forgetStalePanes: (args: { ptyIds: string[] }) => Promise + /** 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 diff --git a/src/preload/index.ts b/src/preload/index.ts index b358c689edf..60241c88b6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2054,7 +2054,13 @@ const api = { listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => - 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: { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2cd4d3c0d8b..b3b81a18be7 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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>(new Map()) const paneLastThemeModeRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(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>(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>(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) } } }, [ diff --git a/src/renderer/src/lib/codex-account-restart-startup.test.ts b/src/renderer/src/lib/codex-account-restart-startup.test.ts new file mode 100644 index 00000000000..ad50fcedfad --- /dev/null +++ b/src/renderer/src/lib/codex-account-restart-startup.test.ts @@ -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 = {}): 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 + ) + }) +}) diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index 0a8ab5ca01a..cac3828f613 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -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 `) 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 { + 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). */