diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts new file mode 100644 index 00000000000..3aa1982bc89 --- /dev/null +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' + +describe('buildCodexStructuredChildEnvironment', () => { + it('keeps shell exports while pinned launch values win', () => { + expect( + buildCodexStructuredChildEnvironment( + { + command: 'codex', + args: ['app-server'], + cwd: '/worktree', + codexHome: '/pinned/home', + resumeThreadId: null, + env: { CODEX_LB_API_KEY: 'shell-exported', CODEX_HOME: '/shell/home' } + }, + 'spawn-token' + ) + ).toEqual({ + CODEX_LB_API_KEY: 'shell-exported', + CODEX_HOME: '/pinned/home', + [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token' + }) + }) +}) diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts new file mode 100644 index 00000000000..88326eb3fc0 --- /dev/null +++ b/src/main/codex/codex-structured-child-environment.ts @@ -0,0 +1,13 @@ +import type { CodexStructuredLaunch } from './codex-structured-session-state' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' + +export function buildCodexStructuredChildEnvironment( + launch: CodexStructuredLaunch, + spawnToken: string +): Record { + return { + ...launch.env, + ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}), + [CODEX_SPAWN_TOKEN_ENV]: spawnToken + } +} diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index c3817a8be0b..64127efcf58 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -56,6 +56,7 @@ export function createCodexStructuredLaunchResolver( args: spawnArgs, cwd: await deps.resolveWorkspacePath(location.workspaceId), codexHome: accountHome.path, + ...(record.launchEnv ? { env: { ...record.launchEnv } } : {}), // An empty chain is a session that has never proved a thread, so it // starts one; anything else resumes the last link this session proved. resumeThreadId: head?.handle.provider === 'codex' ? head.handle.threadId : null diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index a0e476cdeec..e7d27ac699b 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -16,11 +16,8 @@ import { openCodexAppServerConnection } from './codex-app-server-connection' import { isCodexAppServerUnsupportedError } from './codex-app-server-session' -import { - CODEX_SPAWN_TOKEN_ENV, - codexProcessIdentity, - codexProviderHandleLink -} from './codex-structured-owner-identity' +import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' import { answerCodexPrompt } from './codex-structured-prompt-replies' import { openCodexThread } from './codex-structured-thread-open' import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' @@ -91,10 +88,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap { command: launch.command, args: launch.args, - env: { - [CODEX_SPAWN_TOKEN_ENV]: input.spawnToken, - ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) - } + env: buildCodexStructuredChildEnvironment(launch, input.spawnToken) }, { onNotification: (method, params) => diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index f9ea65a7f13..e65942e5475 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -12,6 +12,7 @@ export type CodexStructuredLaunch = { cwd: string codexHome: string | null resumeThreadId: string | null + env?: Record } export type CodexStructuredSessionEvent = diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 067f48b5b24..ea215afbc05 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -23,7 +23,7 @@ export type StructuredAgentSessionHostDeps = { mintSpawnToken?: () => string resolveLaunchEnv?: ( provider: AgentSessionRecord['provider'] - ) => Record | undefined + ) => Promise | undefined> | Record | undefined now?: () => number onEventSinkError?: (input: { sessionId: string; error: unknown }) => void handoffTransport?: StructuredAgentSessionHandoffTransport diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b95863e3605..3847c435563 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -49,6 +49,7 @@ import { } from './structured-agent-session-host-handoff' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' +import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import type { StructuredAgentSessionCaller, @@ -162,9 +163,7 @@ export class StructuredAgentSessionHost { claimKeyId: this.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, probe: await this.runtimeState.probeOwner(sessionId), - ...(this.deps.resolveLaunchEnv - ? { launchEnv: this.deps.resolveLaunchEnv(params.provider) } - : {}) + ...(await pinnedAgentSessionLaunchEnv(this.deps.resolveLaunchEnv, params)) }, callerKey: caller.callerKey, params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts new file mode 100644 index 00000000000..861032a727f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { pinnedAgentSessionLaunchEnv } from './structured-agent-session-launch-env' + +describe('pinnedAgentSessionLaunchEnv', () => { + it('layers the pinned account home over the shell environment', async () => { + await expect( + pinnedAgentSessionLaunchEnv( + async () => ({ CODEX_LB_API_KEY: 'shell-exported', CODEX_HOME: '/shell/home' }), + hostTestAttachParams(null) + ) + ).resolves.toEqual({ + launchEnv: { + CODEX_LB_API_KEY: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts new file mode 100644 index 00000000000..3efdf3ccc2d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts @@ -0,0 +1,21 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +type LaunchEnvResolver = ( + provider: AgentSessionRecord['provider'] +) => Promise | undefined> | Record | undefined + +export async function pinnedAgentSessionLaunchEnv( + resolver: LaunchEnvResolver | undefined, + params: AgentSessionAttachParams +): Promise<{ launchEnv: Record } | Record> { + if (!resolver) { + return {} + } + return { + launchEnv: { + ...(await resolver(params.provider)), + [params.accountHome.variable]: params.accountHome.path + } + } +} diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index dae5155effd..8fd99002417 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -73,12 +73,14 @@ type FakeConnection = Omit & { calls: { method: string; params?: Record }[] replies: { id: number | string; result?: unknown; code?: number }[] resumedThreadId: string | null + launch: Parameters[0] } function fakeCodex(): CodexScript { const connections: FakeConnection[] = [] - const openConnection = (async (_launch, handlers = {}) => { + const openConnection = (async (launch, handlers = {}) => { const connection: FakeConnection = { + launch, handlers, calls: [], replies: [], @@ -307,6 +309,10 @@ beforeEach(async () => { claimKeyId: 'key-1', resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', + resolveLaunchEnv: async () => ({ + CODEX_LB_API_KEY: 'shell-exported', + CODEX_HOME: '/shell/home' + }), openCodexConnection: codex.openConnection }).then(() => undefined), registerSubscriptionCleanup: vi.fn((id: string, dispose: () => void) => @@ -386,6 +392,10 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.snapshot.items).toEqual([]) + expect(codex.live().launch.env).toMatchObject({ + CODEX_LB_API_KEY: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + }) const stream = await subscribe('sub-first-send') const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 09088144fbd..91ef86b9471 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -21,6 +21,7 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { AgentSessionRecordStore } from './agent-session-record-store' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' /** Sibling of the journal tree rather than inside it: one file adjudicates every * session's lease, while a journal is per session. */ @@ -39,6 +40,7 @@ export type StructuredAgentSessionRuntimeDeps = { /** Transport for the Codex child. Overridden only to drive the whole runtime * against a scripted app-server; production spawns the real one. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + resolveLaunchEnv?: () => Promise onError?: (input: { scope: string; error: unknown }) => void handoffTransport?: StructuredAgentSessionHandoffTransport } @@ -103,6 +105,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + (await (deps.resolveLaunchEnv ?? resolveLoginShellEnvironment)()) as Record, onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), ...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {}) diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 9cf7dc75608..35607a3079e 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -49,7 +49,7 @@ export function _resetHydrateShellPathCache(): void { windowsPathOwnership.reset() } -function pickShell(): string | null { +export function resolveProfileLoadingShell(): string | null { if (process.platform === 'win32') { const family = resolveWindowsShellStartupFamily(configuredWindowsShell) if (family === 'cmd') { @@ -68,6 +68,10 @@ function pickShell(): string | null { return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' } +export function resolveProfileLoadingFallbackShell(): string | null { + return configuredWindowsFallbackShell +} + function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] { const cleaned = stdout.replace(ANSI_RE, '') const first = cleaned.indexOf(DELIMITER) @@ -204,7 +208,8 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { + resetLoginShellEnvironmentCacheForTests() + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = originalZdotdir + } + if (originalShellOnlyValue === undefined) { + delete process.env[SHELL_ONLY_VARIABLE] + } else { + process.env[SHELL_ONLY_VARIABLE] = originalShellOnlyValue + } + if (testHome) { + await rm(testHome, { recursive: true, force: true }) + testHome = null + } +}) + +describe('resolveLoginShellEnvironment', () => { + it('returns variables exported by the profile-loading shell', async () => { + const spawner = vi.fn(async () => ({ + ...process.env, + CODEX_LB_API_KEY: 'shell-exported' + })) + + await expect( + resolveLoginShellEnvironment({ shellOverride: '/bin/zsh', spawner }) + ).resolves.toMatchObject({ CODEX_LB_API_KEY: 'shell-exported' }) + }) + + it.runIf(process.platform !== 'win32')( + 'captures a profile export missing from the parent process', + async () => { + testHome = await mkdtemp(join(tmpdir(), 'orca-login-shell-env-')) + await writeFile(join(testHome, '.zshenv'), `export ${SHELL_ONLY_VARIABLE}=shell-only\n`) + process.env.HOME = testHome + process.env.ZDOTDIR = testHome + delete process.env[SHELL_ONLY_VARIABLE] + + const environment = await resolveLoginShellEnvironment({ + shellOverride: '/bin/zsh', + force: true + }) + expect(environment[SHELL_ONLY_VARIABLE]).toBe('shell-only') + } + ) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts new file mode 100644 index 00000000000..00aaa26d67c --- /dev/null +++ b/src/main/startup/login-shell-environment.ts @@ -0,0 +1,160 @@ +import { spawn } from 'node:child_process' +import { win32 as pathWin32 } from 'node:path' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { + resolveProfileLoadingFallbackShell, + resolveProfileLoadingShell +} from './hydrate-shell-path' + +const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' +const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' +const SPAWN_TIMEOUT_MS = 5000 + +let cached: Promise | null = null +let cachedShellKey: string | null = null + +function processEnvironment(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) +} + +function shellProbe(shell: string): string[] | null { + if (process.platform !== 'win32' || resolveWindowsShellStartupFamily(shell) === 'posix') { + const command = + `printf '\\0${START_MARKER}\\0'; /usr/bin/env -0; ` + `printf '\\0${END_MARKER}\\0'` + return ['-ilc', command] + } + const basename = pathWin32.basename(shell).toLowerCase() + if (basename !== 'powershell.exe' && basename !== 'pwsh.exe') { + return null + } + const command = + `$values = @{}; [Environment]::GetEnvironmentVariables().GetEnumerator() | ` + + `ForEach-Object { $values[[string]$_.Key] = [string]$_.Value }; ` + + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + + `[Console]::Write('${START_MARKER}'); ` + + `[Console]::Write(($values | ConvertTo-Json -Compress)); ` + + `[Console]::Write('${END_MARKER}')` + return ['-NoLogo', '-Command', command] +} + +function parsePosixEnvironment(output: Buffer): NodeJS.ProcessEnv | null { + const start = output.indexOf(Buffer.from(`\0${START_MARKER}\0`)) + const end = output.indexOf(Buffer.from(`\0${END_MARKER}\0`), start + START_MARKER.length + 2) + if (start < 0 || end < 0) { + return null + } + const bodyStart = start + START_MARKER.length + 2 + const entries = output.subarray(bodyStart, end).toString('utf8').split('\0') + const environment: NodeJS.ProcessEnv = {} + for (const entry of entries) { + const separator = entry.indexOf('=') + if (separator > 0) { + environment[entry.slice(0, separator)] = entry.slice(separator + 1) + } + } + return Object.keys(environment).length > 0 ? environment : null +} + +function parsePowerShellEnvironment(output: Buffer): NodeJS.ProcessEnv | null { + const text = output.toString('utf8') + const start = text.indexOf(START_MARKER) + const end = text.indexOf(END_MARKER, start + START_MARKER.length) + if (start < 0 || end < 0) { + return null + } + try { + const parsed = JSON.parse(text.slice(start + START_MARKER.length, end)) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const entries = Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + return entries.length > 0 ? Object.fromEntries(entries) : null + } catch { + return null + } +} + +function spawnShellAndReadEnvironment(shell: string): Promise { + const args = shellProbe(shell) + if (!args) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + let settled = false + const chunks: Buffer[] = [] + const child = spawn(shell, args, { + env: process.env, + stdio: ['ignore', 'pipe', 'ignore'], + detached: false, + windowsHide: true + }) + const finish = (value: NodeJS.ProcessEnv | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(value) + } + const timer = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // Best-effort timeout cleanup. + } + finish(null) + }, SPAWN_TIMEOUT_MS) + child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) + child.on('error', () => finish(null)) + child.on('close', () => { + const output = Buffer.concat(chunks) + finish( + process.platform === 'win32' && resolveWindowsShellStartupFamily(shell) !== 'posix' + ? parsePowerShellEnvironment(output) + : parsePosixEnvironment(output) + ) + }) + }) +} + +export type ResolveLoginShellEnvironmentOptions = { + force?: boolean + shellOverride?: string | null + spawner?: (shell: string) => Promise +} + +/** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ +export function resolveLoginShellEnvironment( + options: ResolveLoginShellEnvironmentOptions = {} +): Promise { + const shell = + options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell() + const fallback = options.shellOverride === undefined ? resolveProfileLoadingFallbackShell() : null + const shellKey = `${shell ?? ''}\0${fallback ?? ''}` + if (cached && cachedShellKey === shellKey && !options.force) { + return cached + } + if (!shell) { + return Promise.resolve(processEnvironment()) + } + const spawner = options.spawner ?? spawnShellAndReadEnvironment + cachedShellKey = shellKey + cached = spawner(shell) + .then(async (environment) => { + if (environment) { + return environment + } + return fallback ? ((await spawner(fallback)) ?? processEnvironment()) : processEnvironment() + }) + .catch(() => processEnvironment()) + return cached +} + +export function resetLoginShellEnvironmentCacheForTests(): void { + cached = null + cachedShellKey = null +} diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d27a18565ef..52b5589294c 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -38,7 +38,10 @@ import { setWorktreeNavActivator, setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' -import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { + gateWorktreeAgentActivation, + workspaceHasSleepingAgentSessions +} from '@/lib/worktree-agent-activation-gate' import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery' import { getRuntimeEnvironmentIdForWorktree, @@ -260,8 +263,16 @@ export function activateAndRevealFolderWorkspace( if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } - resumeSleepingAgentSessionsForWorktree(workspaceKey) - const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) + const shouldGateAutoResume = + !opts?.startup && workspaceHasSleepingAgentSessions(state, workspaceKey) + if (shouldGateAutoResume) { + void gateWorktreeAgentActivation(workspaceKey).then(() => { + ensureFolderWorkspaceInitialTerminal(folderWorkspace) + }) + } + const primaryTabId = shouldGateAutoResume + ? null + : ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) if (opts?.sidebarRevealBehavior) { state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) @@ -336,18 +347,26 @@ export function activateAndRevealWorktree( } // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically. - resumeSleepingAgentSessionsForWorktree(worktreeId) + const shouldGateAutoResume = + !hasActivationWork && workspaceHasSleepingAgentSessions(postActivationState, worktreeId) + if (shouldGateAutoResume) { + void gateWorktreeAgentActivation(worktreeId).then(() => { + ensureWorktreeHasInitialTerminal(useAppStore.getState(), worktreeId) + }) + } // 4. Ensure a focusable surface exists for externally-created worktrees - const primaryTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - worktreeId, - opts?.startup, - opts?.setup, - opts?.issueCommand, - opts?.defaultTabs, - opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined - ) + const primaryTabId = shouldGateAutoResume + ? null + : ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktreeId, + opts?.startup, + opts?.setup, + opts?.issueCommand, + opts?.defaultTabs, + opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : undefined + ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts new file mode 100644 index 00000000000..7853647e4f9 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PtyListedSession } from '../../../shared/pty-listed-session' +import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' + +const WORKTREE_ID = 'repo::/worktree' + +function listed(id: string): PtyListedSession { + return { id, cwd: '/worktree', title: 'Codex', agentOwnership: 'present' } +} + +function testDeps(args: { sessions?: PtyListedSession[]; structured?: boolean }) { + const createTab = vi.fn() + const resume = vi.fn(() => 1) + const store = { + createTab, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: { + [WORKTREE_ID]: args.structured + ? [ + { + id: 'structured-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'agent-session' as const, + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + : [] + } + } + return { + createTab, + resume, + deps: { + getState: () => store, + listSessions: vi.fn(async () => args.sessions ?? []), + resume + } + } +} + +describe('worktree agent activation gate', () => { + it('adopts a live daemon PTY before activation can resume another agent', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + expect(resume).not.toHaveBeenCalled() + }) + + it('adopts a daemon PTY minted for a folder workspace', async () => { + const folderWorkspaceId = 'folder:plain-workspace' + const ptyId = `${folderWorkspaceId}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + + await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledWith(folderWorkspaceId, undefined, undefined, { + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + expect(resume).not.toHaveBeenCalled() + }) + + it('does not resume when the workspace has only a structured session', async () => { + const { deps, createTab, resume } = testDeps({ structured: true }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('structured') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('does not resume when the runtime reports a structured session before tab sync', async () => { + const { deps, createTab, resume } = testDeps({}) + const hasStructuredSession = vi.fn(async () => true) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + ).resolves.toBe('structured') + + expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID) + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('keeps the existing resume path when the workspace has no live agent', async () => { + const { deps, createTab, resume } = testDeps({}) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).toHaveBeenCalledOnce() + expect(resume).toHaveBeenCalledWith(WORKTREE_ID) + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts new file mode 100644 index 00000000000..9873eb4894b --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -0,0 +1,139 @@ +import { useAppStore } from '@/store' +import type { PtyListedSession } from '../../../shared/pty-listed-session' +import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +type ActivationStore = Pick< + ReturnType, + 'createTab' | 'ptyIdsByTabId' | 'unifiedTabsByWorktree' +> + +type ActivationGateDeps = { + getState: () => ActivationStore + listSessions: () => Promise + hasStructuredSession?: (worktreeId: string) => Promise + resume: (worktreeId: string) => number +} + +export type WorktreeAgentActivationOutcome = 'adopted' | 'structured' | 'resumed' | 'blocked' + +const inFlightByWorktreeId = new Map>() + +export function workspaceHasSleepingAgentSessions( + state: Pick, 'sleepingAgentSessionsByPaneKey'>, + worktreeId: string +): boolean { + return Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === worktreeId + ) +} + +function hasStructuredSession(store: ActivationStore, worktreeId: string): boolean { + return (store.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.contentType === 'agent-session' + ) +} + +function ptyIsAlreadyBound(store: ActivationStore, ptyId: string): boolean { + return Object.values(store.ptyIdsByTabId).some((ids) => ids.includes(ptyId)) +} + +function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean { + if (parsePtySessionId(sessionId).worktreeId === worktreeId) { + return true + } + const scope = parseWorkspaceKey(worktreeId) + return ( + scope?.type === 'folder' && + sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) && + sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length + ) +} + +export async function runWorktreeAgentActivationGate( + worktreeId: string, + deps: ActivationGateDeps +): Promise { + let sessions: PtyListedSession[] + try { + sessions = await deps.listSessions() + } catch { + // Inventory uncertainty cannot authorize a second writer. + return 'blocked' + } + + const liveWorkspaceSessions = sessions.filter((session) => + sessionBelongsToWorkspace(session.id, worktreeId) + ) + if (liveWorkspaceSessions.length > 0) { + for (const session of liveWorkspaceSessions) { + const store = deps.getState() + if (ptyIsAlreadyBound(store, session.id)) { + continue + } + store.createTab(worktreeId, undefined, undefined, { + initialPtyId: session.id, + activate: false, + recordInteraction: false + }) + } + return 'adopted' + } + + try { + if ( + hasStructuredSession(deps.getState(), worktreeId) || + (await deps.hasStructuredSession?.(worktreeId)) + ) { + return 'structured' + } + } catch { + return 'blocked' + } + deps.resume(worktreeId) + return 'resumed' +} + +export function gateWorktreeAgentActivation( + worktreeId: string +): Promise { + const existing = inFlightByWorktreeId.get(worktreeId) + if (existing) { + return existing + } + const gate = runWorktreeAgentActivationGate(worktreeId, { + getState: () => useAppStore.getState(), + listSessions: () => + typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(), + hasStructuredSession: async (candidate) => { + if (typeof window === 'undefined') { + return false + } + const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }) + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + return (result.snapshots ?? []).some( + (snapshot) => + snapshot.worktree === candidate && + snapshot.tabs.some((tab) => tab.type === 'agent-session') + ) + }, + resume: resumeSleepingAgentSessionsForWorktree + }).finally(() => { + if (inFlightByWorktreeId.get(worktreeId) === gate) { + inFlightByWorktreeId.delete(worktreeId) + } + }) + inFlightByWorktreeId.set(worktreeId, gate) + return gate +} + +export function waitForWorktreeAgentActivationGateForTests( + worktreeId: string +): Promise { + return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null) +} diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index 7b2d8bda8e2..479244ddc81 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' import { makePaneKey } from '../../../shared/stable-pane-id' @@ -123,7 +124,7 @@ afterEach(() => { }) describe('preserved-pane replacement contract on workspace activation', () => { - it('appends exactly one replacement tab for a husk pane and retains the husk across repeats', () => { + it('appends exactly one replacement tab for a husk pane and retains the husk across repeats', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) // Hibernation cleared the pane's PTY binding: the husk cannot resume in place. @@ -132,6 +133,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-A') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const afterFirst = useAppStore.getState() const tabsAfterFirst = afterFirst.tabsByWorktree[worktree.id] ?? [] @@ -154,7 +156,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(afterRepeats.tabsByWorktree[worktree.id]).toHaveLength(2) }) - it('does not append a replacement while the preserved pane still has a live PTY', () => { + it('does not append a replacement while the preserved pane still has a live PTY', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) seedHuskTab(state, worktree.id, 'pty-live-1') @@ -163,6 +165,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-B') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]).toHaveLength(1) @@ -170,7 +173,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined() }) - it('does not fork a NON-group-active restorable pane into a replacement tab', () => { + it('does not fork a NON-group-active restorable pane into a replacement tab', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) seedHuskTab(state, worktree.id, 'pty-old-1') @@ -215,6 +218,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { activateAndRevealWorktree(worktree.id) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([ @@ -226,7 +230,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined() }) - it('does not append a replacement when the preserved pane will cold-restore in place', () => { + it('does not append a replacement when the preserved pane will cold-restore in place', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) // Restorable binding persists, no live PTY: pane-level cold restore owns recovery. @@ -237,6 +241,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-C') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]).toHaveLength(1) @@ -245,7 +250,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { // Why: web-mirror tabs never mount a local pane, so they cannot own recovery // — the appended replacement stays the correct resume path for them. - it('still appends a replacement for a web-mirror tab that cannot mount a local pane', () => { + it('still appends a replacement for a web-mirror tab that cannot mount a local pane', async () => { const webTabId = 'web-terminal-host-tab' const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) @@ -302,6 +307,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { })) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index a0c24f24517..2f76ca24307 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' const initialAppStoreState = useAppStore.getState() @@ -52,7 +53,7 @@ afterEach(() => { }) describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { - it('re-captured sleeping codex session resumes once, not once per reopen', () => { + it('re-captured sleeping codex session resumes once, not once per reopen', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } useAppStore.setState(baseState(worktree)) const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } @@ -80,6 +81,7 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { })) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const state = useAppStore.getState() const tabs = state.tabsByWorktree[worktree.id] ?? []