mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(worktree): sequence agent launch on provider setup exit
This commit is contained in:
@@ -168,16 +168,7 @@ export function spawnDirectPty(params: {
|
||||
env: Record<string, string>
|
||||
ptySpawn: typeof pty.spawn
|
||||
}): ShellSpawnResult {
|
||||
const {
|
||||
executable,
|
||||
argv,
|
||||
termName = 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env,
|
||||
ptySpawn
|
||||
} = params
|
||||
const { executable, argv, termName = 'xterm-256color', cols, rows, cwd, env, ptySpawn } = params
|
||||
const process_ = ptySpawn(executable, [...argv], {
|
||||
name: termName,
|
||||
cols,
|
||||
|
||||
@@ -243,7 +243,5 @@ export type IPtyProvider = {
|
||||
getProfiles(): Promise<{ name: string; path: string }[]>
|
||||
onData(callback: (payload: PtyDataEvent) => void): () => void
|
||||
onReplay(callback: (payload: { id: string; data: string }) => void): () => void
|
||||
onExit(
|
||||
callback: (payload: PtyExitPayload) => void
|
||||
): () => void
|
||||
onExit(callback: (payload: PtyExitPayload) => void): () => void
|
||||
}
|
||||
|
||||
@@ -127,8 +127,7 @@ import { advertisedUrlWatcher } from '../ports/advertised-url-watcher'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
|
||||
} from '../../shared/setup-agent-sequencing'
|
||||
import type {
|
||||
AgentSessionExecutionClaim,
|
||||
@@ -6996,13 +6995,21 @@ describe('OrcaRuntimeService', () => {
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-remote-agent' })
|
||||
.mockResolvedValueOnce({ id: 'pty-remote-setup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-remote-agent' })
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-remote' })
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
registerSshFilesystemProvider('ssh-1', fsProvider as never)
|
||||
getActiveMultiplexerMock.mockReturnValue({ request: muxRequestMock, notify: vi.fn() })
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
const waitForSetupTerminalEvidence = vi
|
||||
.spyOn(runtime, 'waitForSetupTerminalEvidence')
|
||||
.mockResolvedValue({
|
||||
ptyId: 'pty-remote-setup',
|
||||
exitCode: 0,
|
||||
incarnationId: 'incarnation-setup',
|
||||
cause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
@@ -7040,19 +7047,10 @@ describe('OrcaRuntimeService', () => {
|
||||
`path:${result.worktree.path}`,
|
||||
expect.objectContaining({ viewMode: 'chat' })
|
||||
)
|
||||
expect(waitForSetupTerminalEvidence).toHaveBeenCalledWith(expect.any(String))
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-setup',
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec claude')
|
||||
}),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-setup',
|
||||
command: expect.stringContaining(
|
||||
@@ -7061,27 +7059,26 @@ describe('OrcaRuntimeService', () => {
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
const startup = spawn.mock.calls[0]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
const startupCommand = startup.command
|
||||
const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]!
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-setup',
|
||||
command: expect.stringContaining('claude'),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
const setupSpawn = spawn.mock.calls[1]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
const setupCommand = setupSpawn.command
|
||||
const setupScript = setupSpawn.env[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]!
|
||||
const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
const markerPath = `/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh.${nonceMatch![1]}.done`
|
||||
expect(startupCommand.length).toBeLessThan(256)
|
||||
expect(setupCommand.length).toBeLessThan(1024)
|
||||
expect(setupScript).toContain('printf')
|
||||
expect(setupScript).toContain(`${nonceMatch![1]} "$1"`)
|
||||
expect(startupScript).toContain(markerPath)
|
||||
expect(setupScript).toContain(markerPath)
|
||||
const startup = spawn.mock.calls[0]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
expect(startup.command).toContain('claude')
|
||||
expect(setupSpawn.command).toContain(
|
||||
'/remote/repo/.git/worktrees/mobile-setup/orca/setup-runner.sh'
|
||||
)
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
@@ -45906,11 +45903,19 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const createTerminal = vi.spyOn(runtime, 'createTerminal')
|
||||
const waitForSetupTerminalEvidence = vi
|
||||
.spyOn(runtime, 'waitForSetupTerminalEvidence')
|
||||
.mockResolvedValue({
|
||||
ptyId: 'pty-headless-setup',
|
||||
exitCode: 0,
|
||||
incarnationId: 'incarnation-setup',
|
||||
cause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-headless-startup' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-startup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-setup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-headless-startup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
@@ -45963,7 +45968,8 @@ describe('OrcaRuntimeService', () => {
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-headless-startup-setup',
|
||||
setupDecision: 'run',
|
||||
startup: { command: 'claude', viewMode: 'chat' }
|
||||
startup: { command: 'claude', viewMode: 'chat' },
|
||||
awaitTerminalProvisioning: true
|
||||
})
|
||||
|
||||
expect(createSetupRunnerScript).toHaveBeenCalled()
|
||||
@@ -45972,29 +45978,23 @@ describe('OrcaRuntimeService', () => {
|
||||
`id:${result.worktree.id}`,
|
||||
expect.objectContaining({ viewMode: 'chat' })
|
||||
)
|
||||
// Why: setup is provisioned fire-and-forget; the wait-for-setup guarantee comes from the shell nonce/marker, not JS spawn ordering.
|
||||
expect(waitForSetupTerminalEvidence).toHaveBeenCalledWith(expect.any(String))
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
const startup = spawn.mock.calls[0]![0] as {
|
||||
const setupSpawn = spawn.mock.calls[0]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
const startupCommand = startup.command
|
||||
const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]!
|
||||
const setupSpawn = spawn.mock.calls[1]![0] as {
|
||||
const startup = spawn.mock.calls[1]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
const setupCommand = setupSpawn.command
|
||||
const setupScript = setupSpawn.env[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]!
|
||||
const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
expect(startupCommand.length).toBeLessThan(256)
|
||||
expect(startupScript).toContain('exec claude')
|
||||
expect(startupScript).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
expect(setupScript).toContain('printf')
|
||||
expect(setupScript).toContain(`${nonceMatch![1]} "$1"`)
|
||||
expect(setupSpawn.command).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
expect(startup.command).toContain('claude')
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(result.setupReceipt).toMatchObject({
|
||||
state: 'succeeded',
|
||||
terminalHandle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
})
|
||||
|
||||
it('starts setup and startup side by side by default for local headless worktree creates', async () => {
|
||||
@@ -47261,7 +47261,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(metaById[result.worktree.id]).toMatchObject({ createdWithAgent: 'claude' })
|
||||
})
|
||||
|
||||
it('honors split setup placement for opted-in local startup-draft worktrees', async () => {
|
||||
it('sequences setup before startup for opted-in local startup-draft worktrees', async () => {
|
||||
const metaById: Record<string, WorktreeMeta> = {}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -47280,8 +47280,16 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-startup-split-main' })
|
||||
.mockResolvedValueOnce({ id: 'pty-startup-split-setup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-startup-split-main' })
|
||||
const waitForSetupTerminalEvidence = vi
|
||||
.spyOn(runtime, 'waitForSetupTerminalEvidence')
|
||||
.mockResolvedValue({
|
||||
ptyId: 'pty-startup-split-setup',
|
||||
exitCode: 0,
|
||||
incarnationId: 'incarnation-setup',
|
||||
cause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-split' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
@@ -47340,14 +47348,13 @@ describe('OrcaRuntimeService', () => {
|
||||
awaitTerminalProvisioning: true
|
||||
})
|
||||
|
||||
expect(waitForSetupTerminalEvidence).toHaveBeenCalledWith(expect.any(String))
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-setup-split',
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: expect.stringContaining('codex')
|
||||
}),
|
||||
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
@@ -47355,65 +47362,25 @@ describe('OrcaRuntimeService', () => {
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-setup-split',
|
||||
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh'),
|
||||
env: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-split',
|
||||
ORCA_WORKTREE_ID: result.worktree.id
|
||||
}),
|
||||
command: expect.stringContaining('codex'),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
const startup = spawn.mock.calls[0]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
const startupCommand = startup.command
|
||||
const startupScript = startup.env[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]!
|
||||
const setupSpawn = spawn.mock.calls[1]![0] as {
|
||||
command: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
const setupScript = setupSpawn.env[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]!
|
||||
const nonceMatch = startupScript.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
const markerPath = `/tmp/repo/.git/orca/setup-runner.sh.${nonceMatch![1]}.done`
|
||||
expect(startupCommand.length).toBeLessThan(256)
|
||||
expect(setupSpawn.command.length).toBeLessThan(1024)
|
||||
expect(startupScript).toContain('--dangerously-bypass-approvals-and-sandbox')
|
||||
expect(setupScript).toContain('printf')
|
||||
expect(setupScript).toContain(`${nonceMatch![1]} "$1"`)
|
||||
expect(startupScript).toContain(markerPath)
|
||||
expect(setupScript).toContain(markerPath)
|
||||
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record<string, string> }).env ?? {}
|
||||
const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record<string, string> }).env ?? {}
|
||||
const setupCall = spawn.mock.calls[0]![0] as { env?: Record<string, string> }
|
||||
const startupCall = spawn.mock.calls[1]![0] as { command?: string }
|
||||
expect(result.setup).toBeUndefined()
|
||||
expect(result.setupReceipt).toMatchObject({
|
||||
state: 'running',
|
||||
state: 'succeeded',
|
||||
terminalHandle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
expect(mainEnv.ORCA_TAB_ID).toBeDefined()
|
||||
expect(mainEnv.ORCA_PANE_KEY).toBeDefined()
|
||||
expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID)
|
||||
const mainLeafId = mainEnv.ORCA_PANE_KEY!.slice(`${mainEnv.ORCA_TAB_ID!}:`.length)
|
||||
expect(revealTerminalSession).toHaveBeenLastCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-startup-split-setup',
|
||||
tabId: mainEnv.ORCA_TAB_ID,
|
||||
activate: false,
|
||||
splitFromLeafId: mainLeafId,
|
||||
splitDirection: 'vertical'
|
||||
})
|
||||
)
|
||||
expect(setupCall.env).not.toHaveProperty(SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV)
|
||||
expect(startupCall.command).toContain('codex')
|
||||
expect(revealTerminalSession).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes the wrapped setup command to activation when startup spawned but setup did not', async () => {
|
||||
it('does not start the agent when setup spawn fails', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-startup-main' })
|
||||
.mockRejectedValueOnce(new Error('setup spawn failed'))
|
||||
const spawn = vi.fn().mockRejectedValueOnce(new Error('setup spawn failed'))
|
||||
const activateWorktree = vi.fn()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
@@ -47472,13 +47439,21 @@ describe('OrcaRuntimeService', () => {
|
||||
startup: { command: 'claude' }
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2)
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh',
|
||||
command: expect.stringContaining('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
shell: {
|
||||
family: 'posix',
|
||||
executable: 'wsl.exe'
|
||||
},
|
||||
envVars: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry'
|
||||
}),
|
||||
waitForAgentStartup: true
|
||||
}),
|
||||
undefined,
|
||||
undefined
|
||||
@@ -47486,9 +47461,7 @@ describe('OrcaRuntimeService', () => {
|
||||
const activationSetup = activateWorktree.mock.calls[0]?.[2] as
|
||||
| { command?: string; envVars?: Record<string, string> }
|
||||
| undefined
|
||||
// Why: the retry the renderer performs must carry the gate script alongside the command,
|
||||
// or the Setup tab it opens records no outcome for the waiting agent terminal.
|
||||
expect(activationSetup?.envVars?.[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]).toContain('printf')
|
||||
expect(activationSetup?.envVars).not.toHaveProperty(SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV)
|
||||
})
|
||||
|
||||
it('lets explicit startup draft agents override the desktop default', async () => {
|
||||
|
||||
+250
-107
@@ -418,7 +418,7 @@ import type {
|
||||
ForceDeleteWorktreeBranchResult,
|
||||
RemoveWorktreeResult
|
||||
} from '../../shared/worktree/create-types'
|
||||
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
|
||||
import type {
|
||||
WorkspaceLineage,
|
||||
WorktreeLineage,
|
||||
@@ -616,11 +616,7 @@ import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
} from '../../shared/setup-runner-command'
|
||||
import {
|
||||
applySequencedSetupLaunch,
|
||||
createSequencedSetupAgentCommands,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV
|
||||
} from '../../shared/setup-agent-sequencing'
|
||||
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
|
||||
import { TASK_PROVIDERS } from '../../shared/task-providers'
|
||||
import { FIRST_PANE_ID } from '../../shared/pane-key'
|
||||
import {
|
||||
@@ -1717,6 +1713,7 @@ type TerminalAgentStatusSnapshot = {
|
||||
|
||||
type TerminalCreateOptions = {
|
||||
command?: string
|
||||
directExec?: { executable: string; argv: string[] }
|
||||
claudeAgentTeamsSourceCommand?: string
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
@@ -2084,6 +2081,7 @@ type RuntimePtyController = {
|
||||
rows: number
|
||||
cwd?: string
|
||||
command?: string
|
||||
directExec?: { executable: string; argv: string[] }
|
||||
launchAgent?: TuiAgent
|
||||
commandDelivery?: 'renderer' | 'provider'
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
@@ -3239,6 +3237,28 @@ function getSetupRunnerCommandPlatformForLaunch(
|
||||
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
|
||||
}
|
||||
|
||||
function getDirectSetupExec(
|
||||
setup: WorktreeSetupLaunch,
|
||||
platform: 'windows' | 'posix'
|
||||
): { executable: string; argv: string[] } | undefined {
|
||||
// WSL and SSH providers need their own host-specific command routing; keep those launches
|
||||
// shell-mediated until the remote owner advertises the structured-create capability.
|
||||
if (setup.shell?.executable?.toLowerCase().endsWith('wsl.exe')) {
|
||||
return undefined
|
||||
}
|
||||
if (platform === 'posix' && setup.runnerScriptPath.startsWith('/')) {
|
||||
return { executable: 'bash', argv: [setup.runnerScriptPath] }
|
||||
}
|
||||
if (platform === 'windows' && /\.(cmd|bat)$/i.test(setup.runnerScriptPath)) {
|
||||
const comspec = process.env.ComSpec || 'cmd.exe'
|
||||
return {
|
||||
executable: comspec,
|
||||
argv: ['/d', '/s', '/v:on', '/c', `""${setup.runnerScriptPath}""`]
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type RuntimeRendererReloadFence = Readonly<{
|
||||
revision: number
|
||||
recovery: 'renderer' | 'headless' | 'reloading'
|
||||
@@ -3254,6 +3274,20 @@ type ProviderSnapshotReadOptions = {
|
||||
visibleScreenOnly?: boolean
|
||||
}
|
||||
|
||||
/** Ordered, provider-backed PTY termination evidence exposed to runtime waiters. */
|
||||
export type RuntimePtyExitEvent = Readonly<{
|
||||
ptyId: string
|
||||
exitCode: number
|
||||
incarnationId: PtyIncarnationId
|
||||
cause: TerminalExitCause
|
||||
}>
|
||||
|
||||
/** Setup sequencing admits exactly one terminal outcome; all inferred, signaled, or mismatched
|
||||
* statuses fail closed so an agent can never start against an unproven environment. */
|
||||
export function isSuccessfulSetupExitEvidence(event: RuntimePtyExitEvent): boolean {
|
||||
return event.cause.kind === 'exited' && event.cause.exitCode === 0 && event.exitCode === 0
|
||||
}
|
||||
|
||||
export class OrcaRuntimeService {
|
||||
private readonly runtimeId = randomUUID()
|
||||
private readonly startedAt = Date.now()
|
||||
@@ -3416,7 +3450,7 @@ export class OrcaRuntimeService {
|
||||
private sessionTabsInventoryPublicationEpoch: number | null = null
|
||||
private sessionTabsInventoryWaiters = new Set<() => void>()
|
||||
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
|
||||
private ptyExitListenersByPtyId = new Map<string, Set<() => void>>()
|
||||
private ptyExitListenersByPtyId = new Map<string, Set<(event: RuntimePtyExitEvent) => void>>()
|
||||
private ptyController: RuntimePtyController | null = null
|
||||
private notifier: RuntimeNotifier | null = null
|
||||
private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
|
||||
@@ -17776,7 +17810,6 @@ export class OrcaRuntimeService {
|
||||
pty?.incarnationId ??
|
||||
`runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}`
|
||||
this.advancePtyLifecycleGeneration(ptyId)
|
||||
this.notifyPtyExitListeners(ptyId)
|
||||
const exactSurfaceByKey = new Map<
|
||||
string,
|
||||
Pick<RetiredTerminalSurface, 'worktreeId' | 'parentTabId' | 'leafId'>
|
||||
@@ -17929,6 +17962,14 @@ export class OrcaRuntimeService {
|
||||
exitedSurfaces.push({ handle: leafHandle, paneKey: `${leaf.tabId}:${leaf.leafId}` })
|
||||
}
|
||||
}
|
||||
// Publish only after the mutable PTY/leaf records carry the same evidence. Late subscribers
|
||||
// and re-entrant observers therefore see one coherent exit certificate.
|
||||
this.notifyPtyExitListeners(ptyId, {
|
||||
ptyId,
|
||||
exitCode,
|
||||
incarnationId,
|
||||
cause: exitCause
|
||||
})
|
||||
// Why: an explicit whole-tab close drops the leaf from the graph *before*
|
||||
// this exit lands, so a leaf-only walk found nothing and left the dispatch
|
||||
// reading 'dispatched' forever against a dead process. The PTY's own handle
|
||||
@@ -22529,10 +22570,17 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
subscribeToPtyExit(ptyId: string, listener: () => void): () => void {
|
||||
subscribeToPtyExit(ptyId: string, listener: (event: RuntimePtyExitEvent) => void): () => void {
|
||||
const lifecycleGeneration = this.getPtyLifecycleGeneration(ptyId)
|
||||
if (this.isPtyKnownExited(ptyId)) {
|
||||
listener()
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const exitCode = pty?.lastExitCode ?? -1
|
||||
listener({
|
||||
ptyId,
|
||||
exitCode,
|
||||
incarnationId: pty?.incarnationId ?? `runtime:${this.runtimeId}:${lifecycleGeneration}`,
|
||||
cause: pty?.lastExitCause ?? resolveUnreportedExitCause(exitCode)
|
||||
})
|
||||
return () => {}
|
||||
}
|
||||
let listeners = this.ptyExitListenersByPtyId.get(ptyId)
|
||||
@@ -22557,7 +22605,16 @@ export class OrcaRuntimeService {
|
||||
this.isPtyKnownExited(ptyId)
|
||||
) {
|
||||
unsubscribe()
|
||||
listener()
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const exitCode = pty?.lastExitCode ?? -1
|
||||
listener({
|
||||
ptyId,
|
||||
exitCode,
|
||||
incarnationId:
|
||||
pty?.incarnationId ??
|
||||
`runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}`,
|
||||
cause: pty?.lastExitCause ?? resolveUnreportedExitCause(exitCode)
|
||||
})
|
||||
}
|
||||
return unsubscribe
|
||||
}
|
||||
@@ -22618,6 +22675,38 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the provider-owned termination certificate for a Setup PTY. This is intentionally
|
||||
* separate from the legacy numeric waiter: sequencing must reject inferred/unknown status and
|
||||
* only accept an explicit normal exit from the matching PTY incarnation.
|
||||
*/
|
||||
async waitForSetupTerminalEvidence(handle: string): Promise<RuntimePtyExitEvent> {
|
||||
const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId
|
||||
if (!ptyId) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
return await new Promise<RuntimePtyExitEvent>((resolve, reject) => {
|
||||
let settled = false
|
||||
let unsubscribe: (() => void) | null = null
|
||||
const finish = (event: RuntimePtyExitEvent): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
unsubscribe?.()
|
||||
resolve(event)
|
||||
}
|
||||
const fail = (error: unknown): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
unsubscribe?.()
|
||||
reject(error)
|
||||
}
|
||||
unsubscribe = this.subscribeToPtyExit(ptyId, finish)
|
||||
if (this.isPtyKnownExited(ptyId) && !settled) {
|
||||
fail(new Error('setup_exit_evidence_unavailable'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getWorktreePs(
|
||||
limit = DEFAULT_WORKTREE_PS_LIMIT,
|
||||
sourceDefaultsSupported = true
|
||||
@@ -26812,6 +26901,10 @@ export class OrcaRuntimeService {
|
||||
// records. Pass the whole sequenced launch record — command plus the env carrying its
|
||||
// script — so the Setup tab runs the script the agent waits on, not a bare runner.
|
||||
sequencedSetup?: CreateWorktreeResult['setup']
|
||||
/** Reserve only the Setup surface. Sequenced agent launches create their agent pane
|
||||
* after the provider reports a successful Setup exit, so a placeholder shell must not
|
||||
* occupy the agent surface first. */
|
||||
reservePrimaryTerminal?: boolean
|
||||
// Why: a workspace provisioned in the background must not pull the sidebar
|
||||
// to itself; the user never asked to look at these tabs.
|
||||
surfaceOwner?: false
|
||||
@@ -26836,7 +26929,11 @@ export class OrcaRuntimeService {
|
||||
Pick<GlobalSettings, 'setupScriptLaunchMode'>
|
||||
>
|
||||
).setupScriptLaunchMode ?? 'new-tab'
|
||||
if (!args.hasStartupTerminal && !primaryTerminalHandle) {
|
||||
if (
|
||||
!args.hasStartupTerminal &&
|
||||
!primaryTerminalHandle &&
|
||||
args.reservePrimaryTerminal !== false
|
||||
) {
|
||||
const terminal = await this.createTerminal(args.worktreeSelector, surfacing)
|
||||
primaryTerminalHandle = terminal.handle
|
||||
}
|
||||
@@ -26861,6 +26958,10 @@ export class OrcaRuntimeService {
|
||||
args.setup.shell
|
||||
)
|
||||
const setupEnv = { ...setupLaunch.envVars, ...observedCommand?.env }
|
||||
const directExec =
|
||||
args.worktreeSelector.startsWith('id:') && !setupLaunch.command
|
||||
? getDirectSetupExec(args.setup, args.setupCommandPlatform)
|
||||
: undefined
|
||||
const shouldSplitSetup =
|
||||
primaryTerminalHandle &&
|
||||
(setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal')
|
||||
@@ -26875,6 +26976,7 @@ export class OrcaRuntimeService {
|
||||
: this.createTerminal(args.worktreeSelector, {
|
||||
title: 'Setup',
|
||||
command: setupCommand,
|
||||
...(directExec ? { directExec } : {}),
|
||||
env: setupEnv,
|
||||
...surfacing
|
||||
}))
|
||||
@@ -27942,30 +28044,48 @@ export class OrcaRuntimeService {
|
||||
let startupTerminalPaneKey: string | null = null
|
||||
let startupTerminalPtyId: string | null = null
|
||||
|
||||
let sequencedStartup = effectiveStartup
|
||||
let sequencedSetup: CreateWorktreeResult['setup']
|
||||
if (effectiveStartup && setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForLaunch(
|
||||
setup,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: effectiveStartup.command,
|
||||
platform,
|
||||
shell: setup.shell
|
||||
})
|
||||
sequencedStartup = {
|
||||
...effectiveStartup,
|
||||
command: sequenced.startupCommand,
|
||||
...(sequenced.startupEnv
|
||||
? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } }
|
||||
: {})
|
||||
const setupMustGateStartup = Boolean(effectiveStartup && setup?.waitForAgentStartup === true)
|
||||
let setupProvisioned = false
|
||||
let setupWaitSucceeded = !setupMustGateStartup
|
||||
|
||||
// Host-owned sequencing: Setup is a real PTY whose provider exit is the only authorization
|
||||
// to create the Agent PTY. Unknown/lost setup outcomes fail closed; there is no timer that
|
||||
// can silently launch an agent without a successful exit certificate.
|
||||
if (setupMustGateStartup && setup && this.ptyController?.spawn) {
|
||||
try {
|
||||
const provisioned = await this.provisionManagedWorktreeTerminals({
|
||||
worktreeSelector: `id:${worktree.id}`,
|
||||
worktreeId: worktree.id,
|
||||
worktreePath,
|
||||
setup,
|
||||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
hasStartupTerminal: false,
|
||||
reservePrimaryTerminal: false,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
|
||||
observeSetupCompletion: false,
|
||||
...(shouldActivate ? {} : { surfaceOwner: false })
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
setupTerminalHandle = provisioned.setupTerminalHandle
|
||||
setupProvisioned = provisioned.setupSpawned
|
||||
if (!setupTerminalHandle) {
|
||||
throw new Error('setup_spawn_failed')
|
||||
}
|
||||
const evidence = await this.waitForSetupTerminalEvidence(setupTerminalHandle)
|
||||
setupWaitSucceeded = isSuccessfulSetupExitEvidence(evidence)
|
||||
if (!setupWaitSucceeded) {
|
||||
warning = `Setup did not complete successfully for ${worktreePath}; the agent was not started.`
|
||||
}
|
||||
} catch (err) {
|
||||
setupWaitSucceeded = false
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
? `${warning} Also setup sequencing could not be proven for ${worktreePath}: ${message}`
|
||||
: `Setup sequencing could not be proven for ${worktreePath}: ${message}`
|
||||
}
|
||||
sequencedSetup = applySequencedSetupLaunch(setup, sequenced)
|
||||
}
|
||||
|
||||
if (sequencedStartup && this.ptyController?.spawn) {
|
||||
if (effectiveStartup && setupWaitSucceeded && this.ptyController?.spawn) {
|
||||
try {
|
||||
// Why: automation startup must not depend on a renderer TerminalPane
|
||||
// mounting. Runtime-spawned PTYs run immediately and the UI adopts the
|
||||
@@ -27975,16 +28095,16 @@ export class OrcaRuntimeService {
|
||||
await this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath)
|
||||
}
|
||||
const terminal = await this.createTerminal(`id:${worktree.id}`, {
|
||||
command: sequencedStartup.command,
|
||||
command: effectiveStartup.command,
|
||||
...(setup && effectiveStartup
|
||||
? { claudeAgentTeamsSourceCommand: effectiveStartup.command }
|
||||
: {}),
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
env: effectiveStartup.env,
|
||||
...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}),
|
||||
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}),
|
||||
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
|
||||
telemetry: effectiveStartup.telemetry,
|
||||
...ownerSurfacing(shouldActivate)
|
||||
})
|
||||
if (effectiveDraftPaste) {
|
||||
@@ -28010,7 +28130,8 @@ export class OrcaRuntimeService {
|
||||
// Why: plain CLI creates should not steal the user's current workspace.
|
||||
// Explicit activation and hook-running still use renderer activation so
|
||||
// the user can watch prompts/output in a visible pane.
|
||||
const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs)
|
||||
const runtimeWillProvisionTerminals =
|
||||
!setupProvisioned && didSpawnStartup && Boolean(setup || defaultTabs)
|
||||
if (runtimeWillProvisionTerminals) {
|
||||
// Why: once runtime spawned the startup PTY, renderer activation may see
|
||||
// an existing terminal and skip setup/default tabs. Await provisioning so
|
||||
@@ -28026,10 +28147,7 @@ export class OrcaRuntimeService {
|
||||
primaryTerminalHandle: startupTerminalHandle,
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
// Why: carry the wait-for-agent gated setup launch (#6298) so the
|
||||
// Setup tab runs the same script the sequenced agent waits on.
|
||||
...(sequencedSetup ? { sequencedSetup } : {})
|
||||
observeSetupCompletion: args.observeSetupCompletion
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
setupTerminalHandle = provisioned.setupTerminalHandle
|
||||
@@ -28037,13 +28155,9 @@ export class OrcaRuntimeService {
|
||||
// Why: when runtime spawned setup, omit it from activation. When setup
|
||||
// spawn failed, fall through with the wrapped command so renderer
|
||||
// activation retries it.
|
||||
const activationSetup = didSpawnSetup
|
||||
? undefined
|
||||
: didSpawnStartup && sequencedSetup
|
||||
? sequencedSetup
|
||||
: setup
|
||||
const activationSetup = didSpawnSetup ? undefined : setup
|
||||
const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs
|
||||
if (effectiveStartup && !didSpawnStartup) {
|
||||
if (effectiveStartup && !didSpawnStartup && !setupMustGateStartup) {
|
||||
this.notifyActivateWorktree(repo.id, worktree.id, {
|
||||
setup: activationSetup,
|
||||
startup: effectiveStartup,
|
||||
@@ -28057,7 +28171,13 @@ export class OrcaRuntimeService {
|
||||
navigationTarget: args.navigation
|
||||
})
|
||||
}
|
||||
} else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) {
|
||||
} else if (
|
||||
!setupProvisioned &&
|
||||
this.ptyController?.spawn &&
|
||||
!didSpawnStartup &&
|
||||
!setup &&
|
||||
!defaultTabs
|
||||
) {
|
||||
// Why: inactive terminal materialization matches normal worktree creation,
|
||||
// but setup/default tab failures must not gate automation dispatch.
|
||||
const provisioning = this.provisionManagedWorktreeTerminals({
|
||||
@@ -28070,7 +28190,6 @@ export class OrcaRuntimeService {
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
...(sequencedSetup ? { sequencedSetup } : {}),
|
||||
surfaceOwner: false
|
||||
})
|
||||
// Why: runtime owns setup spawning here, so the RPC result must omit setup
|
||||
@@ -28085,7 +28204,7 @@ export class OrcaRuntimeService {
|
||||
didSpawnSetup = true
|
||||
}
|
||||
}
|
||||
} else if (this.ptyController?.spawn) {
|
||||
} else if (this.ptyController?.spawn && !setupProvisioned && !didSpawnStartup) {
|
||||
try {
|
||||
await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
|
||||
} catch (err) {
|
||||
@@ -28096,11 +28215,7 @@ export class OrcaRuntimeService {
|
||||
console.warn(`[worktree-create] ${warning}`)
|
||||
}
|
||||
}
|
||||
const returnedSetup = didSpawnSetup
|
||||
? undefined
|
||||
: didSpawnStartup && sequencedSetup
|
||||
? sequencedSetup
|
||||
: setup
|
||||
const returnedSetup = didSpawnSetup ? undefined : setup
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
worktreeId: worktree.id,
|
||||
@@ -28132,9 +28247,13 @@ export class OrcaRuntimeService {
|
||||
? ('skipped' as const)
|
||||
: // Why: the in-process hook is already executing, so reporting
|
||||
// spawn_failed would strand callers that retry on it.
|
||||
didSpawnSetup || didStartInProcessSetupHook
|
||||
? ('running' as const)
|
||||
: ('spawn_failed' as const),
|
||||
setupMustGateStartup && setupProvisioned
|
||||
? setupWaitSucceeded
|
||||
? ('succeeded' as const)
|
||||
: ('failed' as const)
|
||||
: didSpawnSetup || didStartInProcessSetupHook
|
||||
? ('running' as const)
|
||||
: ('spawn_failed' as const),
|
||||
...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {})
|
||||
}
|
||||
}
|
||||
@@ -28283,25 +28402,48 @@ export class OrcaRuntimeService {
|
||||
let startupTerminalPaneKey: string | null = null
|
||||
let startupTerminalPtyId: string | null = null
|
||||
|
||||
let sequencedStartup = args.startup
|
||||
let sequencedSetup: CreateWorktreeResult['setup']
|
||||
if (args.startup && result.setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix')
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: result.setup.runnerScriptPath,
|
||||
startupCommand: args.startup.command,
|
||||
platform,
|
||||
shell: result.setup.shell
|
||||
})
|
||||
sequencedStartup = {
|
||||
...args.startup,
|
||||
command: sequenced.startupCommand,
|
||||
...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {})
|
||||
const setupMustGateStartup = Boolean(args.startup && result.setup?.waitForAgentStartup === true)
|
||||
let setupProvisioned = false
|
||||
let setupWaitSucceeded = !setupMustGateStartup
|
||||
|
||||
// Host-owned sequencing: Setup is a real PTY whose provider exit is the only authorization
|
||||
// to create the Agent PTY. Unknown/lost setup outcomes fail closed; there is no timer that
|
||||
// can silently launch an agent without a successful exit certificate.
|
||||
if (setupMustGateStartup && result.setup && this.ptyController?.spawn) {
|
||||
try {
|
||||
const provisioned = await this.provisionManagedWorktreeTerminals({
|
||||
worktreeSelector: `path:${result.worktree.path}`,
|
||||
worktreeId: result.worktree.id,
|
||||
worktreePath: result.worktree.path,
|
||||
setup: result.setup,
|
||||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
hasStartupTerminal: false,
|
||||
reservePrimaryTerminal: false,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'),
|
||||
observeSetupCompletion: false,
|
||||
...(shouldActivate ? {} : { surfaceOwner: false })
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
setupTerminalHandle = provisioned.setupTerminalHandle
|
||||
setupProvisioned = provisioned.setupSpawned
|
||||
if (!setupTerminalHandle) {
|
||||
throw new Error('setup_spawn_failed')
|
||||
}
|
||||
const evidence = await this.waitForSetupTerminalEvidence(setupTerminalHandle)
|
||||
setupWaitSucceeded = isSuccessfulSetupExitEvidence(evidence)
|
||||
if (!setupWaitSucceeded) {
|
||||
warning = `Setup did not complete successfully for ${result.worktree.path}; the agent was not started.`
|
||||
}
|
||||
} catch (err) {
|
||||
setupWaitSucceeded = false
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
warning = warning
|
||||
? `${warning} Also setup sequencing could not be proven for ${result.worktree.path}: ${message}`
|
||||
: `Setup sequencing could not be proven for ${result.worktree.path}: ${message}`
|
||||
}
|
||||
sequencedSetup = applySequencedSetupLaunch(result.setup, sequenced)
|
||||
}
|
||||
|
||||
if (sequencedStartup && this.ptyController?.spawn) {
|
||||
if (args.startup && setupWaitSucceeded && this.ptyController?.spawn) {
|
||||
try {
|
||||
const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent
|
||||
if (startupTrustAgent) {
|
||||
@@ -28312,16 +28454,16 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
const terminal = await this.createTerminal(`path:${result.worktree.path}`, {
|
||||
command: sequencedStartup.command,
|
||||
command: args.startup.command,
|
||||
...(result.setup && args.startup
|
||||
? { claudeAgentTeamsSourceCommand: args.startup.command }
|
||||
: {}),
|
||||
env: sequencedStartup.env,
|
||||
...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}),
|
||||
env: args.startup.env,
|
||||
...(args.startup.launchConfig ? { launchConfig: args.startup.launchConfig } : {}),
|
||||
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
|
||||
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
|
||||
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
|
||||
telemetry: sequencedStartup.telemetry,
|
||||
...(args.startup.viewMode ? { viewMode: args.startup.viewMode } : {}),
|
||||
startupCommandDelivery: args.startup.startupCommandDelivery,
|
||||
telemetry: args.startup.telemetry,
|
||||
...ownerSurfacing(shouldActivate)
|
||||
})
|
||||
if (args.startupDraftPaste) {
|
||||
@@ -28345,7 +28487,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
if (shouldActivate) {
|
||||
const runtimeWillProvisionTerminals =
|
||||
didSpawnStartup && Boolean(result.setup || result.defaultTabs)
|
||||
!setupProvisioned && didSpawnStartup && Boolean(result.setup || result.defaultTabs)
|
||||
if (runtimeWillProvisionTerminals) {
|
||||
// Why: remote/mobile task creates spawn the agent terminal in runtime,
|
||||
// so renderer activation may not materialize setup/default tabs. Await so
|
||||
@@ -28359,23 +28501,16 @@ export class OrcaRuntimeService {
|
||||
primaryTerminalHandle: startupTerminalHandle,
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
// Why: carry the wait-for-agent gated setup launch (#6298) so the
|
||||
// remote Setup tab runs the same script the sequenced agent waits on.
|
||||
...(sequencedSetup ? { sequencedSetup } : {})
|
||||
observeSetupCompletion: args.observeSetupCompletion
|
||||
})
|
||||
didSpawnSetup = provisioned.setupSpawned
|
||||
setupTerminalHandle = provisioned.setupTerminalHandle
|
||||
}
|
||||
// Why: omit setup from activation when runtime spawned it; on spawn
|
||||
// failure fall through with the wrapped command so renderer retries.
|
||||
const activationSetup = didSpawnSetup
|
||||
? undefined
|
||||
: didSpawnStartup && sequencedSetup
|
||||
? sequencedSetup
|
||||
: result.setup
|
||||
const activationSetup = didSpawnSetup ? undefined : result.setup
|
||||
const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs
|
||||
if (args.startup && !didSpawnStartup) {
|
||||
if (args.startup && !didSpawnStartup && !setupMustGateStartup) {
|
||||
this.notifyActivateWorktree(repo.id, result.worktree.id, {
|
||||
setup: activationSetup,
|
||||
startup: args.startup,
|
||||
@@ -28394,7 +28529,10 @@ export class OrcaRuntimeService {
|
||||
if (
|
||||
!shouldActivate &&
|
||||
this.ptyController?.spawn &&
|
||||
(result.setup || result.defaultTabs || didSpawnStartup)
|
||||
!setupProvisioned &&
|
||||
!didSpawnStartup &&
|
||||
!result.setup &&
|
||||
!result.defaultTabs
|
||||
) {
|
||||
// Why: inactive terminal materialization matches normal worktree creation,
|
||||
// but setup/default tab failures must not gate automation dispatch.
|
||||
@@ -28408,7 +28546,6 @@ export class OrcaRuntimeService {
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
...(sequencedSetup ? { sequencedSetup } : {}),
|
||||
surfaceOwner: false
|
||||
})
|
||||
// Why: runtime owns setup spawning here, so omit setup from the RPC result
|
||||
@@ -28423,7 +28560,12 @@ export class OrcaRuntimeService {
|
||||
didSpawnSetup = true
|
||||
}
|
||||
}
|
||||
} else if (!shouldActivate && this.ptyController?.spawn) {
|
||||
} else if (
|
||||
!shouldActivate &&
|
||||
this.ptyController?.spawn &&
|
||||
!setupProvisioned &&
|
||||
!didSpawnStartup
|
||||
) {
|
||||
try {
|
||||
await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false })
|
||||
} catch (err) {
|
||||
@@ -28434,11 +28576,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
const returnedSetup = didSpawnSetup
|
||||
? undefined
|
||||
: didSpawnStartup && sequencedSetup
|
||||
? sequencedSetup
|
||||
: result.setup
|
||||
const returnedSetup = didSpawnSetup ? undefined : result.setup
|
||||
const resultForRenderer = returnedSetup
|
||||
? { ...result, setup: returnedSetup }
|
||||
: (() => {
|
||||
@@ -28473,9 +28611,13 @@ export class OrcaRuntimeService {
|
||||
? ('skipped' as const)
|
||||
: !result.setup
|
||||
? ('not_configured' as const)
|
||||
: didSpawnSetup
|
||||
? ('running' as const)
|
||||
: ('spawn_failed' as const),
|
||||
: setupMustGateStartup && setupProvisioned
|
||||
? setupWaitSucceeded
|
||||
? ('succeeded' as const)
|
||||
: ('failed' as const)
|
||||
: didSpawnSetup
|
||||
? ('running' as const)
|
||||
: ('spawn_failed' as const),
|
||||
...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {})
|
||||
}
|
||||
const resultWithSetupReceipt = args.awaitTerminalProvisioning
|
||||
@@ -31021,6 +31163,7 @@ export class OrcaRuntimeService {
|
||||
command: sequencedStartupCommand
|
||||
? launchOpts.command
|
||||
: (agentTeamsPlan?.command ?? launchOpts.command),
|
||||
...(launchOpts.directExec ? { directExec: launchOpts.directExec } : {}),
|
||||
launchAgent: launchOpts.launchAgent,
|
||||
commandDelivery: 'provider',
|
||||
startupCommandDelivery: launchOpts.startupCommandDelivery,
|
||||
@@ -38666,13 +38809,13 @@ export class OrcaRuntimeService {
|
||||
return this.getLeavesForPty(ptyId).some((leaf) => getTerminalState(leaf) === 'exited')
|
||||
}
|
||||
|
||||
private notifyPtyExitListeners(ptyId: string): void {
|
||||
private notifyPtyExitListeners(ptyId: string, event: RuntimePtyExitEvent): void {
|
||||
const listeners = this.ptyExitListenersByPtyId.get(ptyId)
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
this.ptyExitListenersByPtyId.delete(ptyId)
|
||||
notifyRuntimeListeners(listeners, (listener) => listener(), 'pty-exit')
|
||||
notifyRuntimeListeners(listeners, (listener) => listener(event), 'pty-exit')
|
||||
}
|
||||
|
||||
private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { isSuccessfulSetupExitEvidence, OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
type RuntimeInternals = {
|
||||
recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown
|
||||
@@ -16,6 +16,21 @@ function registerLivePty(runtime: OrcaRuntimeService): void {
|
||||
}
|
||||
|
||||
describe('PTY exit subscription', () => {
|
||||
it.each([
|
||||
[{ exitCode: 0, cause: { kind: 'exited', exitCode: 0 } }, true],
|
||||
[{ exitCode: 1, cause: { kind: 'exited', exitCode: 1 } }, false],
|
||||
[{ exitCode: 0, cause: { kind: 'signaled', signal: 9 } }, false],
|
||||
[{ exitCode: -1, cause: { kind: 'unknown', reason: 'transport_lost' } }, false]
|
||||
])('admits only an explicit normal zero exit as setup success', (partial, expected) => {
|
||||
expect(
|
||||
isSuccessfulSetupExitEvidence({
|
||||
ptyId: 'pty-1',
|
||||
incarnationId: 'incarnation-1',
|
||||
...(partial as { exitCode: number; cause: never })
|
||||
})
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('fires on the backing PTY exit', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerLivePty(runtime)
|
||||
@@ -31,6 +46,25 @@ describe('PTY exit subscription', () => {
|
||||
expect(internals(runtime).ptyExitListenersByPtyId.has('pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('delivers provider exit evidence instead of requiring a mutable record read', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerLivePty(runtime)
|
||||
const listener = vi.fn()
|
||||
|
||||
runtime.subscribeToPtyExit('pty-1', listener)
|
||||
runtime.onPtyExit('pty-1', 0, 'incarnation-1', {
|
||||
providerExitObserved: true,
|
||||
cause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
exitCode: 0,
|
||||
incarnationId: 'incarnation-1',
|
||||
cause: { kind: 'exited', exitCode: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retain listeners across subscription churn', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerLivePty(runtime)
|
||||
@@ -109,4 +143,23 @@ describe('PTY exit subscription demands proof of exit, not loss of connection',
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('replays the recorded cause for late subscribers', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
registerLivePty(runtime)
|
||||
runtime.onPtyExit('pty-1', 0, 'incarnation-1', {
|
||||
providerExitObserved: true,
|
||||
cause: { kind: 'signaled', signal: 9 }
|
||||
})
|
||||
const listener = vi.fn()
|
||||
|
||||
runtime.subscribeToPtyExit('pty-1', listener)
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
ptyId: 'pty-1',
|
||||
exitCode: 0,
|
||||
incarnationId: 'incarnation-1',
|
||||
cause: { kind: 'signaled', signal: 9 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -142,7 +142,7 @@ export type CreateWorktreeResult = {
|
||||
requested: 'run' | 'skip' | 'inherit'
|
||||
hookFound: boolean
|
||||
startupPolicy: 'start-immediately' | 'wait-for-setup'
|
||||
state: 'running' | 'skipped' | 'not_configured' | 'spawn_failed'
|
||||
state: 'running' | 'succeeded' | 'failed' | 'skipped' | 'not_configured' | 'spawn_failed'
|
||||
terminalHandle?: string
|
||||
}
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
|
||||
Reference in New Issue
Block a user