fix agent activation adoption and structured shell env

This commit is contained in:
Brennan Benson
2026-08-13 15:14:09 -07:00
parent 84727e695a
commit 737d37a268
19 changed files with 628 additions and 35 deletions
@@ -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'
})
})
})
@@ -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<string, string> {
return {
...launch.env,
...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}),
[CODEX_SPAWN_TOKEN_ENV]: spawnToken
}
}
@@ -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
@@ -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) =>
@@ -12,6 +12,7 @@ export type CodexStructuredLaunch = {
cwd: string
codexHome: string | null
resumeThreadId: string | null
env?: Record<string, string>
}
export type CodexStructuredSessionEvent =
@@ -23,7 +23,7 @@ export type StructuredAgentSessionHostDeps = {
mintSpawnToken?: () => string
resolveLaunchEnv?: (
provider: AgentSessionRecord['provider']
) => Record<string, string> | undefined
) => Promise<Record<string, string> | undefined> | Record<string, string> | undefined
now?: () => number
onEventSinkError?: (input: { sessionId: string; error: unknown }) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
@@ -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,
@@ -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'
}
})
})
})
@@ -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<Record<string, string> | undefined> | Record<string, string> | undefined
export async function pinnedAgentSessionLaunchEnv(
resolver: LaunchEnvResolver | undefined,
params: AgentSessionAttachParams
): Promise<{ launchEnv: Record<string, string> } | Record<string, never>> {
if (!resolver) {
return {}
}
return {
launchEnv: {
...(await resolver(params.provider)),
[params.accountHome.variable]: params.accountHome.path
}
}
}
@@ -73,12 +73,14 @@ type FakeConnection = Omit<CodexAppServerConnection, 'closed'> & {
calls: { method: string; params?: Record<string, unknown> }[]
replies: { id: number | string; result?: unknown; code?: number }[]
resumedThreadId: string | null
launch: Parameters<typeof openCodexAppServerConnection>[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' }] }
@@ -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<NodeJS.ProcessEnv>
onError?: (input: { scope: string; error: unknown }) => void
handoffTransport?: StructuredAgentSessionHandoffTransport
}
@@ -103,6 +105,8 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
journalRoot: deps.stateDirectory,
claimKeyId: deps.claimKeyId,
probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId),
resolveLaunchEnv: async () =>
(await (deps.resolveLaunchEnv ?? resolveLoginShellEnvironment)()) as Record<string, string>,
onEventSinkError: ({ sessionId, error }) =>
deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }),
...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {})
+7 -2
View File
@@ -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<Hydratio
}
const platform = process.platform
const configurationVersion = windowsShellConfigurationVersion
const shell = options.shellOverride !== undefined ? options.shellOverride : pickShell()
const shell =
options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell()
if (!shell) {
cached = Promise.resolve({ segments: [], ok: false, failureReason: 'no_shell' })
return cached
@@ -0,0 +1,67 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
resetLoginShellEnvironmentCacheForTests,
resolveLoginShellEnvironment
} from './login-shell-environment'
const originalHome = process.env.HOME
const originalZdotdir = process.env.ZDOTDIR
const SHELL_ONLY_VARIABLE = 'ORCA_TEST_LOGIN_SHELL_ONLY'
const originalShellOnlyValue = process.env[SHELL_ONLY_VARIABLE]
let testHome: string | null = null
afterEach(async () => {
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')
}
)
})
+160
View File
@@ -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<NodeJS.ProcessEnv> | 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<NodeJS.ProcessEnv | null> {
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<NodeJS.ProcessEnv | null>
}
/** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */
export function resolveLoginShellEnvironment(
options: ResolveLoginShellEnvironmentOptions = {}
): Promise<NodeJS.ProcessEnv> {
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
}
+32 -13
View File
@@ -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)
}
@@ -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)
})
})
@@ -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<typeof useAppStore.getState>,
'createTab' | 'ptyIdsByTabId' | 'unifiedTabsByWorktree'
>
type ActivationGateDeps = {
getState: () => ActivationStore
listSessions: () => Promise<PtyListedSession[]>
hasStructuredSession?: (worktreeId: string) => Promise<boolean>
resume: (worktreeId: string) => number
}
export type WorktreeAgentActivationOutcome = 'adopted' | 'structured' | 'resumed' | 'blocked'
const inFlightByWorktreeId = new Map<string, Promise<WorktreeAgentActivationOutcome>>()
export function workspaceHasSleepingAgentSessions(
state: Pick<ReturnType<typeof useAppStore.getState>, '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<WorktreeAgentActivationOutcome> {
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<WorktreeAgentActivationOutcome> {
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<WorktreeAgentActivationOutcome | null> {
return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null)
}
@@ -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] ?? []
@@ -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] ?? []