feat(cli): make terminal close the canonical workspace teardown (#18073)

* fix(runtime): recover stale session owners and await retirement

* fix(runtime): preserve session hydration and smoke compatibility

* test(runtime): cover empty and unindexed session owners

* feat(cli): make terminal close the canonical workspace teardown

* fix(preload): align ssh termination result type

* test(runtime): assert folder hydration owner

* fix(runtime): fence legacy terminal stop by worktree host

* fix(preload): reconcile ssh result import with main

* fix(runtime): keep same-id sibling hosts out of workspace close

The stale-owner fallback in the session controller re-routed any worktree whose
catalog partition had no tabs to whichever other partition held tabs. Only
`runtime:` environment ids rotate across relay restarts; `repoId::path` legitimately
repeats across hosts, so an SSH workspace close could retire the local copy's
tabs and resume records, or flip owners mid-close and strand the SSH PTY.

Restrict the fallback to runtime hosts, and pin the session partition once per
workspace close so record clearing targets the partition that owned the tabs.

* test(runtime): give the cross-host close fixture a real resume record

* fix(preload): take main's ssh-bridge import order so the merge stays duplicate-free
This commit is contained in:
Jinwoo Hong
2026-09-03 03:58:45 -04:00
committed by GitHub
parent 316ec38f67
commit 573537ecd4
38 changed files with 1163 additions and 112 deletions
+10 -2
View File
@@ -6141,10 +6141,11 @@
"https://github.com/stablyai/orca/pull/8706",
"https://github.com/stablyai/orca/issues/14524"
],
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. Workspace-wide CLI close applies that same durable contract to every terminal in exactly one worktree, including pinned and persisted-only surfaces, without touching sibling-worktree PTYs or resume records. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. terminal.closeAll must force-retire every pinned and unpinned target surface, clear its persisted agent-resume and incarnation records, await authoritative PTY exit, preserve a sibling worktree's tab, PTY, and resume authority, and return an unverifiable failure instead of claiming exit when the owning host does not confirm a stop. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence-initial-load.test.ts src/main/persistence-pane-identity-migration.test.ts src/main/persistence-pty-binding-reconciliation.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/runtime/worktree-teardown.test.ts",
@@ -6156,6 +6157,7 @@
],
"testFiles": [
"src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/renderer/src/components/terminal/initial-terminal.test.ts",
"src/renderer/src/lib/worktree-activation-default-tabs.test.ts",
"src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
@@ -6184,6 +6186,12 @@
"tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"workspace-wide close force-retires pinned and unpinned tabs, clears resume/incarnation state, awaits both target PTYs, preserves the sibling worktree, and reports an unconfirmed stop as unverifiable"
]
},
{
"file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"assertions": [
+8
View File
@@ -127,10 +127,18 @@ orca terminal split --terminal <handle> --direction horizontal --command "npm ru
orca terminal rename --terminal <handle> --title "runner" --json
orca terminal switch --terminal <handle> --json
orca terminal close --terminal <handle> --json
orca terminal close --worktree active --all --json
```
Omit `--terminal` to target the active terminal in the current worktree. Read before sending when you are not sure what the terminal is waiting for.
Close stops the process as part of removing the terminal. Use `--terminal <handle>` for one
terminal, or `--worktree <selector> --all` to durably close every terminal in exactly that
workspace, including its saved layouts and agent-resume records. Use workspace Sleep when those
terminals should resume later. If the execution host cannot confirm that every PTY stopped, bulk
close returns a failing `unverifiable` result rather than claiming the processes exited.
`terminal stop` remains only for compatibility with older tooling.
<Callout title="Terminal handles">
Terminal handles are runtime-scoped. If Orca restarts or a command reports a stale terminal
handle, run `orca terminal list --json` and reacquire the handle.
+4 -1
View File
@@ -184,7 +184,6 @@ ORCA terminal send --terminal <handle> --text "continue" --enter --json
ORCA terminal send --text "echo hello" --enter --json
ORCA terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
ORCA terminal stop --worktree id:<repoId>::<worktreePath> --json
ORCA terminal create --json
ORCA terminal create --title "Worker" --json
ORCA terminal create --worktree active --command "codex" --json
@@ -193,11 +192,15 @@ ORCA terminal split --terminal <handle> --direction horizontal --command "npm te
ORCA terminal rename --terminal <handle> --title "New Name" --json
ORCA terminal switch --terminal <handle> --json
ORCA terminal close --terminal <handle> --json
ORCA terminal close --worktree id:<repoId>::<worktreePath> --all --json
```
Terminal rules:
- `--terminal` is optional for most commands; omitted means the active terminal in the current worktree.
- Use `terminal close --terminal <handle>` to close one terminal. Use `terminal close --worktree <selector> --all` to stop every terminal process in exactly that workspace and durably remove its terminal tabs, layouts, and agent-resume records.
- A bulk close fails when the execution host cannot confirm every PTY stopped. Treat that as `unverifiable`; do not report the processes as exited or retry against another host.
- Use workspace Sleep, not close, when the terminals and agent sessions should resume later. `terminal stop` is legacy compatibility plumbing and should not be used in new agent workflows.
- `terminal list --json` omits `visualLayouts` to keep the common agent payload bounded. Add `--include-visual-layouts` only when tab and pane topology is required.
- Use `terminal read` before `terminal send` unless the next input is obvious.
- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.
File diff suppressed because one or more lines are too long
+1
View File
@@ -5,6 +5,7 @@ export type CommandSpec = {
argumentMode?: 'parsed' | 'passthrough'
// Why: typo recovery must never steer a benign mistake into destructive state changes.
destructive?: boolean
hidden?: boolean
summary: string
usage: string
allowedFlags: string[]
+11
View File
@@ -35,6 +35,13 @@ const specs: CommandSpec[] = [
summary: 'Kill the emulator',
usage: 'orca emulator kill',
allowedFlags: []
},
{
path: ['terminal', 'stop'],
hidden: true,
summary: 'Deprecated terminal stop',
usage: 'orca terminal stop',
allowedFlags: []
}
]
@@ -111,6 +118,10 @@ describe('suggestCommands', () => {
it('still recovers non-destructive near-misses', () => {
expect(suggestCommands(specs, ['worktree', 'lst'])).toContain('worktree list')
})
it('does not suggest hidden compatibility commands', () => {
expect(suggestCommands(specs, ['terminal', 'stp'])).not.toContain('terminal stop')
})
})
describe('unknownCommandData', () => {
+3
View File
@@ -67,6 +67,9 @@ export function suggestCommands(specs: CommandSpec[], commandPath: string[]): st
const seen = new Set<string>()
const scored: { label: string; distance: number }[] = []
for (const spec of specs) {
if (spec.hidden) {
continue
}
if (spec.destructive && !allowDestructive) {
continue
}
+118 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeClient } from '../runtime-client'
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'
import { parseArgs } from '../args'
import { printHelp } from '../help'
import { COMMAND_SPECS } from '../specs'
@@ -122,15 +122,131 @@ describe('terminal close CLI', () => {
expect(process.exitCode).toBeUndefined()
})
it('routes --worktree --all to authoritative durable bulk close', async () => {
const parsed = parseArgs(['terminal', 'close', '--worktree', 'id:repo::/worktree', '--all'])
const call = vi.fn().mockResolvedValue({
result: { closed: 2, stopped: 3, retiredSurfaces: true }
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal close']({
flags: parsed.flags,
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenCalledWith('terminal.closeAll', {
worktree: 'id:repo::/worktree'
})
})
it('fails JSON when bulk close cannot verify every PTY stopped', async () => {
process.exitCode = undefined
const call = vi.fn().mockResolvedValue({
result: {
closed: 2,
stopped: 1,
retiredSurfaces: true,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'the SSH host disconnected'
}
})
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal close']({
flags: new Map<string, string | true>([
['worktree', 'id:repo::/worktree'],
['all', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'terminal_stop_unverifiable',
data: { close: { closed: 2, stopped: 1, ptyStopVerdict: 'unverifiable' } }
}
})
expect(process.exitCode).toBe(1)
})
it.each([
[new Map<string, string | true>([['worktree', 'active']]), 'requires --all'],
[
new Map<string, string | true>([
['worktree', 'active'],
['all', true],
['terminal', 'term-1']
]),
'cannot be combined'
],
[new Map<string, string | true>([['all', true]]), 'Missing required --worktree']
])('rejects ambiguous bulk-close flags', async (flags, message) => {
await expect(
TERMINAL_HANDLERS['terminal close']({
flags,
client: { call: vi.fn() } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
).rejects.toThrow(message)
})
it('fails safely before mutation when the host predates bulk close', async () => {
const call = vi
.fn()
.mockRejectedValue(
new RuntimeClientError('method_not_found', 'Unknown method: terminal.closeAll')
)
await expect(
TERMINAL_HANDLERS['terminal close']({
flags: new Map<string, string | true>([
['worktree', 'active'],
['all', true]
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
).rejects.toMatchObject({ code: 'incompatible_runtime' })
})
it('documents that --tab waits for durable persistence', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
printHelp(COMMAND_SPECS, ['terminal', 'close'])
const help = String(log.mock.calls[0]?.[0])
expect(help).toContain('orca terminal close [--terminal <handle>] [--tab] [--json]')
expect(help).toContain('--worktree <selector> --all')
expect(help).toContain('durable persistence')
})
it('hides legacy stop from terminal command discovery', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
printHelp(COMMAND_SPECS, ['terminal'])
const help = String(log.mock.calls[0]?.[0])
expect(help).toContain('close')
expect(help).not.toContain('stop')
})
it('keeps root help aligned with the canonical close command', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
printHelp(COMMAND_SPECS)
const help = String(log.mock.calls[0]?.[0])
expect(help).toContain(
'terminal close Close one terminal, its whole tab with --tab, or all in a worktree'
)
expect(help).not.toContain('terminal stop')
})
})
describe('terminal send CLI', () => {
+59 -1
View File
@@ -8,7 +8,8 @@ import type {
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait
RuntimeTerminalWait,
RuntimeWorktreeTerminalCloseResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
@@ -62,6 +63,23 @@ function terminalCloseFailure(close: RuntimeTerminalClose): RuntimeClientError |
)
}
function terminalCloseAllFailure(
close: RuntimeWorktreeTerminalCloseResult
): RuntimeClientError | null {
if (!close.ptyStopVerdict) {
return null
}
const detail =
close.ptyStopVerdict === 'live'
? 'At least one PTY is live.'
: `At least one PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its owning host could not be reached'}.`
return new RuntimeClientError(
close.ptyStopVerdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
`Workspace terminal close did not confirm every PTY stopped (${close.ptyStopVerdict}). ${detail}`,
{ close }
)
}
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
terminal: await getTerminalHandle(flags, cwd, client),
@@ -198,6 +216,46 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
'terminal switch': terminalFocusHandler,
'terminal close': async ({ flags, client, cwd, json }) => {
if (flags.get('all') === true) {
if (flags.has('terminal') || flags.get('tab') === true) {
throw new RuntimeClientError(
'invalid_argument',
'--all uses --worktree and cannot be combined with --terminal or --tab'
)
}
try {
const result = await client.call<RuntimeWorktreeTerminalCloseResult>('terminal.closeAll', {
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
})
const failure = terminalCloseAllFailure(result.result)
if (failure) {
reportCliError(failure, json)
process.exitCode = 1
return
}
printResult(
result,
json,
(value) =>
`Closed ${value.closed} terminal tabs and stopped ${value.stopped} terminal processes.`
)
return
} catch (error) {
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca host does not support closing every terminal in a workspace yet. Update Orca on the host and try again.'
)
}
throw error
}
}
if (flags.has('worktree')) {
throw new RuntimeClientError(
'invalid_argument',
'Closing a workspace requires --all: terminal close --worktree <selector> --all'
)
}
const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close'
const result = await client.call<{ close: RuntimeTerminalClose }>(method, {
terminal: await getTerminalHandle(flags, cwd, client)
+1 -1
View File
@@ -61,7 +61,7 @@ export function formatCommandHelp(spec: CommandSpec): string {
}
export function formatGroupHelp(specs: CommandSpec[], group: string): string {
const groupSpecs = specs.filter((spec) => spec.path[0] === group)
const groupSpecs = specs.filter((spec) => spec.path[0] === group && spec.hidden !== true)
const lines = [`orca ${group}`, '', `Usage: orca ${group} <command> [options]`, '', 'Commands:']
for (const spec of groupSpecs) {
lines.push(` ${spec.path.slice(1).join(' ').padEnd(18)} ${spec.summary}`)
+1 -2
View File
@@ -83,13 +83,12 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' terminal read Read bounded terminal output',
' terminal send Send input to a live terminal',
' terminal wait Wait for a terminal condition (exit, tui-idle)',
' terminal stop Stop terminals for a worktree',
' terminal create Create a terminal session in a worktree',
' terminal rename Set or clear the title of a terminal tab',
' terminal split Split an existing terminal pane',
' terminal switch Bring a terminal tab to the foreground',
' terminal focus Alias for terminal switch',
' terminal close Close a terminal pane/session, or its whole tab with --tab',
' terminal close Close one terminal, its whole tab with --tab, or all in a worktree',
'',
'Orchestration:',
' orchestration run-create Create and bind a lightweight orchestration Run',
+1 -2
View File
@@ -62,11 +62,10 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
' orca terminal stop --worktree <selector> [--json]',
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
' orca terminal switch [--terminal <handle>] [--json]',
' orca terminal close [--terminal <handle>] [--tab] [--json]',
' orca terminal close ([--terminal <handle>] [--tab] | --worktree <selector> --all) [--json]',
' orca project list [--json]',
' orca project setups [--project <id>] [--host <host-id>] [--json]',
' orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
+8 -15
View File
@@ -1,6 +1,7 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
import { SERVE_COMMAND_SPECS } from './serve'
import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close'
export const CORE_COMMAND_SPECS: CommandSpec[] = [
{
@@ -236,9 +237,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['terminal', 'stop'],
summary: 'Stop terminals for a worktree',
hidden: true,
summary: 'Deprecated compatibility command for stopping terminal processes',
usage: 'orca terminal stop --worktree <selector> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree']
allowedFlags: [...GLOBAL_FLAGS, 'worktree'],
notes: [
'Deprecated: use terminal close --worktree <selector> --all to stop the processes and durably remove their terminal surfaces.'
]
},
{
path: ['terminal', 'create'],
@@ -267,19 +272,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'terminal'],
examples: ['orca terminal switch --terminal term_abc123']
},
{
path: ['terminal', 'close'],
summary: 'Close a terminal pane/session, or its whole tab with --tab',
usage: 'orca terminal close [--terminal <handle>] [--tab] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'tab'],
notes: [
'Without --tab, preserves the existing pane/session close behavior. With --tab, waits until the whole tab is durably removed.'
],
examples: [
'orca terminal close --terminal term_abc123',
'orca terminal close --terminal term_abc123 --tab --json'
]
},
TERMINAL_CLOSE_COMMAND_SPEC,
{
path: ['terminal', 'rename'],
summary: 'Set or clear the title of a terminal tab',
+21
View File
@@ -0,0 +1,21 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const TERMINAL_CLOSE_COMMAND_SPEC: CommandSpec = {
path: ['terminal', 'close'],
destructive: true,
summary: 'Close one terminal, its whole tab, or every terminal in a workspace',
usage:
'orca terminal close ([--terminal <handle>] [--tab] | --worktree <selector> --all) [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'tab', 'worktree', 'all'],
notes: [
'Without --all, closes one terminal pane/session; add --tab to close its whole tab.',
'With --worktree <selector> --all, stops every terminal process owned by that workspace and durably removes its terminal tabs, layouts, and resume records.',
'Use workspace Sleep when the terminals and agent sessions should resume later.'
],
examples: [
'orca terminal close --terminal term_abc123',
'orca terminal close --terminal term_abc123 --tab --json',
'orca terminal close --worktree active --all --json'
]
}
@@ -81,13 +81,15 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu
protected commitHeadlessTerminalTabRetirement(
worktreeId: string,
parentTabId: string,
options: { allowMissing?: boolean } = {}
options: { allowMissing?: boolean; force?: boolean } = {}
): string[] {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession || !this.store.flushOrThrow) {
throw new Error('workspace_session_unavailable')
}
const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId)
const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId, {
force: options.force
})
if (result.pinned) {
throw new Error('terminal_tab_pinned')
}
@@ -21,6 +21,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi
allowMissingPersistedTab?: boolean
killPtys?: boolean
authorizedPty?: RuntimePtyWorktreeRecord
force?: boolean
} = {}
): void {
const closedParentTabId = tab.parentTabId
@@ -38,7 +39,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi
const projectedPtyIds = this.commitHeadlessTerminalTabRetirement(
worktreeId,
closedParentTabId,
{ allowMissing: options.allowMissingPersistedTab }
{ allowMissing: options.allowMissingPersistedTab, force: options.force }
)
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId)
if (options.authorizedPty) {
@@ -32,6 +32,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
clientNavigationId?: string
localPtyTeardownOwnedExternally?: boolean
expectedPtyCloseAuthority?: RuntimePtyTabCloseAuthority
force?: boolean
} = {}
): Promise<MobileSessionTabCloseOutcome> {
const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null
@@ -166,6 +167,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
if (closingWholeParent && !this.tabs.has(tab.parentTabId)) {
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, {
allowMissingPersistedTab: Boolean(ptyCloseAuthority),
force: options.force,
killPtys:
options.localPtyTeardownOwnedExternally !== true &&
(options.reason === undefined || options.reason === 'user'),
@@ -188,9 +190,12 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
try {
await (options.localPtyTeardownOwnedExternally
? this.notifier.closeTerminalTab(tab.parentTabId, {
localPtyTeardownOwnedExternally: true
localPtyTeardownOwnedExternally: true,
...(options.force ? { force: true } : {})
})
: this.notifier.closeTerminalTab(tab.parentTabId))
: options.force
? this.notifier.closeTerminalTab(tab.parentTabId, { force: true })
: this.notifier.closeTerminalTab(tab.parentTabId))
} finally {
releasePublicationThrottle()
}
@@ -211,6 +216,7 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, {
// Why: the renderer may already have durably removed the tab before acknowledging.
allowMissingPersistedTab: true,
force: options.force,
...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {})
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
@@ -221,22 +227,18 @@ export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseU
// Why: notifier implementations without the acknowledged relay may expose
// only raw pane close. Runtime-owned parents still need de-persist + kill.
if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) {
this.closeHeadlessMobileTerminalTab(
worktreeId,
snapshot,
tab,
ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}
)
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, {
force: options.force,
...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {})
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
return finishCommittedClose()
}
if (!this.notifier?.closeTerminal) {
this.closeHeadlessMobileTerminalTab(
worktreeId,
snapshot,
tab,
ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {}
)
this.closeHeadlessMobileTerminalTab(worktreeId, snapshot, tab, {
force: options.force,
...(ptyCloseAuthority ? { authorizedPty: ptyCloseAuthority.pty } : {})
})
return finishCommittedClose()
}
if (tab.id === tabId) {
@@ -5,21 +5,177 @@ import {
runtimeWorktreeIdsEqual
} from './runtime-worktree-path-identity'
import { teardownRpcDeadline } from './worktree-teardown'
import type { RuntimeWorktreeTerminalSleepResult } from '../../shared/runtime-types'
import type {
RuntimeWorktreeTerminalCloseResult,
RuntimeWorktreeTerminalSleepResult
} from '../../shared/runtime-types'
import type { WorktreeTerminalMutationKind } from './worktree-terminal-mutation-lock'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback'
import {
getWorktreeExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { worktreePtyBelongsToHost, type WorktreePtyHostFence } from './worktree-pty-host-fence'
import { summarizeWorktreePtyStopVerdict } from './worktree-pty-stop-verdict'
export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithResolveTerminalSplitSourceAuthority {
private collectWorktreePtyIds(
worktreeId: string,
hostFence: WorktreePtyHostFence,
includeDisconnected = false
): Set<string> {
const ptyIds = new Set<string>()
for (const leaf of this.leaves.values()) {
if (
runtimeWorktreeIdsEqual(leaf.worktreeId, worktreeId) &&
leaf.ptyId &&
worktreePtyBelongsToHost(leaf.ptyId, this.ptysById.get(leaf.ptyId)?.connectionId, hostFence)
) {
ptyIds.add(leaf.ptyId)
}
}
for (const pty of this.ptysById.values()) {
if (
runtimeWorktreeIdsEqual(pty.worktreeId, worktreeId) &&
(includeDisconnected || pty.connected) &&
worktreePtyBelongsToHost(pty.ptyId, pty.connectionId, hostFence)
) {
ptyIds.add(pty.ptyId)
}
}
return ptyIds
}
private getWorktreeHostFence(worktree: { id: string; repoId?: string }): WorktreePtyHostFence {
const repo = worktree.repoId ? this.store?.getRepo?.(worktree.repoId) : undefined
const parsedHost = parseExecutionHostId(getWorktreeExecutionHostId(worktree, repo))
return parsedHost?.kind === 'runtime'
? { resolvedRuntimeEnvironmentId: parsedHost.environmentId }
: { resolvedConnectionId: parsedHost?.kind === 'ssh' ? parsedHost.targetId : null }
}
async closeTerminalsForWorktree(
worktreeSelector: string
): Promise<RuntimeWorktreeTerminalCloseResult> {
const graphEpoch = this.captureReadyGraphEpoch()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
this.assertStableReadyGraph(graphEpoch)
const hostFence = this.getWorktreeHostFence(worktree)
return await this.runWorktreeTerminalMutation(worktree.id, async () => {
// Why: emptying a rotated runtime partition re-routes the session owner, so the
// records cleared below live in the partition that owned the tabs at the start.
const sessionHostId = this.getWorkspaceSessionHostIdForWorktree(worktree.id)
const snapshot = await this.listMobileSessionTabs(`id:${worktree.id}`)
const targetPtyIds = this.collectWorktreePtyIds(worktree.id, hostFence, true)
const parentTabIds = [
...new Set(
snapshot.tabs.flatMap((tab) => (tab.type === 'terminal' ? [tab.parentTabId] : []))
)
]
let closed = 0
for (const parentTabId of parentTabIds) {
const result = await this.closeMobileSessionTab(`id:${worktree.id}`, parentTabId, {
reason: 'user',
force: true,
localPtyTeardownOwnedExternally: true
})
if (result.refused) {
throw new Error(result.refusalReason ?? 'terminal_close_refused')
}
closed += 1
}
this.clearWorktreeTerminalResumeRecords(worktree.id, sessionHostId, parentTabIds)
const { stopped } = await this.stopTerminalsForWorktree(`id:${worktree.id}`, {
resolvedWorktreeId: worktree.id,
...hostFence
})
const ptyStop = summarizeWorktreePtyStopVerdict(
targetPtyIds,
(ptyId) => this.getPtyLivenessVerdict(ptyId),
(ptyId) =>
this.ptysById.get(ptyId)?.connected === true ||
(this.isSshOwnedPtyId(ptyId) && this.ptysById.has(ptyId))
)
return {
closed,
stopped,
retiredSurfaces: true,
...ptyStop
}
})
}
private clearWorktreeTerminalResumeRecords(
worktreeId: string,
hostId: ExecutionHostId,
closedTabIds: readonly string[]
): void {
if (
!this.store?.getWorkspaceSession ||
!this.store.setWorkspaceSession ||
!this.store.flushOrThrow
) {
throw new Error('workspace_session_unavailable')
}
const session = this.store.getWorkspaceSession(hostId)
const sleepingAgentSessionsByPaneKey = Object.fromEntries(
Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).filter(
([, record]) => record.worktreeId !== worktreeId
)
)
const terminalPtyIncarnationsByPaneKey = Object.fromEntries(
Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter(
([paneKey]) => !closedTabIds.some((tabId) => paneKey.startsWith(`${tabId}:`))
)
)
const remainingTerminalRows = session.tabsByWorktree[worktreeId] ?? []
const remainingUnifiedTerminalTabs = (session.unifiedTabs?.[worktreeId] ?? []).filter(
(tab) => tab.contentType === 'terminal'
)
if (remainingTerminalRows.length > 0 || remainingUnifiedTerminalTabs.length > 0) {
throw new Error('terminal_close_incomplete')
}
const hasChanges =
Object.keys(sleepingAgentSessionsByPaneKey).length !==
Object.keys(session.sleepingAgentSessionsByPaneKey ?? {}).length ||
Object.keys(terminalPtyIncarnationsByPaneKey).length !==
Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).length
if (!hasChanges) {
return
}
const next: WorkspaceSessionState = {
...session,
sleepingAgentSessionsByPaneKey,
terminalPtyIncarnationsByPaneKey
}
this.store.setWorkspaceSession(next, hostId)
const staged = this.store.getWorkspaceSession(hostId)
try {
this.store.flushOrThrow()
} catch (error) {
const current = this.store.getWorkspaceSession(hostId)
const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current)
if (rolledBack !== current) {
this.store.setWorkspaceSession(rolledBack, hostId)
}
throw error
}
}
async stopTerminalsForWorktree(
worktreeSelector: string,
options: {
deadline?: number
stopPty?: (
ptyId: string,
stop: () => boolean | Promise<boolean>
stop: () => Promise<boolean>
) => Promise<{ stopped: boolean; owner: boolean }>
/** Authoritative id for an orphan whose selector no longer resolves. */
resolvedWorktreeId?: string
resolvedConnectionId?: string
resolvedConnectionId?: string | null
resolvedRuntimeEnvironmentId?: string
} = {}
): Promise<{ stopped: number }> {
@@ -33,65 +189,45 @@ export class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntimeWithReso
return { stopped: 0 }
}
// Preserve folder-instance suffixes while normalizing cross-platform path spelling.
const ownsWorktree = options.resolvedWorktreeId
? (candidate: string | undefined): boolean =>
candidate ? runtimeWorktreeIdsEqual(candidate, worktree.id) : false
: (candidate: string | undefined): boolean => candidate === worktree.id
const ownsHost = (ptyId: string, connectionId?: string | null): boolean => {
if (options.resolvedRuntimeEnvironmentId !== undefined) {
return ptyId.startsWith(
`remote:${encodeURIComponent(options.resolvedRuntimeEnvironmentId)}@@`
)
}
return (
options.resolvedConnectionId === undefined || connectionId === options.resolvedConnectionId
)
}
const ptyIds = new Set<string>()
for (const leaf of this.leaves.values()) {
if (
ownsWorktree(leaf.worktreeId) &&
leaf.ptyId &&
ownsHost(leaf.ptyId, this.ptysById.get(leaf.ptyId)?.connectionId)
) {
ptyIds.add(leaf.ptyId)
}
}
for (const pty of this.ptysById.values()) {
if (ownsWorktree(pty.worktreeId) && pty.connected && ownsHost(pty.ptyId, pty.connectionId)) {
ptyIds.add(pty.ptyId)
}
}
const hostFence =
options.resolvedWorktreeId ||
options.resolvedConnectionId !== undefined ||
options.resolvedRuntimeEnvironmentId !== undefined
? options
: this.getWorktreeHostFence(worktree)
const ptyIds = this.collectWorktreePtyIds(worktree.id, hostFence)
let stopped = 0
for (const ptyId of ptyIds) {
if (options.deadline !== undefined && Date.now() >= options.deadline) {
break
}
const stop = (): boolean | Promise<boolean> => {
const stop = async (): Promise<boolean> => {
if (options.deadline !== undefined && Date.now() >= options.deadline) {
return false
}
if (options.stopPty) {
// Why: destructive worktree cleanup must not let its cross-surface
// dedupe treat fire-and-forget controller.kill as physical exit.
// Why: the RPC deadline makes shutdown/list RPCs settle before the sweep
// deadline so a wedged daemon yields the accurate stop failure; no deadline
// (non-destructive) keeps the provider default RPC timeout.
if (options.deadline !== undefined) {
return (
this.ptyController?.stopAndWait?.(ptyId, {
try {
// Why: terminal.stop is a durable receipt; wait for provider exit so
// onPtyExit de-persists the tab before returning.
if (this.ptyController?.stopAndWait) {
// Why: the RPC deadline makes shutdown/list RPCs settle before the sweep deadline.
if (options.deadline !== undefined) {
return await this.ptyController.stopAndWait(ptyId, {
deadlineMs: teardownRpcDeadline(options.deadline)
}) ?? false
)
})
}
return await this.ptyController.stopAndWait(ptyId)
}
return this.ptyController?.stopAndWait?.(ptyId) ?? false
return Boolean(this.ptyController?.kill(ptyId))
} catch (error) {
// A worktree sweep is best-effort per PTY; continue after provider errors.
console.warn(`[runtime] failed to stop terminal ${ptyId}`, error)
return false
}
return Boolean(this.ptyController?.kill(ptyId))
}
const stopResult = options.stopPty
? await options.stopPty(ptyId, stop)
: { stopped: stop(), owner: true }
: { stopped: await stop(), owner: true }
if (stopResult.owner && stopResult.stopped) {
stopped += 1
}
@@ -4,6 +4,7 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/exec
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { OrcaRuntimeService } from './orca-runtime'
import { RuntimeWorkspaceSessionController } from './runtime-workspace-session-controller'
const CONNECTION_ID = 'conn-1'
const SSH_HOST_ID: ExecutionHostId = `ssh:${CONNECTION_ID}`
@@ -12,6 +13,14 @@ const SSH_WORKTREE_ID = `${SSH_REPO_ID}::/remote/worktree`
const SSH_PTY_LEFT = `ssh:${CONNECTION_ID}@@pty-left`
const SSH_PTY_RIGHT = `ssh:${CONNECTION_ID}@@pty-right`
function makeDeferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void
const promise = new Promise<void>((next) => {
resolve = next
})
return { promise, resolve }
}
const SSH_REPO = {
id: SSH_REPO_ID,
path: '/remote/worktree',
@@ -166,6 +175,310 @@ function syncSshSplit(runtime: OrcaRuntimeService, snapshot: RuntimeMobileSessio
}
describe('OrcaRuntimeService terminal retirement host partitioning (STA-3463)', () => {
it('routes a stale catalog owner to the unique persisted session owner', async () => {
const staleHostId: ExecutionHostId = 'runtime:stale-host'
const persistedTab = {
id: 'tab',
ptyId: 'persisted-pty',
worktreeId: SSH_WORKTREE_ID,
title: 'Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
const localSession: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
tabsByWorktree: { [SSH_WORKTREE_ID]: [persistedTab] },
terminalLayoutsByTabId: {
tab: {
root: { type: 'leaf', leafId: 'leaf' },
activeLeafId: 'leaf',
expandedLeafId: null,
ptyIdsByLeafId: { leaf: 'persisted-pty' }
}
}
}
const sessions = new Map<ExecutionHostId, WorkspaceSessionState>([
[LOCAL_EXECUTION_HOST_ID, localSession],
[
staleHostId,
{
...getDefaultWorkspaceSession(),
// A prior close can leave an empty retained row in the stale partition.
tabsByWorktree: { [SSH_WORKTREE_ID]: [] }
}
]
])
const store = {
getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }],
getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }),
getWorktreeMeta: () => undefined,
getAllWorktreeMeta: () => ({}),
getWorkspaceSessionHostIds: () => [...sessions.keys()],
getWorkspaceSession: (hostId?: ExecutionHostId) =>
sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(),
setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) =>
sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session),
flushOrThrow: vi.fn()
} as never
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => true),
getForegroundProcess: async () => null
})
runtime.registerPty('persisted-pty', SSH_WORKTREE_ID, null, {
tabId: 'tab',
leafId: 'leaf'
})
await expect(
runtime.closeMobileSessionTab(`id:${SSH_WORKTREE_ID}`, 'tab')
).resolves.toMatchObject({
closed: true
})
expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([])
expect(sessions.get(staleHostId)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([])
})
it('keeps a same-id local workspace out of an SSH workspace close', async () => {
const localTab = {
id: 'local-tab',
ptyId: 'local-pty',
worktreeId: SSH_WORKTREE_ID,
title: 'Local agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
const sessions = new Map<ExecutionHostId, WorkspaceSessionState>([
[
LOCAL_EXECUTION_HOST_ID,
{
...getDefaultWorkspaceSession(),
tabsByWorktree: { [SSH_WORKTREE_ID]: [localTab] },
terminalLayoutsByTabId: {
'local-tab': {
root: { type: 'leaf', leafId: 'leaf' },
activeLeafId: 'leaf',
expandedLeafId: null,
ptyIdsByLeafId: { leaf: 'local-pty' }
}
},
sleepingAgentSessionsByPaneKey: {
'local-tab:leaf': {
paneKey: 'local-tab:leaf',
tabId: 'local-tab',
worktreeId: SSH_WORKTREE_ID,
agent: 'codex',
providerSession: { key: 'session_id', id: 'resume-target' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
}
}
],
// The SSH copy of the same `repoId::path` currently has no terminals.
[SSH_HOST_ID, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }]
])
const store = {
getRepos: () => [SSH_REPO],
getRepo: (id: string) => (id === SSH_REPO_ID ? SSH_REPO : undefined),
getWorktreeMeta: () => ({ hostId: SSH_HOST_ID }),
getAllWorktreeMeta: () => ({ [SSH_WORKTREE_ID]: { hostId: SSH_HOST_ID } }),
setWorktreeMeta: vi.fn(),
getWorkspaceSessionHostIds: () => [...sessions.keys()],
getWorkspaceSession: (hostId?: ExecutionHostId) =>
sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(),
setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) =>
sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session),
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn()
} as never
const runtime = new OrcaRuntimeService(store)
const stopAndWait = vi.fn(async () => true)
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => true),
stopAndWait,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.registerPty('local-pty', SSH_WORKTREE_ID, null, { tabId: 'local-tab', leafId: 'leaf' })
await expect(runtime.closeTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`)).resolves.toEqual({
closed: 0,
stopped: 0,
retiredSurfaces: true
})
expect(stopAndWait).not.toHaveBeenCalled()
const local = sessions.get(LOCAL_EXECUTION_HOST_ID)!
expect(local.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([localTab])
expect(Object.keys(local.sleepingAgentSessionsByPaneKey ?? {})).toEqual(['local-tab:leaf'])
})
it('clears resume records from the partition that owned the tabs when the catalog owner rotated', async () => {
const staleHostId: ExecutionHostId = 'runtime:stale-host'
const sessions = new Map<ExecutionHostId, WorkspaceSessionState>([
[
LOCAL_EXECUTION_HOST_ID,
{
...makePersistedSshSession(),
terminalPtyIncarnationsByPaneKey: { 'tab:left': 'incarnation-1' }
}
],
[staleHostId, { ...getDefaultWorkspaceSession(), tabsByWorktree: { [SSH_WORKTREE_ID]: [] } }]
])
const store = {
getRepos: () => [{ ...SSH_REPO, executionHostId: staleHostId }],
getRepo: () => ({ ...SSH_REPO, executionHostId: staleHostId }),
getWorktreeMeta: () => ({}),
getAllWorktreeMeta: () => ({ [SSH_WORKTREE_ID]: {} }),
setWorktreeMeta: vi.fn(),
getWorkspaceSessionHostIds: () => [...sessions.keys()],
getWorkspaceSession: (hostId?: ExecutionHostId) =>
sessions.get(hostId ?? LOCAL_EXECUTION_HOST_ID) ?? getDefaultWorkspaceSession(),
setWorkspaceSession: (session: WorkspaceSessionState, hostId?: ExecutionHostId) =>
sessions.set(hostId ?? LOCAL_EXECUTION_HOST_ID, session),
flushOrThrow: vi.fn(),
persistPtyBinding: vi.fn()
} as never
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => true),
stopAndWait: vi.fn(async () => true),
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, null, { tabId: 'tab', leafId: 'left' })
await expect(runtime.closeTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`)).resolves.toMatchObject(
{ closed: 1 }
)
expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.tabsByWorktree[SSH_WORKTREE_ID]).toEqual([])
expect(sessions.get(LOCAL_EXECUTION_HOST_ID)?.terminalPtyIncarnationsByPaneKey).toEqual({})
})
it('hydrates the persisted owner when a folder host is absent from the host index', () => {
const folderWorktreeId = 'folder:folder-1'
const localSession = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[folderWorktreeId]: [
{
id: 'folder-tab',
ptyId: null,
worktreeId: folderWorktreeId,
title: 'Folder'
}
]
}
}
const folderHostId: ExecutionHostId = 'runtime:folder-host'
const store = {
getRepos: () => [],
getFolderWorkspaces: () => [
{
id: 'folder-1',
projectGroupId: 'project-1',
name: 'Folder',
folderPath: '/tmp/folder',
executionHostId: folderHostId
}
],
getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID],
getWorkspaceSession: (hostId?: ExecutionHostId) =>
hostId === LOCAL_EXECUTION_HOST_ID ? localSession : getDefaultWorkspaceSession()
} as never
const controller = new RuntimeWorkspaceSessionController({
getStore: () => store,
resolveFolderConnectionId: () => null,
hasRuntimeOwnedPtyCandidate: () => false
})
const targets = controller.getHydrationTargets(true)
expect(targets.get(folderWorktreeId)).toBe(localSession)
})
it('waits for provider retirement on a direct worktree stop', async () => {
const harness = partitionedStore()
const runtime = new OrcaRuntimeService(harness.store)
const physicalStop = makeDeferred()
const kill = vi.fn(() => true)
const stopAndWait = vi.fn(async () => {
await physicalStop.promise
return true
})
runtime.setPtyController({
write: () => true,
kill,
stopAndWait,
getForegroundProcess: async () => null
})
syncSshSplit(runtime, makeSshSnapshot())
runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, CONNECTION_ID, {
tabId: 'tab',
leafId: 'left'
})
const stopping = runtime.stopTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`, {
resolvedWorktreeId: SSH_WORKTREE_ID
})
await vi.waitFor(() => expect(stopAndWait).toHaveBeenCalledWith(SSH_PTY_LEFT))
let settled = false
void stopping.then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
expect(kill).not.toHaveBeenCalled()
physicalStop.resolve()
await expect(stopping).resolves.toEqual({ stopped: 2 })
})
it('continues stopping later PTYs when one provider retirement rejects', async () => {
const harness = partitionedStore()
const runtime = new OrcaRuntimeService(harness.store)
const stopAndWait = vi.fn(async (ptyId: string) => {
if (ptyId === SSH_PTY_LEFT) {
throw new Error('relay_unavailable')
}
return true
})
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => true),
stopAndWait,
getForegroundProcess: async () => null
})
syncSshSplit(runtime, makeSshSnapshot())
runtime.registerPty(SSH_PTY_LEFT, SSH_WORKTREE_ID, CONNECTION_ID, {
tabId: 'tab',
leafId: 'left'
})
runtime.registerPty(SSH_PTY_RIGHT, SSH_WORKTREE_ID, CONNECTION_ID, {
tabId: 'tab',
leafId: 'right'
})
await expect(
runtime.stopTerminalsForWorktree(`id:${SSH_WORKTREE_ID}`, {
resolvedWorktreeId: SSH_WORKTREE_ID
})
).resolves.toEqual({ stopped: 1 })
expect(stopAndWait).toHaveBeenNthCalledWith(1, SSH_PTY_LEFT)
expect(stopAndWait).toHaveBeenNthCalledWith(2, SSH_PTY_RIGHT)
})
it('retires an exited SSH pane from the SSH partition and leaves the local partition untouched', async () => {
const harness = partitionedStore()
const localBefore = harness.sessions.get(LOCAL_EXECUTION_HOST_ID)!
@@ -20,6 +20,241 @@ import {
} from '../orca-runtime-test-fixtures.spec'
describe('OrcaRuntimeService', () => {
it('durably closes every terminal in one workspace without touching a sibling', async () => {
const otherWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-b`
const session = makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId: 'pty-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: 'pinned-tab',
ptyId: 'pty-2',
worktreeId: TEST_WORKTREE_ID,
title: 'Pinned',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 2,
isPinned: true
}
],
[otherWorktreeId]: [
{
id: 'other-tab',
ptyId: 'pty-other',
worktreeId: otherWorktreeId,
title: 'Unrelated',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'pty-1' }),
'pinned-tab': makeHeadlessTerminalLayout({ pinned: 'pty-2' }),
'other-tab': makeHeadlessTerminalLayout({ other: 'pty-other' })
},
sleepingAgentSessionsByPaneKey: {
'host-tab:headless': {
paneKey: 'host-tab:headless',
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
agent: 'codex',
providerSession: { key: 'session_id', id: 'resume-target' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1
},
'other-tab:other': {
paneKey: 'other-tab:other',
tabId: 'other-tab',
worktreeId: otherWorktreeId,
agent: 'codex',
providerSession: { key: 'session_id', id: 'unrelated' },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1
}
},
terminalPtyIncarnationsByPaneKey: {
'host-tab:headless': 'incarnation-1',
'pinned-tab:pinned': 'incarnation-2',
'other-tab:other': 'incarnation-other'
}
})
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const stopAndWait = vi.fn(async (ptyId: string) => {
runtime.onPtyExit(ptyId, 0)
return true
})
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait,
getForegroundProcess: async () => null
})
runtime.registerPty('pty-1', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
runtime.registerPty('pty-2', TEST_WORKTREE_ID, null, {
tabId: 'pinned-tab',
leafId: 'pinned'
})
runtime.registerPty('pty-other', otherWorktreeId, null, {
tabId: 'other-tab',
leafId: 'other'
})
runtime.registerPty('pty-shadow', TEST_WORKTREE_ID, 'ssh-shadow')
await expect(runtime.closeTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)).resolves.toEqual({
closed: 2,
stopped: 2,
retiredSurfaces: true
})
expect(stopAndWait).toHaveBeenCalledTimes(2)
expect(stopAndWait).not.toHaveBeenCalledWith('pty-other')
expect(stopAndWait).not.toHaveBeenCalledWith('pty-shadow')
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().tabsByWorktree[otherWorktreeId]).toHaveLength(1)
expect(getSession().sleepingAgentSessionsByPaneKey).toEqual({
'other-tab:other': expect.objectContaining({ worktreeId: otherWorktreeId })
})
expect(getSession().terminalPtyIncarnationsByPaneKey).toEqual({
'other-tab:other': 'incarnation-other'
})
})
it('reports an unverified PTY instead of claiming workspace close stopped it', async () => {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.setPtyController({
write: () => true,
kill: () => false,
stopAndWait: async () => false,
getForegroundProcess: async () => null
})
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
await expect(runtime.closeTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)).resolves.toEqual({
closed: 1,
stopped: 0,
retiredSurfaces: true,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'the owning host did not confirm the PTY exit'
})
})
it('uses the worktree host when repository connection metadata is stale', async () => {
const targetConnectionId = 'conn-target'
const targetHostId = `ssh:${targetConnectionId}`
const targetPtyId = `${targetHostId}@@pty-target`
const session = makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId: targetPtyId,
worktreeId: TEST_WORKTREE_ID,
title: 'Persisted Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: targetPtyId })
}
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'conn-stale' }
runtimeStore.getRepos = () => [repo]
runtimeStore.getRepo = (id: string) => (id === TEST_REPO_ID ? repo : undefined)
runtimeStore.getWorktreeMeta = () => ({ hostId: targetHostId }) as never
runtimeStore.getAllWorktreeMeta = () =>
({ [TEST_WORKTREE_ID]: { hostId: targetHostId } }) as never
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const stopAndWait = vi.fn(async () => true)
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => false),
stopAndWait,
getForegroundProcess: async () => null
})
runtime.registerPty(targetPtyId, TEST_WORKTREE_ID, targetConnectionId, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
await expect(runtime.stopTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)).resolves.toEqual({
stopped: 1
})
expect(stopAndWait).toHaveBeenCalledWith(targetPtyId)
})
it('reports disconnected SSH PTYs as unverifiable during workspace close', async () => {
const ptyId = 'ssh:conn-1@@disconnected'
const session = makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: { [TEST_WORKTREE_ID]: [] },
terminalLayoutsByTabId: {}
})
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(session)
const repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'conn-1' }
runtimeStore.getRepos = () => [repo]
runtimeStore.getRepo = (id: string) => (id === TEST_REPO_ID ? repo : undefined)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => false),
stopAndWait: vi.fn(async () => false),
getForegroundProcess: async () => null
})
runtime['recordPtyWorktree'](ptyId, TEST_WORKTREE_ID, {
connected: false,
connectionId: 'conn-1',
tabId: 'host-tab'
})
runtime.markPtyLivenessUnverifiable(ptyId, 'relay disconnected')
await expect(
runtime.closeTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)
).resolves.toMatchObject({
closed: 0,
stopped: 0,
ptyStopVerdict: 'unverifiable',
ptyStopReason: 'relay disconnected'
})
})
it('shows worktree.ps working when the current pane supersedes a Claude agents OSC title', async () => {
const runtime = new OrcaRuntimeService(store)
@@ -76,10 +311,11 @@ describe('OrcaRuntimeService', () => {
it('stops by exact id when the selector no longer resolves', async () => {
const runtime = new OrcaRuntimeService(store)
const kill = vi.fn(() => true)
const stopAndWait = vi.fn(async () => true)
runtime.setPtyController({
write: () => true,
kill,
stopAndWait: vi.fn(async () => true),
stopAndWait,
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
@@ -90,7 +326,7 @@ describe('OrcaRuntimeService', () => {
resolvedWorktreeId: TEST_WORKTREE_ID
})
).resolves.toEqual({ stopped: 1 })
expect(kill).toHaveBeenCalledWith('pty-1')
expect(stopAndWait).toHaveBeenCalledWith('pty-1')
})
it('does not sweep a sibling workspace sharing the checkout dir of an exact id', async () => {
@@ -138,10 +374,11 @@ describe('OrcaRuntimeService', () => {
it('stops only the owning connection when one worktree id lives on two hosts', async () => {
const runtime = new OrcaRuntimeService(store)
const kill = vi.fn(() => true)
const stopAndWait = vi.fn(async () => true)
runtime.setPtyController({
write: () => true,
kill,
stopAndWait: vi.fn(async () => true),
stopAndWait,
getForegroundProcess: async () => null
})
syncSinglePty(runtime, null)
@@ -156,8 +393,8 @@ describe('OrcaRuntimeService', () => {
resolvedConnectionId: 'ssh-1'
})
).resolves.toEqual({ stopped: 1 })
expect(kill).toHaveBeenCalledWith('pty-ssh')
expect(kill).not.toHaveBeenCalledWith('pty-local')
expect(stopAndWait).toHaveBeenCalledWith('pty-ssh')
expect(stopAndWait).not.toHaveBeenCalledWith('pty-local')
})
it('awaits physical PTY stop when destructive teardown supplies shared dedupe', async () => {
@@ -175,7 +412,7 @@ describe('OrcaRuntimeService', () => {
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const stopPty = vi.fn(async (_ptyId: string, stop: () => boolean | Promise<boolean>) => ({
const stopPty = vi.fn(async (_ptyId: string, stop: () => Promise<boolean>) => ({
stopped: await stop(),
owner: true
}))
@@ -204,7 +441,7 @@ describe('OrcaRuntimeService', () => {
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const stopPty = vi.fn(async (_ptyId: string, stop: () => boolean | Promise<boolean>) => ({
const stopPty = vi.fn(async (_ptyId: string, stop: () => Promise<boolean>) => ({
stopped: await stop(),
owner: true
}))
@@ -24,6 +24,7 @@ const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [
['terminal.create', {}, false],
['terminal.split', { terminal: 'term' }, false],
['terminal.stop', { worktree: 'worktree' }, false],
['terminal.closeAll', { worktree: 'worktree' }, false],
['terminal.sleep', { worktree: 'worktree' }, false],
['terminal.stopExact', { worktree: 'worktree', expectedPtyIds: ['pty'] }, false],
['terminal.resizeForClient', { terminal: 'term', mode: 'restore', clientId: 'client' }, false],
@@ -64,11 +65,11 @@ async function invoke(name: string, params: unknown, runtime: Partial<OrcaRuntim
describe('terminal RPC manifest characterization', () => {
it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => {
expect(TERMINAL_METHODS).toHaveLength(33)
expect(TERMINAL_METHODS).toHaveLength(34)
expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual(
METHOD_CASES.map(([name, _params, stream]) => [name, stream])
)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(33)
expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(34)
for (const [name, params] of METHOD_CASES) {
expect(() => schemaFor(name).parse(params), name).not.toThrow()
}
@@ -7,6 +7,7 @@ import { withTerminalCloseAttribution } from '../../terminal-close-attribution'
import {
AgentTeamsPrepareLaunch,
AgentTeamsTmuxCompat,
TerminalCloseAll,
TerminalCreateParams,
TerminalFocus,
TerminalHandle,
@@ -94,6 +95,11 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [
params: TerminalStop,
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.closeAll',
params: TerminalCloseAll,
handler: async (params, { runtime }) => runtime.closeTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.sleep',
params: TerminalSleep,
@@ -183,6 +183,8 @@ export const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
export const TerminalCloseAll = TerminalStop
export const TerminalSleep = TerminalStop
export const TerminalStopExact = TerminalStop.extend({
@@ -119,7 +119,7 @@ export type RuntimeNotifier = {
closeTerminal(tabId: string, paneRuntimeId?: number): void
closeTerminalTab?(
tabId: string,
options?: { localPtyTeardownOwnedExternally?: boolean }
options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean }
): Promise<void>
sleepWorktree(worktreeId: string): void
// Why: a phone opening a worktree wakes its slept agents by asking the host
@@ -227,6 +227,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'agentTeams.tmuxCompat',
'terminal.clearBuffer',
'terminal.close',
'terminal.closeAll',
'terminal.closeTab',
'terminal.create',
'terminal.createAgentSession',
@@ -25,8 +25,7 @@ type RuntimeWorkspaceSessionDependencies = {
export class RuntimeWorkspaceSessionController {
constructor(private readonly deps: RuntimeWorkspaceSessionDependencies) {}
tryGetHostId(worktreeId: string): ExecutionHostId | null {
const store = this.deps.getStore()
private getPreferredHostId(worktreeId: string, store: RuntimeStore): ExecutionHostId | null {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const workspace = store
@@ -38,7 +37,11 @@ export class RuntimeWorkspaceSessionController {
// An explicit host is authoritative for folder workspaces. The connection
// id is only a legacy fallback for records written before host ids existed.
if (workspace.executionHostId != null) {
return parseExecutionHostId(workspace.executionHostId)?.id ?? null
const parsedHostId = parseExecutionHostId(workspace.executionHostId)?.id
if (!parsedHostId) {
return null
}
return parsedHostId
}
const connectionId = this.deps.resolveFolderConnectionId(workspace)
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
@@ -52,6 +55,47 @@ export class RuntimeWorkspaceSessionController {
: LOCAL_EXECUTION_HOST_ID
}
private resolveHostId(
worktreeId: string,
preferredHostId: ExecutionHostId,
persistedHostIds: readonly ExecutionHostId[],
getWorkspaceSession: (hostId: ExecutionHostId) => WorkspaceSessionState
): ExecutionHostId {
const hasPersistedTabs = (hostId: ExecutionHostId): boolean =>
(getWorkspaceSession(hostId).tabsByWorktree[worktreeId]?.length ?? 0) > 0
// Why: only runtime environment ids rotate across relay restarts. An empty SSH or
// local partition is the truth, and `repoId::path` repeats across hosts, so a
// same-id workspace elsewhere must never be adopted as this one's owner.
if (
parseExecutionHostId(preferredHostId)?.kind !== 'runtime' ||
hasPersistedTabs(preferredHostId)
) {
return preferredHostId
}
const persistedOwners = persistedHostIds.filter(
(hostId) => hostId !== preferredHostId && hasPersistedTabs(hostId)
)
return persistedOwners.length === 1 ? persistedOwners[0]! : preferredHostId
}
tryGetHostId(worktreeId: string): ExecutionHostId | null {
const store = this.deps.getStore()
if (!store) {
return null
}
const preferredHostId = this.getPreferredHostId(worktreeId, store)
if (!preferredHostId) {
return null
}
const persistedHostIds = store?.getWorkspaceSessionHostIds?.()
if (!store.getWorkspaceSession || !persistedHostIds) {
return preferredHostId
}
return this.resolveHostId(worktreeId, preferredHostId, persistedHostIds, (hostId) =>
store.getWorkspaceSession!(hostId)
)
}
getHostId(worktreeId: string): ExecutionHostId {
const hostId = this.tryGetHostId(worktreeId)
if (!hostId) {
@@ -91,6 +135,9 @@ export class RuntimeWorkspaceSessionController {
getHydrationTargets(includeAllPersistedWorktrees: boolean): Map<string, WorkspaceSessionState> {
const store = this.deps.getStore()
if (!store) {
return new Map()
}
const repos = store?.getRepos?.() ?? []
const repoHostIdByRepoId = new Map(
repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const)
@@ -118,19 +165,30 @@ export class RuntimeWorkspaceSessionController {
}
const targets = new Map<string, WorkspaceSessionState>()
const sessionsByHostId = new Map<ExecutionHostId, WorkspaceSessionState>()
for (const hostId of hostIds) {
const session = store?.getWorkspaceSession?.(hostId)
if (!session) {
continue
}
sessionsByHostId.set(hostId, session)
}
for (const [hostId, session] of sessionsByHostId) {
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
const scope = parseWorkspaceKey(worktreeId)
const ownerHostId =
const catalogOwnerHostId =
scope?.type === 'folder'
? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null)
: (repoHostIdByRepoId.get(
getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId)
) ?? LOCAL_EXECUTION_HOST_ID)
const ownerHostId = this.resolveHostId(
worktreeId,
catalogOwnerHostId ?? LOCAL_EXECUTION_HOST_ID,
[...sessionsByHostId.keys()],
(candidateHostId) =>
sessionsByHostId.get(candidateHostId) ?? store.getWorkspaceSession!(candidateHostId)
)
if (
ownerHostId === hostId &&
(includeAllPersistedWorktrees ||
@@ -0,0 +1,18 @@
export type WorktreePtyHostFence = {
resolvedConnectionId?: string | null
resolvedRuntimeEnvironmentId?: string
}
export function worktreePtyBelongsToHost(
ptyId: string,
connectionId: string | null | undefined,
fence: WorktreePtyHostFence
): boolean {
if (fence.resolvedRuntimeEnvironmentId !== undefined) {
return ptyId.startsWith(`remote:${encodeURIComponent(fence.resolvedRuntimeEnvironmentId)}@@`)
}
return (
fence.resolvedConnectionId === undefined ||
(connectionId ?? null) === fence.resolvedConnectionId
)
}
@@ -0,0 +1,33 @@
import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict'
import type { RuntimeWorktreeTerminalCloseResult } from '../../shared/runtime-types'
type WorktreePtyStopVerdict = Pick<
RuntimeWorktreeTerminalCloseResult,
'ptyStopVerdict' | 'ptyStopReason'
>
export function summarizeWorktreePtyStopVerdict(
ptyIds: Iterable<string>,
getVerdict: (ptyId: string) => PtyLivenessVerdict | null,
isConnected: (ptyId: string) => boolean
): WorktreePtyStopVerdict {
let ptyStopVerdict: 'live' | 'unverifiable' | undefined
let ptyStopReason: string | undefined
for (const ptyId of ptyIds) {
const verdict = getVerdict(ptyId)
if (verdict?.status === 'live') {
return { ptyStopVerdict: 'live' }
}
if (verdict?.status === 'unverifiable') {
ptyStopVerdict = 'unverifiable'
ptyStopReason ??= verdict.reason
} else if (isConnected(ptyId)) {
ptyStopVerdict ??= 'unverifiable'
ptyStopReason ??= 'the owning host did not confirm the PTY exit'
}
}
return {
...(ptyStopVerdict ? { ptyStopVerdict } : {}),
...(ptyStopReason ? { ptyStopReason } : {})
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ export async function killAllProcessesForWorktree(
const stopAttempts = new Map<string, Promise<boolean>>()
const stopPty = (
ptyId: string,
stop: () => boolean | Promise<boolean>
stop: () => Promise<boolean>
): Promise<{ stopped: boolean; owner: boolean }> => {
const previous = stopAttempts.get(ptyId) ?? Promise.resolve(false)
const current = previous
@@ -27,16 +27,19 @@ describe('requestTerminalTabCloseFromRenderer', () => {
const otherWebContents = {}
const mainWindow = { isDestroyed: () => false, webContents }
const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-1', {
localPtyTeardownOwnedExternally: true
localPtyTeardownOwnedExternally: true,
force: true
})
const request = webContents.send.mock.calls[0]?.[1] as {
requestId: string
tabId: string
localPtyTeardownOwnedExternally?: boolean
force?: boolean
}
expect(request.tabId).toBe('tab-1')
expect(request.localPtyTeardownOwnedExternally).toBe(true)
expect(request.force).toBe(true)
ipcEmitter.emit(
'ui:terminalTabCloseResponse',
{ sender: otherWebContents },
@@ -12,7 +12,7 @@ const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000
export async function requestTerminalTabCloseFromRenderer(
mainWindow: BrowserWindow,
tabId: string,
options: { localPtyTeardownOwnedExternally?: boolean } = {}
options: { localPtyTeardownOwnedExternally?: boolean; force?: boolean } = {}
): Promise<void> {
if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) {
throw new Error('renderer_unavailable')
@@ -21,6 +21,7 @@ export type TerminalTabCloseRequestListener = (data: {
requestId: string
tabId: string
localPtyTeardownOwnedExternally?: boolean
force?: boolean
}) => void
export async function useIpcEventsForCloseRouting({
@@ -69,7 +69,7 @@ export function registerMobileAndTerminalCloseIpcBridge(
if (window.api.ui.onTerminalTabCloseRequest) {
unsubs.push(
window.api.ui.onTerminalTabCloseRequest(
({ requestId, tabId, localPtyTeardownOwnedExternally }) => {
({ requestId, tabId, localPtyTeardownOwnedExternally, force }) => {
let responded = false
const respond = (error?: string): void => {
if (responded) {
@@ -80,6 +80,7 @@ export function registerMobileAndTerminalCloseIpcBridge(
}
closeTerminalTab(tabId, {
rejectPinned: true,
...(force ? { force: true } : {}),
...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}),
onCancel: () => respond('terminal_tab_pinned'),
onClosed: () => {
@@ -123,7 +123,8 @@ describe('useIpcEvents browser tab close routing', () => {
listenerRef.current?.({
requestId: 'close-1',
tabId: 'terminal-1',
localPtyTeardownOwnedExternally: true
localPtyTeardownOwnedExternally: true,
force: true
})
await Promise.resolve()
@@ -131,6 +132,7 @@ describe('useIpcEvents browser tab close routing', () => {
'terminal-1',
expect.objectContaining({
rejectPinned: true,
force: true,
localPtyTeardownOwnedExternally: true
})
)
+8
View File
@@ -159,6 +159,14 @@ export type RuntimeWorktreeTerminalSleepResult = {
}
)
export type RuntimeWorktreeTerminalCloseResult = {
closed: number
stopped: number
retiredSurfaces: true
ptyStopVerdict?: 'live' | 'unverifiable'
ptyStopReason?: string
}
export type RuntimeTerminalInteractiveWaitSource = 'hook' | 'prompt-text' | 'title'
export type RuntimeTerminalInteractiveWait = {
+1
View File
@@ -177,6 +177,7 @@ export type {
RuntimeTerminalWait,
RuntimeTerminalWaitBlockedReason,
RuntimeTerminalWaitCondition,
RuntimeWorktreeTerminalCloseResult,
RuntimeWorktreeTerminalSleepResult
} from './runtime-terminal-contracts'
export type {
+1
View File
@@ -2,6 +2,7 @@ export type TerminalTabCloseRequest = {
requestId: string
tabId: string
localPtyTeardownOwnedExternally?: boolean
force?: boolean
}
export type TerminalTabCloseResponse = {
@@ -151,14 +151,18 @@ function deriveActiveSurface(
export function closeTerminalTabInWorkspaceSession(
session: WorkspaceSessionState,
worktreeId: string,
tabId: string
tabId: string,
options: { force?: boolean } = {}
): WorkspaceSessionTerminalTabCloseResult {
const terminalRow = session.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
const unifiedTerminalTabs = findUnifiedTerminalTabs(session, worktreeId, tabId)
if (!terminalRow && unifiedTerminalTabs.length === 0) {
return { session, ptyIdsToKill: [], closed: false, pinned: false }
}
if (terminalRow?.isPinned || unifiedTerminalTabs.some((tab) => tab.isPinned)) {
if (
options.force !== true &&
(terminalRow?.isPinned || unifiedTerminalTabs.some((tab) => tab.isPinned))
) {
return { session, ptyIdsToKill: [], closed: false, pinned: true }
}