mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix agent activation adoption and structured shell env
This commit is contained in:
@@ -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 } : {})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] ?? []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user