mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): make close and handles incarnation-stable (STA-4327) (#14590)
This commit is contained in:
@@ -2323,9 +2323,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts",
|
||||
"assertions": [
|
||||
"explicit cleanup cannot close a page owned by another worktree"
|
||||
]
|
||||
"assertions": ["explicit cleanup cannot close a page owned by another worktree"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/browser.test.ts",
|
||||
@@ -3173,20 +3171,28 @@
|
||||
"providers": ["local", "daemon", "ssh", "runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/8628",
|
||||
"https://github.com/stablyai/orca/pull/8706"
|
||||
"https://github.com/stablyai/orca/pull/8706",
|
||||
"https://github.com/stablyai/orca/issues/14524"
|
||||
],
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; 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 the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.",
|
||||
"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 does not acknowledge before durable tab retirement, and 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 kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall 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/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",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-tab-close-restart-persistence.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
|
||||
"pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orca-runtime-terminal-close-continuity.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",
|
||||
"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",
|
||||
@@ -3205,10 +3211,41 @@
|
||||
"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",
|
||||
"tests/e2e/terminal-tab-close-restart-persistence.spec.ts",
|
||||
"tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"tests/e2e/agent-descendant-process-kill.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"assertions": [
|
||||
"final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout",
|
||||
"post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive",
|
||||
"renderer reload with the same provider-attested PTY incarnation preserves the exact handle",
|
||||
"same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale",
|
||||
"hard graph loss and preallocated-handle supersession make the retained renderer handle deterministically stale"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-tab-close-restart-persistence.spec.ts",
|
||||
"assertions": [
|
||||
"a durability-acknowledged whole-tab close leaves zero terminal tabs and provider sessions after restart"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
|
||||
"assertions": [
|
||||
"git worktree and folder workspace hydration preserve an explicit empty terminal row"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal/initial-terminal.test.ts",
|
||||
"assertions": ["preserves an explicitly persisted empty terminal list"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/worktree-activation-default-tabs.test.ts",
|
||||
"assertions": ["does not recreate a terminal after an explicit empty state was persisted"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts",
|
||||
"assertions": [
|
||||
@@ -3296,6 +3333,42 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 78,
|
||||
"summary": "The cycle-safe, target-indexed, bounded-fanout review head passed from a cold full build; the live detached-pgid descendant test body completed in 4.8 seconds."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.38,
|
||||
"summary": "Six owner-level checks proved durable final-pane retirement, same-incarnation handle and waiter continuity, and deterministic stale fencing at replacement or hard lifecycle boundaries."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-15",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.49,
|
||||
"summary": "Fifteen checks passed after merging latest main, including post-ack session-loss cleanup and the inverse non-destructive pre-ack error proof."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "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",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.71,
|
||||
"summary": "Forty-nine renderer checks preserved explicit empty terminal state for git and folder workspaces and suppressed initial/default recreation only for previously persisted workspace rows."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/terminal-tab-close-restart-persistence.spec.ts --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18.8,
|
||||
"summary": "A fresh isolated Electron profile closed a split terminal tab through the runtime CLI and kept both renderer and runtime terminal inventories empty after restart."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -3307,8 +3380,8 @@
|
||||
"evidence": "The Electron gate passed three times locally, including the final review-fix head through the registered fresh-build command; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached."
|
||||
"status": "complete",
|
||||
"evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -5971,7 +6044,9 @@
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts",
|
||||
"assertions": ["binary first paint remains valid when only retained history was truncated"]
|
||||
"assertions": [
|
||||
"binary first paint remains valid when only retained history was truncated"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts",
|
||||
@@ -6624,21 +6699,15 @@
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ipc/pty-renderer-inflight-credit.test.ts",
|
||||
"assertions": [
|
||||
"total renderer in-flight output is capped across many PTYs"
|
||||
]
|
||||
"assertions": ["total renderer in-flight output is capped across many PTYs"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-global-renderer-credit.test.ts",
|
||||
"assertions": [
|
||||
"active PTY pending output is prioritized during renderer backpressure"
|
||||
]
|
||||
"assertions": ["active PTY pending output is prioritized during renderer backpressure"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-output-batching-drain.test.ts",
|
||||
"assertions": [
|
||||
"combined pending output exceeding the interactive size limit is batched"
|
||||
]
|
||||
"assertions": ["combined pending output exceeding the interactive size limit is batched"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-ipc-producer-flow-control.test.ts",
|
||||
@@ -7188,9 +7257,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-persisted-incarnation-repair.test.ts",
|
||||
"assertions": [
|
||||
"unverified ownership retains the binding and creates no fresh PTY"
|
||||
]
|
||||
"assertions": ["unverified ownership retains the binding and creates no fresh PTY"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -10413,9 +10480,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts",
|
||||
"assertions": [
|
||||
"a scrolled-up viewport is preserved after hidden-backlog snapshot replay"
|
||||
]
|
||||
"assertions": ["a scrolled-up viewport is preserved after hidden-backlog snapshot replay"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -11233,9 +11298,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-atlas-recovery.test.ts",
|
||||
"assertions": [
|
||||
"keeps hidden synchronized output off the global atlas recovery path"
|
||||
]
|
||||
"assertions": ["keeps hidden synchronized output off the global atlas recovery path"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection-renderer-risk-repaint.test.ts",
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const REPO_ID = 'repo-close-continuity'
|
||||
const WORKTREE_PATH = '/tmp/terminal-close-continuity'
|
||||
const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
|
||||
const TAB_ID = 'tab-close-continuity'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const PTY_ID = 'pty-close-continuity'
|
||||
const RUNTIME_OWNED_PTY_ID = 'serve-close-continuity'
|
||||
const SIBLING_PTY_ID = 'pty-close-continuity-sibling'
|
||||
const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444'
|
||||
|
||||
function makeSession(ptyId = PTY_ID): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: TAB_ID,
|
||||
ptyId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Fixture shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
options: { ptyId?: string; publishMobileSurface?: boolean; registerPtyBacked?: boolean } = {}
|
||||
) {
|
||||
const ptyId = options.ptyId ?? PTY_ID
|
||||
let session = makeSession(ptyId)
|
||||
let sessionAvailable = true
|
||||
let incarnationId = INCARNATION_ID
|
||||
let includeSiblingPty = false
|
||||
const repo = {
|
||||
id: REPO_ID,
|
||||
path: WORKTREE_PATH,
|
||||
displayName: 'close-continuity',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1
|
||||
}
|
||||
const store = {
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === REPO_ID ? repo : undefined),
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined,
|
||||
getSettings: () => ({ workspaceDir: '/tmp/workspaces' }),
|
||||
getProjects: () => [],
|
||||
getWorkspaceSession: () => (sessionAvailable ? session : undefined),
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
}
|
||||
}
|
||||
const acknowledged = makeDeferred()
|
||||
let closeTerminalTabError: Error | null = null
|
||||
const closeTerminal = vi.fn()
|
||||
const closeTerminalTab = vi.fn(() =>
|
||||
closeTerminalTabError ? Promise.reject(closeTerminalTabError) : acknowledged.promise
|
||||
)
|
||||
const kill = vi.fn(() => true)
|
||||
let verifiedStopResult: boolean | Error = false
|
||||
const stopAndWait = vi.fn(async () => {
|
||||
if (verifiedStopResult instanceof Error) {
|
||||
throw verifiedStopResult
|
||||
}
|
||||
return verifiedStopResult
|
||||
})
|
||||
const listProcesses = vi.fn(async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
incarnationId,
|
||||
cwd: WORKTREE_PATH,
|
||||
title: 'Fixture shell'
|
||||
},
|
||||
...(includeSiblingPty
|
||||
? [
|
||||
{
|
||||
id: SIBLING_PTY_ID,
|
||||
incarnationId: SIBLING_INCARNATION_ID,
|
||||
cwd: WORKTREE_PATH,
|
||||
title: 'Fixture sibling shell'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
stopAndWait,
|
||||
listProcesses,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
const syncFixtureGraph = () =>
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Fixture shell',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: LEAF_ID }
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 7,
|
||||
ptyId
|
||||
}
|
||||
],
|
||||
...(options.publishMobileSurface
|
||||
? {
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer:close-continuity',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: `${TAB_ID}::${LEAF_ID}`,
|
||||
activeTabType: 'terminal' as const,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal' as const,
|
||||
id: `${TAB_ID}::${LEAF_ID}`,
|
||||
parentTabId: TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
ptyId,
|
||||
title: 'Fixture shell',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
})
|
||||
const syncEmptyGraph = () => runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
const syncFixtureTabWithoutLeaf = () =>
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Fixture shell',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: LEAF_ID }
|
||||
}
|
||||
],
|
||||
leaves: []
|
||||
})
|
||||
const syncSplitFixtureGraph = () => {
|
||||
includeSiblingPty = true
|
||||
session = {
|
||||
...session,
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SIBLING_LEAF_ID }
|
||||
},
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[LEAF_ID]: ptyId,
|
||||
[SIBLING_LEAF_ID]: SIBLING_PTY_ID
|
||||
}
|
||||
}
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
...session.terminalPtyIncarnationsByPaneKey,
|
||||
[makePaneKey(TAB_ID, SIBLING_LEAF_ID)]: SIBLING_INCARNATION_ID
|
||||
}
|
||||
}
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Fixture shell',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: session.terminalLayoutsByTabId[TAB_ID]!.root
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 7,
|
||||
ptyId
|
||||
},
|
||||
{
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: SIBLING_LEAF_ID,
|
||||
paneRuntimeId: 8,
|
||||
ptyId: SIBLING_PTY_ID
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (options.registerPtyBacked) {
|
||||
runtime.registerPty(ptyId, WORKTREE_ID, null, {
|
||||
tabId: TAB_ID,
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: INCARNATION_ID
|
||||
})
|
||||
}
|
||||
syncFixtureGraph()
|
||||
return {
|
||||
runtime,
|
||||
acknowledged,
|
||||
closeTerminal,
|
||||
closeTerminalTab,
|
||||
kill,
|
||||
stopAndWait,
|
||||
syncEmptyGraph,
|
||||
syncFixtureGraph,
|
||||
syncFixtureTabWithoutLeaf,
|
||||
syncSplitFixtureGraph,
|
||||
getSession: () => session,
|
||||
makeSessionUnavailable: () => {
|
||||
sessionAvailable = false
|
||||
},
|
||||
retirePersistedTab: () => {
|
||||
session = {
|
||||
...session,
|
||||
tabsByWorktree: { ...session.tabsByWorktree, [WORKTREE_ID]: [] },
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalPtyIncarnationsByPaneKey: {}
|
||||
}
|
||||
},
|
||||
rejectTerminalTabClose: (error: Error) => {
|
||||
closeTerminalTabError = error
|
||||
},
|
||||
setVerifiedStopResult: (result: boolean | Error) => {
|
||||
verifiedStopResult = result
|
||||
},
|
||||
replaceIncarnation: (next: string) => {
|
||||
incarnationId = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPtyBackedPublishedSurfaceHarness() {
|
||||
const harness = createHarness({
|
||||
ptyId: RUNTIME_OWNED_PTY_ID,
|
||||
publishMobileSurface: true,
|
||||
registerPtyBacked: true
|
||||
})
|
||||
harness.syncFixtureTabWithoutLeaf()
|
||||
return harness
|
||||
}
|
||||
|
||||
describe('terminal close and handle incarnation continuity', () => {
|
||||
it('does not acknowledge final-pane close before durable tab retirement', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
let settled = false
|
||||
const closing = harness.runtime.closeTerminal(handle).finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.closeTerminalTab).toHaveBeenCalledWith(TAB_ID, {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
)
|
||||
expect(settled).toBe(false)
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
|
||||
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: true })
|
||||
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(harness.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
})
|
||||
|
||||
it('does not kill the final PTY when durable tab retirement is rejected', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.rejectTerminalTabClose(new Error('terminal_tab_pinned'))
|
||||
|
||||
await expect(harness.runtime.closeTerminal(handle)).rejects.toThrow('terminal_tab_pinned')
|
||||
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('kills every live tab PTY after retirement when the renderer graph is stale', async () => {
|
||||
const harness = createHarness()
|
||||
harness.syncSplitFixtureGraph()
|
||||
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
|
||||
(candidate) => candidate.ptyId === PTY_ID
|
||||
)!
|
||||
harness.syncFixtureGraph()
|
||||
|
||||
const closing = harness.runtime.closeTerminal(terminal.handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
|
||||
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(harness.kill).toHaveBeenCalledWith(SIBLING_PTY_ID)
|
||||
})
|
||||
|
||||
it('uses verified teardown after retirement before falling back to kill', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.setVerifiedStopResult(true)
|
||||
|
||||
const closing = harness.runtime.closeTerminal(handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
expect(harness.stopAndWait).not.toHaveBeenCalled()
|
||||
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
|
||||
deadlineMs: expect.any(Number)
|
||||
})
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to kill when verified teardown rejects after retirement', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.setVerifiedStopResult(new Error('provider_unavailable'))
|
||||
|
||||
const closing = harness.runtime.closeTerminal(handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
|
||||
deadlineMs: expect.any(Number)
|
||||
})
|
||||
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
|
||||
})
|
||||
|
||||
it('finishes PTY teardown when the session store disappears after retirement', async () => {
|
||||
const harness = createPtyBackedPublishedSurfaceHarness()
|
||||
const closeMobileSessionTab = vi.spyOn(harness.runtime, 'closeMobileSessionTab')
|
||||
const [terminal] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(terminal).toMatchObject({ ptyId: RUNTIME_OWNED_PTY_ID })
|
||||
const { handle } = terminal
|
||||
|
||||
const closing = harness.runtime.closeTerminal(handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
expect(closeMobileSessionTab).toHaveBeenCalled()
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
|
||||
harness.retirePersistedTab()
|
||||
harness.makeSessionUnavailable()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: true })
|
||||
expect(harness.closeTerminal).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID, {
|
||||
deadlineMs: expect.any(Number)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not tear down a published PTY when retirement fails before acknowledgement', async () => {
|
||||
const harness = createPtyBackedPublishedSurfaceHarness()
|
||||
harness.rejectTerminalTabClose(new Error('terminal_tab_pinned'))
|
||||
const [terminal] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
|
||||
await expect(harness.runtime.closeTerminal(terminal.handle)).rejects.toThrow(
|
||||
'terminal_tab_pinned'
|
||||
)
|
||||
expect(harness.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.stopAndWait).not.toHaveBeenCalled()
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a handle valid when renderer reload preserves the PTY incarnation', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
|
||||
harness.runtime.markRendererReloading(1)
|
||||
harness.syncFixtureGraph()
|
||||
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after).toMatchObject({ handle: before.handle, incarnationId: INCARNATION_ID })
|
||||
await expect(harness.runtime.readTerminal(before.handle)).resolves.toMatchObject({
|
||||
handle: before.handle,
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a handle through an intermediate empty reload graph', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;\u280b Working on task\x07output\n', 100)
|
||||
const waiting = harness.runtime.waitForTerminal(before.handle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 1_000
|
||||
})
|
||||
|
||||
harness.runtime.markRendererReloading(1)
|
||||
harness.syncEmptyGraph()
|
||||
harness.syncFixtureGraph()
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;\u2733 Task complete\x07done\n', 200)
|
||||
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after.handle).toBe(before.handle)
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
handle: before.handle,
|
||||
condition: 'tui-idle',
|
||||
satisfied: true
|
||||
})
|
||||
await expect(harness.runtime.readTerminal(before.handle)).resolves.toMatchObject({
|
||||
handle: before.handle,
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a retained handle waiter when idle arrives during renderer reload', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;\u280b Working on task\x07output\n', 100)
|
||||
const waiting = harness.runtime.waitForTerminal(before.handle, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 1_000
|
||||
})
|
||||
|
||||
harness.runtime.markRendererReloading(1)
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;\u2733 Task complete\x07done\n', 200)
|
||||
|
||||
await expect(waiting).resolves.toMatchObject({
|
||||
handle: before.handle,
|
||||
condition: 'tui-idle',
|
||||
satisfied: true
|
||||
})
|
||||
harness.syncFixtureGraph()
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after.handle).toBe(before.handle)
|
||||
})
|
||||
|
||||
it('stales the old handle when the same PTY id names a new incarnation', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
|
||||
harness.runtime.markRendererReloading(1)
|
||||
harness.replaceIncarnation('33333333-3333-4333-8333-333333333333')
|
||||
harness.syncFixtureGraph()
|
||||
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after.handle).not.toBe(before.handle)
|
||||
expect(after.incarnationId).toBe('33333333-3333-4333-8333-333333333333')
|
||||
await expect(harness.runtime.readTerminal(before.handle)).rejects.toThrow(
|
||||
'terminal_handle_stale'
|
||||
)
|
||||
})
|
||||
|
||||
it('stales a retained handle after the renderer graph becomes unavailable', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
|
||||
harness.runtime.markGraphUnavailable(1)
|
||||
harness.runtime.attachWindow(1)
|
||||
harness.syncFixtureGraph()
|
||||
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after.handle).not.toBe(before.handle)
|
||||
await expect(harness.runtime.readTerminal(before.handle)).rejects.toThrow(
|
||||
'terminal_handle_stale'
|
||||
)
|
||||
})
|
||||
|
||||
it('stales a renderer handle superseded by a preallocated handle', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
const preallocated = 'term_preallocated-close-continuity'
|
||||
|
||||
harness.runtime.registerPreAllocatedHandleForPty(PTY_ID, preallocated)
|
||||
await expect(harness.runtime.readTerminal(before.handle)).rejects.toThrow(
|
||||
'terminal_handle_stale'
|
||||
)
|
||||
await expect(harness.runtime.readTerminal(preallocated)).resolves.toMatchObject({
|
||||
handle: preallocated,
|
||||
status: 'running'
|
||||
})
|
||||
harness.syncFixtureGraph()
|
||||
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after.handle).toBe(preallocated)
|
||||
await expect(harness.runtime.readTerminal(preallocated)).resolves.toMatchObject({
|
||||
handle: preallocated,
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a renderer handle when the controller adopts that same handle', async () => {
|
||||
const harness = createHarness()
|
||||
const [before] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
|
||||
harness.runtime.registerPreAllocatedHandleForPty(PTY_ID, before.handle)
|
||||
harness.syncFixtureGraph()
|
||||
|
||||
const [after] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(after.handle).toBe(before.handle)
|
||||
await expect(harness.runtime.readTerminal(before.handle)).resolves.toMatchObject({
|
||||
handle: before.handle,
|
||||
status: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a preallocated-handle waiter when its PTY is invalidated during reload', async () => {
|
||||
const harness = createHarness()
|
||||
const preallocated = 'term_preallocated-reload-invalidation'
|
||||
harness.runtime.registerPreAllocatedHandleForPty(PTY_ID, preallocated)
|
||||
harness.syncFixtureGraph()
|
||||
harness.runtime.onPtyData(PTY_ID, '\x1b]0;\u280b Working on task\x07output\n', 100)
|
||||
const waiting = harness.runtime.waitForTerminal(preallocated, {
|
||||
condition: 'tui-idle',
|
||||
timeoutMs: 100
|
||||
})
|
||||
|
||||
harness.runtime.markRendererReloading(1)
|
||||
harness.runtime['invalidateAllHandlesForPty'](PTY_ID)
|
||||
|
||||
await expect(waiting).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
})
|
||||
@@ -31057,7 +31057,9 @@ describe('OrcaRuntimeService', () => {
|
||||
ptyKilled: true
|
||||
})
|
||||
|
||||
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab')
|
||||
expect(closeTerminalTab).toHaveBeenCalledWith('host-tab', {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
expect(closeTerminal).toHaveBeenCalledWith('host-tab')
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined()
|
||||
|
||||
@@ -1886,6 +1886,7 @@ const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
|
||||
// Why: the split already failed; the caller waits on this teardown only to learn whether the
|
||||
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
|
||||
const REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS = 2_000
|
||||
const EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS = 2_000
|
||||
const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000
|
||||
const SSH_PANE_RECOVERY_GRACE_MS = 30_000
|
||||
// Why: long enough that a keystroke burst to a proven-dead leaf probes once,
|
||||
@@ -2018,7 +2019,10 @@ type RuntimeNotifier = {
|
||||
content: string
|
||||
): Promise<RuntimeMarkdownSaveTabResult>
|
||||
closeTerminal(tabId: string, paneRuntimeId?: number): void
|
||||
closeTerminalTab?(tabId: string): Promise<void>
|
||||
closeTerminalTab?(
|
||||
tabId: string,
|
||||
options?: { localPtyTeardownOwnedExternally?: boolean }
|
||||
): Promise<void>
|
||||
sleepWorktree(worktreeId: string): void
|
||||
// Why: a phone opening a worktree wakes its slept agents by asking the host
|
||||
// renderer to run its own navigation-free wake (experimental agent sleep);
|
||||
@@ -2056,6 +2060,12 @@ type TerminalHandleRecord = {
|
||||
ptyGeneration: number
|
||||
}
|
||||
|
||||
type PtyIncarnationHandleRecord = {
|
||||
handle: string
|
||||
incarnationId: string
|
||||
leafKey: string
|
||||
}
|
||||
|
||||
export type OrchestrationCompatibilityTerminalAuthority = {
|
||||
runtimeId: string
|
||||
terminalHandle: string
|
||||
@@ -2894,6 +2904,7 @@ export class OrcaRuntimeService {
|
||||
private handles = new Map<string, TerminalHandleRecord>()
|
||||
private handleByLeafKey = new Map<string, string>()
|
||||
private handleByPtyId = new Map<string, string>()
|
||||
private handleByPtyIncarnation = new Map<string, PtyIncarnationHandleRecord>()
|
||||
private readonly mailPointerRepointScheduler = new MailPointerRepointScheduler((handle) =>
|
||||
this.repointPendingMessagesForHandle(handle)
|
||||
)
|
||||
@@ -5821,14 +5832,19 @@ export class OrcaRuntimeService {
|
||||
for (const oldLeafKey of this.leaves.keys()) {
|
||||
if (!nextLeaves.has(oldLeafKey)) {
|
||||
const oldLeaf = this.leaves.get(oldLeafKey)
|
||||
const retainedIncarnation = oldLeaf?.ptyId
|
||||
? this.handleByPtyIncarnation.get(oldLeaf.ptyId)
|
||||
: undefined
|
||||
if (
|
||||
preserveLivePtysDuringReload &&
|
||||
oldLeaf?.ptyId &&
|
||||
this.handleByPtyId.has(oldLeaf.ptyId) &&
|
||||
(this.handleByPtyId.has(oldLeaf.ptyId) ||
|
||||
(retainedIncarnation &&
|
||||
retainedIncarnation.incarnationId ===
|
||||
this.ptysById.get(oldLeaf.ptyId)?.incarnationId)) &&
|
||||
!nextPtyIds.has(oldLeaf.ptyId)
|
||||
) {
|
||||
// Why: a CLI-created agent keeps using its exported handle even if
|
||||
// the reloaded renderer has not rebound the pane yet.
|
||||
// Why: the first reload graph can precede pane rebinding; the live PTY incarnation still owns its handle.
|
||||
nextLeaves.set(oldLeafKey, oldLeaf)
|
||||
nextPtyIds.add(oldLeaf.ptyId)
|
||||
} else if (oldLeaf?.ptyId && nextPtyIds.has(oldLeaf.ptyId)) {
|
||||
@@ -5841,7 +5857,11 @@ export class OrcaRuntimeService {
|
||||
// no next owner — invalidate it so in-flight CLI waiters fail fast
|
||||
// instead of hanging on a dead leaf.
|
||||
const oldHandle = this.handleByLeafKey.get(oldLeafKey)
|
||||
if (oldHandle !== undefined && oldHandle === this.handleByPtyId.get(oldLeaf.ptyId)) {
|
||||
const incarnationHandle = retainedIncarnation?.handle
|
||||
if (
|
||||
oldHandle !== undefined &&
|
||||
(oldHandle === this.handleByPtyId.get(oldLeaf.ptyId) || oldHandle === incarnationHandle)
|
||||
) {
|
||||
this.handleByLeafKey.delete(oldLeafKey)
|
||||
} else {
|
||||
this.invalidateLeafHandle(oldLeafKey)
|
||||
@@ -5863,6 +5883,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
this.leaves = nextLeaves
|
||||
this.rebuildLeafPtyIndex()
|
||||
this.reconcilePtyIncarnationHandles()
|
||||
// Why: the emitted client payload is a function of the stored snapshot AND
|
||||
// the tab/leaf graph (handles/titles/connected resolve from leaf state), so
|
||||
// a graph-only change — e.g. a restored leaf binding its ptyId while the
|
||||
@@ -8020,6 +8041,7 @@ export class OrcaRuntimeService {
|
||||
expectedPublicationEpoch?: string
|
||||
expectedTerminalHandle?: string
|
||||
clientNavigationId?: string
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionTabCloseResult> {
|
||||
const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null
|
||||
@@ -8172,7 +8194,11 @@ export class OrcaRuntimeService {
|
||||
? this.rendererPublicationThrottle.acquire(win.webContents)
|
||||
: () => {}
|
||||
try {
|
||||
await this.notifier.closeTerminalTab(tab.parentTabId)
|
||||
await (options.localPtyTeardownOwnedExternally
|
||||
? this.notifier.closeTerminalTab(tab.parentTabId, {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
: this.notifier.closeTerminalTab(tab.parentTabId))
|
||||
} finally {
|
||||
releasePublicationThrottle()
|
||||
}
|
||||
@@ -9476,6 +9502,12 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
registerPreAllocatedHandleForPty(ptyId: string, handle: string): void {
|
||||
const retained = this.handleByPtyIncarnation.get(ptyId)
|
||||
if (retained?.handle === handle) {
|
||||
this.handleByPtyIncarnation.delete(ptyId)
|
||||
} else {
|
||||
this.invalidatePtyIncarnationHandle(ptyId)
|
||||
}
|
||||
this.handleByPtyId.set(ptyId, handle)
|
||||
for (const leaf of this.getLeavesForPty(ptyId)) {
|
||||
this.adoptPreAllocatedHandle(leaf)
|
||||
@@ -9521,16 +9553,25 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private invalidateAllHandlesForPty(ptyId: string): void {
|
||||
const incarnationHandle = this.handleByPtyIncarnation.get(ptyId)?.handle
|
||||
const preallocatedHandle = this.handleByPtyId.get(ptyId)
|
||||
this.invalidatePtyIncarnationHandle(ptyId)
|
||||
this.handleByPtyId.delete(ptyId)
|
||||
const invalidated = new Set<string>()
|
||||
if (preallocatedHandle && preallocatedHandle !== incarnationHandle) {
|
||||
invalidated.add(preallocatedHandle)
|
||||
}
|
||||
for (const [handle, record] of this.handles) {
|
||||
if (record.ptyId === ptyId) {
|
||||
invalidated.add(handle)
|
||||
this.handles.delete(handle)
|
||||
this.syntheticTerminalHandles.delete(handle)
|
||||
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
for (const handle of invalidated) {
|
||||
this.handles.delete(handle)
|
||||
this.syntheticTerminalHandles.delete(handle)
|
||||
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
|
||||
}
|
||||
for (const [leafKey, handle] of this.handleByLeafKey) {
|
||||
if (invalidated.has(handle)) {
|
||||
this.handleByLeafKey.delete(leafKey)
|
||||
@@ -27561,6 +27602,42 @@ export class OrcaRuntimeService {
|
||||
return count
|
||||
}
|
||||
|
||||
private getPtyIdsForExplicitTabClose(worktreeId: string, tabId: string): string[] {
|
||||
const ptyIds = new Set<string>()
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (pty.connected && pty.worktreeId === worktreeId && pty.tabId === tabId) {
|
||||
ptyIds.add(pty.ptyId)
|
||||
}
|
||||
}
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.worktreeId === worktreeId && leaf.tabId === tabId && leaf.ptyId) {
|
||||
ptyIds.add(leaf.ptyId)
|
||||
}
|
||||
}
|
||||
return [...ptyIds]
|
||||
}
|
||||
|
||||
private async stopExplicitlyClosedTabPtys(
|
||||
ptyIds: readonly string[],
|
||||
addressedPtyId: string
|
||||
): Promise<boolean> {
|
||||
let addressedPtyStopped = false
|
||||
const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS
|
||||
for (const ptyId of ptyIds) {
|
||||
let verifiedStopped = false
|
||||
try {
|
||||
verifiedStopped = (await this.ptyController?.stopAndWait?.(ptyId, { deadlineMs })) ?? false
|
||||
} catch {
|
||||
// Why: verified teardown is preferred, but its transport failure must not suppress the legacy provider kill.
|
||||
}
|
||||
const stopped = verifiedStopped || (this.ptyController?.kill(ptyId) ?? false)
|
||||
if (ptyId === addressedPtyId) {
|
||||
addressedPtyStopped = stopped
|
||||
}
|
||||
}
|
||||
return addressedPtyStopped
|
||||
}
|
||||
|
||||
private resolveHandleForTab(tabId: string): string | null {
|
||||
for (const leaf of this.leaves.values()) {
|
||||
if (leaf.tabId === tabId && leaf.ptyId !== null) {
|
||||
@@ -27742,6 +27819,27 @@ export class OrcaRuntimeService {
|
||||
const siblingCount = surface?.tab.parentLayout
|
||||
? countTerminalLayoutLeaves(surface.tab.parentLayout.root)
|
||||
: this.countLeavesInTab(tabId)
|
||||
if (siblingCount <= 1 && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) {
|
||||
const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId)
|
||||
try {
|
||||
await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') {
|
||||
throw error
|
||||
}
|
||||
this.notifier.closeTerminal?.(tabId)
|
||||
}
|
||||
const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId)
|
||||
return { handle, tabId, ptyKilled }
|
||||
}
|
||||
if (siblingCount <= 1 && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) {
|
||||
const ptyIdsToKill = this.getPtyIdsForExplicitTabClose(pty.pty.worktreeId, tabId)
|
||||
await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true })
|
||||
const ptyKilled = await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, pty.pty.ptyId)
|
||||
return { handle, tabId, ptyKilled }
|
||||
}
|
||||
const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false
|
||||
if (!ptyKilled || siblingCount <= 1) {
|
||||
if (surface) {
|
||||
@@ -27762,13 +27860,23 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
this.assertGraphReady()
|
||||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||||
let ptyKilled = false
|
||||
if (leaf.ptyId) {
|
||||
ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false
|
||||
}
|
||||
// Why: in a multi-pane tab, killing the PTY is enough (renderer's exit handler closes the pane); an extra IPC close would race it and close the whole tab.
|
||||
const siblingCount = this.countLeavesInTab(leaf.tabId)
|
||||
if (!ptyKilled || siblingCount <= 1) {
|
||||
const ptyIdsToKill =
|
||||
siblingCount <= 1
|
||||
? this.getPtyIdsForExplicitTabClose(leaf.worktreeId, leaf.tabId)
|
||||
: leaf.ptyId
|
||||
? [leaf.ptyId]
|
||||
: []
|
||||
if (siblingCount <= 1 && this.notifier?.closeTerminalTab) {
|
||||
await this.notifier.closeTerminalTab(leaf.tabId, {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
}
|
||||
const ptyKilled = leaf.ptyId
|
||||
? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId)
|
||||
: false
|
||||
if (siblingCount > 1 ? !ptyKilled : !this.notifier?.closeTerminalTab) {
|
||||
this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId)
|
||||
}
|
||||
return { handle, tabId: leaf.tabId, ptyKilled }
|
||||
@@ -28858,16 +28966,23 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private beginGraphReload(windowId: number): number {
|
||||
// Why: a renderer reload tears down the live graph, so live handles must go stale immediately, not be reused against the rebuild.
|
||||
// Why: the rebuilt graph decides whether an incarnation survived; do not stale proven process identities before that comparison.
|
||||
this.rendererGraphEpoch += 1
|
||||
this.graphStatus = 'reloading'
|
||||
const revision = this.graphReloadLifecycle.begin(windowId)
|
||||
this.setTerminalSideEffectConsumerAvailable(false)
|
||||
this.rememberDetachedPreAllocatedLeaves()
|
||||
const retainedHandles = new Set([
|
||||
...this.handleByPtyId.values(),
|
||||
...[...this.handleByPtyIncarnation.values()].map((record) => record.handle)
|
||||
])
|
||||
for (const handle of this.waitersByHandle.keys()) {
|
||||
if (!retainedHandles.has(handle)) {
|
||||
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
this.handles.clear()
|
||||
this.handleByLeafKey.clear()
|
||||
// Why: handleByPtyId (pre-allocated CLI handles) survives reloads so CLI agents keep control; adoptPreAllocatedHandle re-links on the new graph.
|
||||
this.rejectAllWaiters('terminal_handle_stale')
|
||||
this.refreshWritableFlags()
|
||||
return revision
|
||||
}
|
||||
@@ -28887,6 +29002,10 @@ export class OrcaRuntimeService {
|
||||
if (fence.recovery === 'renderer') {
|
||||
this.graphStatus = 'ready'
|
||||
this.setTerminalSideEffectConsumerAvailable(true)
|
||||
for (const leaf of this.leaves.values()) {
|
||||
this.adoptPreAllocatedHandle(leaf)
|
||||
}
|
||||
this.reconcilePtyIncarnationHandles()
|
||||
this.refreshWritableFlags()
|
||||
return true
|
||||
}
|
||||
@@ -28952,6 +29071,7 @@ export class OrcaRuntimeService {
|
||||
this.leavesByPtyId.clear()
|
||||
this.handles.clear()
|
||||
this.handleByLeafKey.clear()
|
||||
this.clearPtyIncarnationHandles()
|
||||
// Why: pre-allocated CLI handles must survive graph unavailability so they can be re-adopted on reconnect.
|
||||
this.rejectAllWaiters('terminal_handle_stale')
|
||||
}
|
||||
@@ -28976,6 +29096,7 @@ export class OrcaRuntimeService {
|
||||
this.leavesByPtyId.clear()
|
||||
this.handles.clear()
|
||||
this.handleByLeafKey.clear()
|
||||
this.clearPtyIncarnationHandles()
|
||||
this.rejectAllWaiters('terminal_handle_stale')
|
||||
this.refreshWritableFlags()
|
||||
}
|
||||
@@ -28995,6 +29116,7 @@ export class OrcaRuntimeService {
|
||||
this.leavesByPtyId.clear()
|
||||
this.handles.clear()
|
||||
this.handleByLeafKey.clear()
|
||||
this.clearPtyIncarnationHandles()
|
||||
this.rejectAllWaiters('terminal_handle_stale')
|
||||
this.refreshWritableFlags()
|
||||
}
|
||||
@@ -30198,6 +30320,9 @@ export class OrcaRuntimeService {
|
||||
|
||||
pty.worktreeId = worktreeId
|
||||
if (state.incarnationId !== undefined) {
|
||||
if (pty.incarnationId && state.incarnationId && pty.incarnationId !== state.incarnationId) {
|
||||
this.invalidatePtyIncarnationHandle(ptyId)
|
||||
}
|
||||
pty.incarnationId = state.incarnationId
|
||||
}
|
||||
if (state.connectionId !== undefined) {
|
||||
@@ -30646,6 +30771,7 @@ export class OrcaRuntimeService {
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.terminalSpawnCommandsByPtyId.delete(ptyId)
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
this.invalidatePtyIncarnationHandle(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.osc7ScanTailByPtyId.delete(ptyId)
|
||||
this.terminalCwdByPtyId.delete(ptyId)
|
||||
@@ -32751,13 +32877,20 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf)
|
||||
const handle = preAllocatedHandle ?? `term_${randomUUID()}`
|
||||
if (!preAllocatedHandle) {
|
||||
this.syntheticTerminalHandles.add(handle)
|
||||
if (preAllocatedHandle) {
|
||||
return preAllocatedHandle
|
||||
}
|
||||
if (this.handles.has(handle)) {
|
||||
return handle
|
||||
const incarnationId = leaf.ptyId ? (this.ptysById.get(leaf.ptyId)?.incarnationId ?? null) : null
|
||||
const retained = leaf.ptyId ? this.handleByPtyIncarnation.get(leaf.ptyId) : undefined
|
||||
if (retained && leaf.ptyId && retained.incarnationId !== incarnationId) {
|
||||
this.invalidatePtyIncarnationHandle(leaf.ptyId)
|
||||
} else if (retained) {
|
||||
this.bindPtyIncarnationHandle(retained, leaf)
|
||||
return retained.handle
|
||||
}
|
||||
|
||||
const handle = `term_${randomUUID()}`
|
||||
this.syntheticTerminalHandles.add(handle)
|
||||
this.handles.set(handle, {
|
||||
handle,
|
||||
runtimeId: this.runtimeId,
|
||||
@@ -32769,9 +32902,74 @@ export class OrcaRuntimeService {
|
||||
ptyGeneration: leaf.ptyGeneration
|
||||
})
|
||||
this.handleByLeafKey.set(leafKey, handle)
|
||||
if (leaf.ptyId && incarnationId) {
|
||||
this.handleByPtyIncarnation.set(leaf.ptyId, { handle, incarnationId, leafKey })
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
private bindPtyIncarnationHandle(
|
||||
retained: PtyIncarnationHandleRecord,
|
||||
leaf: RuntimeLeafRecord
|
||||
): void {
|
||||
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
if (retained.leafKey !== leafKey) {
|
||||
if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) {
|
||||
this.handleByLeafKey.delete(retained.leafKey)
|
||||
}
|
||||
retained.leafKey = leafKey
|
||||
}
|
||||
this.handles.set(retained.handle, {
|
||||
handle: retained.handle,
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
worktreeId: leaf.worktreeId,
|
||||
tabId: leaf.tabId,
|
||||
leafId: leaf.leafId,
|
||||
ptyId: leaf.ptyId,
|
||||
ptyGeneration: leaf.ptyGeneration
|
||||
})
|
||||
this.handleByLeafKey.set(leafKey, retained.handle)
|
||||
}
|
||||
|
||||
private invalidatePtyIncarnationHandle(ptyId: string): void {
|
||||
const retained = this.handleByPtyIncarnation.get(ptyId)
|
||||
if (!retained) {
|
||||
return
|
||||
}
|
||||
this.handleByPtyIncarnation.delete(ptyId)
|
||||
if (this.handleByLeafKey.get(retained.leafKey) === retained.handle) {
|
||||
this.handleByLeafKey.delete(retained.leafKey)
|
||||
}
|
||||
this.handles.delete(retained.handle)
|
||||
this.syntheticTerminalHandles.delete(retained.handle)
|
||||
this.rejectWaitersForHandle(retained.handle, 'terminal_handle_stale')
|
||||
}
|
||||
|
||||
private clearPtyIncarnationHandles(): void {
|
||||
for (const retained of this.handleByPtyIncarnation.values()) {
|
||||
this.syntheticTerminalHandles.delete(retained.handle)
|
||||
}
|
||||
this.handleByPtyIncarnation.clear()
|
||||
}
|
||||
|
||||
private reconcilePtyIncarnationHandles(): void {
|
||||
for (const [ptyId, retained] of this.handleByPtyIncarnation) {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
const leaves = this.getLeavesForPty(ptyId)
|
||||
if (
|
||||
!pty?.incarnationId ||
|
||||
pty.incarnationId !== retained.incarnationId ||
|
||||
leaves.length !== 1 ||
|
||||
this.handleByPtyId.has(ptyId)
|
||||
) {
|
||||
this.invalidatePtyIncarnationHandle(ptyId)
|
||||
continue
|
||||
}
|
||||
this.bindPtyIncarnationHandle(retained, leaves[0])
|
||||
}
|
||||
}
|
||||
|
||||
private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null {
|
||||
if (!leaf.ptyId) {
|
||||
return null
|
||||
@@ -32853,6 +33051,10 @@ export class OrcaRuntimeService {
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
const record = this.handles.get(handle)
|
||||
if (record?.ptyId && this.handleByPtyIncarnation.get(record.ptyId)?.handle === handle) {
|
||||
this.handleByPtyIncarnation.delete(record.ptyId)
|
||||
}
|
||||
this.handleByLeafKey.delete(leafKey)
|
||||
this.handles.delete(handle)
|
||||
this.syntheticTerminalHandles.delete(handle)
|
||||
@@ -32903,10 +33105,17 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private resolveTuiIdleWaiters(leaf: RuntimeLeafRecord): void {
|
||||
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
|
||||
if (!handle) {
|
||||
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
|
||||
const candidateHandle =
|
||||
this.handleByLeafKey.get(leafKey) ??
|
||||
(leaf.ptyId
|
||||
? (this.handleByPtyId.get(leaf.ptyId) ??
|
||||
this.handleByPtyIncarnation.get(leaf.ptyId)?.handle)
|
||||
: undefined)
|
||||
if (!candidateHandle || !this.waitersByHandle.has(candidateHandle)) {
|
||||
return
|
||||
}
|
||||
const handle = this.issueHandle(leaf)
|
||||
const waiters = this.waitersByHandle.get(handle)
|
||||
if (!waiters || waiters.size === 0) {
|
||||
return
|
||||
|
||||
@@ -490,7 +490,8 @@ function registerRuntimeWindowLifecycle(
|
||||
content
|
||||
}) as Promise<RuntimeMarkdownSaveTabResult>,
|
||||
closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }),
|
||||
closeTerminalTab: (tabId) => requestTerminalTabCloseFromRenderer(mainWindow, tabId),
|
||||
closeTerminalTab: (tabId, options) =>
|
||||
requestTerminalTabCloseFromRenderer(mainWindow, tabId, options),
|
||||
sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }),
|
||||
resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) =>
|
||||
|
||||
@@ -26,10 +26,17 @@ describe('requestTerminalTabCloseFromRenderer', () => {
|
||||
const webContents = { isDestroyed: () => false, send: vi.fn() }
|
||||
const otherWebContents = {}
|
||||
const mainWindow = { isDestroyed: () => false, webContents }
|
||||
const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-1')
|
||||
const request = webContents.send.mock.calls[0]?.[1] as { requestId: string; tabId: string }
|
||||
const pending = requestTerminalTabCloseFromRenderer(mainWindow as never, 'tab-1', {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
const request = webContents.send.mock.calls[0]?.[1] as {
|
||||
requestId: string
|
||||
tabId: string
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
}
|
||||
|
||||
expect(request.tabId).toBe('tab-1')
|
||||
expect(request.localPtyTeardownOwnedExternally).toBe(true)
|
||||
ipcEmitter.emit(
|
||||
'ui:terminalTabCloseResponse',
|
||||
{ sender: otherWebContents },
|
||||
|
||||
@@ -11,7 +11,8 @@ const TERMINAL_TAB_CLOSE_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function requestTerminalTabCloseFromRenderer(
|
||||
mainWindow: BrowserWindow,
|
||||
tabId: string
|
||||
tabId: string,
|
||||
options: { localPtyTeardownOwnedExternally?: boolean } = {}
|
||||
): Promise<void> {
|
||||
if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) {
|
||||
throw new Error('renderer_unavailable')
|
||||
@@ -37,7 +38,7 @@ export async function requestTerminalTabCloseFromRenderer(
|
||||
}
|
||||
}
|
||||
ipcMain.on('ui:terminalTabCloseResponse', onResponse)
|
||||
const request: TerminalTabCloseRequest = { requestId, tabId }
|
||||
const request: TerminalTabCloseRequest = { requestId, tabId, ...options }
|
||||
mainWindow.webContents.send('ui:terminalTabCloseRequest', request)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1482,6 +1482,9 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher.
|
||||
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
|
||||
// Auto-create first tab when worktree activates
|
||||
const activeWorktreeHasTerminalState = activeWorktreeId
|
||||
? Object.hasOwn(tabsByWorktree, activeWorktreeId)
|
||||
: false
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
return
|
||||
@@ -1496,12 +1499,18 @@ function Terminal(): React.JSX.Element | null {
|
||||
|
||||
// Why: give a newly activated worktree a focusable surface when nothing renders, without recreating one after the user closes the last visible tab.
|
||||
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) {
|
||||
return
|
||||
}
|
||||
// Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps).
|
||||
createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })
|
||||
}, [workspaceSessionReady, activeWorktreeId, createTab, reconcileWorktreeTabModel])
|
||||
}, [
|
||||
workspaceSessionReady,
|
||||
activeWorktreeId,
|
||||
activeWorktreeHasTerminalState,
|
||||
createTab,
|
||||
reconcileWorktreeTabModel
|
||||
])
|
||||
|
||||
const startupResumeWorktreeIdsRef = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,10 @@ describe('shouldAutoCreateInitialTerminal', () => {
|
||||
expect(shouldAutoCreateInitialTerminal(0)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves an explicitly persisted empty terminal list', () => {
|
||||
expect(shouldAutoCreateInitialTerminal(0, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not create a terminal when the tab-group model already has content', () => {
|
||||
expect(shouldAutoCreateInitialTerminal(1)).toBe(false)
|
||||
expect(shouldAutoCreateInitialTerminal(2)).toBe(false)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function shouldAutoCreateInitialTerminal(renderableTabCount: number): boolean {
|
||||
// Why: the tab-group model is now the source of truth for visible worktree
|
||||
// content. If it has no renderable tabs, the workspace must synthesize a
|
||||
// terminal instead of deferring to legacy editor/browser restore state,
|
||||
// which can otherwise leave an empty split group with nothing mounted.
|
||||
return renderableTabCount === 0
|
||||
export function shouldAutoCreateInitialTerminal(
|
||||
renderableTabCount: number,
|
||||
hasPersistedTerminalState = false
|
||||
): boolean {
|
||||
// Why: a missing row means never initialized; an explicit empty row records that the user closed the last terminal.
|
||||
return renderableTabCount === 0 && !hasPersistedTerminalState
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ export type CloseFloatingItemListener = (payload: { sourceId: string }) => void
|
||||
export type SelectFloatingIndexListener = (payload: { index: number }) => void
|
||||
export type CloseTerminalListener = (data: { tabId: string; paneRuntimeId?: number | null }) => void
|
||||
export type CloseSessionTabListener = (data: { tabId: string; worktreeId: string }) => void
|
||||
export type TerminalTabCloseRequestListener = (data: { requestId: string; tabId: string }) => void
|
||||
export type TerminalTabCloseRequestListener = (data: {
|
||||
requestId: string
|
||||
tabId: string
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
}) => void
|
||||
|
||||
export async function useIpcEventsForCloseRouting({
|
||||
closeActiveTabListenerRef,
|
||||
|
||||
@@ -118,12 +118,19 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
persistWorkspaceSession
|
||||
})
|
||||
|
||||
listenerRef.current?.({ requestId: 'close-1', tabId: 'terminal-1' })
|
||||
listenerRef.current?.({
|
||||
requestId: 'close-1',
|
||||
tabId: 'terminal-1',
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(closeTerminalTabMock).toHaveBeenCalledWith(
|
||||
'terminal-1',
|
||||
expect.objectContaining({ rejectPinned: true })
|
||||
expect.objectContaining({
|
||||
rejectPinned: true,
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
)
|
||||
expect(persistWorkspaceSession).toHaveBeenCalledTimes(1)
|
||||
expect(respondTerminalTabClose).not.toHaveBeenCalled()
|
||||
|
||||
@@ -2045,33 +2045,36 @@ export function useIpcEvents(): void {
|
||||
// Why: during an in-place renderer reload an older preload can linger; keep this listener additive at that seam.
|
||||
if (window.api.ui.onTerminalTabCloseRequest) {
|
||||
unsubs.push(
|
||||
window.api.ui.onTerminalTabCloseRequest(({ requestId, tabId }) => {
|
||||
let responded = false
|
||||
const respond = (error?: string): void => {
|
||||
if (responded) {
|
||||
return
|
||||
window.api.ui.onTerminalTabCloseRequest(
|
||||
({ requestId, tabId, localPtyTeardownOwnedExternally }) => {
|
||||
let responded = false
|
||||
const respond = (error?: string): void => {
|
||||
if (responded) {
|
||||
return
|
||||
}
|
||||
responded = true
|
||||
window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) })
|
||||
}
|
||||
responded = true
|
||||
window.api.ui.respondTerminalTabClose({ requestId, ...(error ? { error } : {}) })
|
||||
closeTerminalTab(tabId, {
|
||||
rejectPinned: true,
|
||||
...(localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}),
|
||||
onCancel: () => respond('terminal_tab_pinned'),
|
||||
onClosed: () => {
|
||||
void (async () => {
|
||||
const state = useAppStore.getState()
|
||||
await persistWorkspaceSessionByHost(
|
||||
window.api.session,
|
||||
buildWorkspaceSessionPayload(state),
|
||||
state
|
||||
)
|
||||
respond()
|
||||
})().catch((error: unknown) => {
|
||||
respond(error instanceof Error ? error.message : 'terminal_tab_close_failed')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
closeTerminalTab(tabId, {
|
||||
rejectPinned: true,
|
||||
onCancel: () => respond('terminal_tab_pinned'),
|
||||
onClosed: () => {
|
||||
void (async () => {
|
||||
const state = useAppStore.getState()
|
||||
await persistWorkspaceSessionByHost(
|
||||
window.api.session,
|
||||
buildWorkspaceSessionPayload(state),
|
||||
state
|
||||
)
|
||||
respond()
|
||||
})().catch((error: unknown) => {
|
||||
respond(error instanceof Error ? error.message : 'terminal_tab_close_failed')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,28 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not recreate a terminal after an explicit empty state was persisted', () => {
|
||||
const store = createMockStore({ tabsByWorktree: { 'wt-1': [] } })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1')
|
||||
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a terminal when explicit launch work targets an empty workspace', () => {
|
||||
const store = createMockStore({ tabsByWorktree: { 'wt-1': [] } })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', { command: 'claude "Fix this bug"' })
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingActivationSpawn: true
|
||||
})
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'claude "Fix this bug"'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates configured default tabs once with title, color, and opted-in commands', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
|
||||
@@ -507,7 +507,13 @@ export function ensureWorktreeHasInitialTerminal(
|
||||
return null
|
||||
}
|
||||
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
|
||||
const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand)
|
||||
const shouldAutoCreate = shouldAutoCreateInitialTerminal(
|
||||
renderableTabCount,
|
||||
Object.hasOwn(store.tabsByWorktree, worktreeId)
|
||||
)
|
||||
const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork
|
||||
if (!shouldAutoCreate && !shouldCreateForExplicitWork) {
|
||||
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
|
||||
if (existingTerminalTabId && (setup || issueCommand)) {
|
||||
// Why: main may have adopted the startup tab but failed to spawn setup; renderer must still launch the returned fallback setup.
|
||||
|
||||
@@ -171,4 +171,22 @@ describe('terminal tab id hydration', () => {
|
||||
[GOOD_TAB_ID]: 'good-remote'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not turn a rejected terminal row into an explicit-empty tombstone', () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/wt-1' })]
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().hydrateWorkspaceSession({
|
||||
...makeBaseSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [makeTab({ id: BAD_TAB_ID, worktreeId: WORKTREE_ID })]
|
||||
}
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(store.getState().tabsByWorktree, WORKTREE_ID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { createTestStore, makeWorktree, seedStore } from './store-test-helpers'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() }))
|
||||
vi.mock('@/components/terminal-pane/pty-transport', () => ({
|
||||
registerEagerPtyBuffer: vi.fn(),
|
||||
ensurePtyDispatcher: vi.fn()
|
||||
}))
|
||||
|
||||
// @ts-expect-error -- hydration does not call the mocked preload surface.
|
||||
globalThis.window = { api: {} }
|
||||
|
||||
describe('explicit empty terminal hydration', () => {
|
||||
it('preserves a git worktree terminal tombstone', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/wt-1'
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-1' })]
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().hydrateWorkspaceSession({
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: worktreeId,
|
||||
tabsByWorktree: { [worktreeId]: [] }
|
||||
})
|
||||
|
||||
expect(store.getState().tabsByWorktree).toEqual({ [worktreeId]: [] })
|
||||
})
|
||||
|
||||
it('preserves a folder workspace terminal tombstone', () => {
|
||||
const store = createTestStore()
|
||||
const workspaceKey = folderWorkspaceKey('folder-1')
|
||||
|
||||
store.getState().hydrateWorkspaceSession(
|
||||
{
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeWorkspaceKey: workspaceKey,
|
||||
activeWorktreeId: workspaceKey,
|
||||
tabsByWorktree: { [workspaceKey]: [] }
|
||||
},
|
||||
{ additionalValidWorkspaceKeys: [workspaceKey] }
|
||||
)
|
||||
|
||||
expect(store.getState().tabsByWorktree).toEqual({ [workspaceKey]: [] })
|
||||
})
|
||||
})
|
||||
@@ -3856,7 +3856,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
|
||||
rowHydrationByWorktree
|
||||
.map(([worktreeId, hydration]) => [worktreeId, hydration.rows] as const)
|
||||
.filter(([, tabs]) => tabs.length > 0)
|
||||
.filter(
|
||||
([worktreeId, tabs]) =>
|
||||
tabs.length > 0 || session.tabsByWorktree[worktreeId]?.length === 0
|
||||
)
|
||||
)
|
||||
const releasedPtyIdsByTabId = new Map<string, Set<string>>(
|
||||
rowHydrationByWorktree.flatMap(([, hydration]) => [...hydration.releasedPtyIdsByTabId])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type TerminalTabCloseRequest = {
|
||||
requestId: string
|
||||
tabId: string
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
}
|
||||
|
||||
export type TerminalTabCloseResponse = {
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../src/shared/agent-session-resume'
|
||||
import { makePaneKey } from '../../src/shared/stable-pane-id'
|
||||
import { tokenizeStartupCommand } from '../../src/shared/tui-agent-startup-shell'
|
||||
import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema'
|
||||
import type { TerminalTab } from '../../src/shared/terminal-tab-types'
|
||||
import type { Worktree } from '../../src/shared/worktree/types'
|
||||
@@ -11,7 +10,6 @@ import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
|
||||
import type { RpcContext } from '../../src/main/runtime/rpc/core'
|
||||
import { ORCHESTRATION_METHODS } from '../../src/main/runtime/rpc/methods/orchestration'
|
||||
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
|
||||
import { seedStartupSessionRestoredBanner } from '@/components/terminal-pane/session-restored-banner-pane-state'
|
||||
import {
|
||||
resolveLegacyWorkerTerminalRecoveryAction,
|
||||
rollbackLegacyWorkerTerminalSurfaceInStore
|
||||
@@ -561,51 +559,24 @@ describe('completed background-worker retirement resume matrix', () => {
|
||||
const restartAfterRetirement = persistAndParseCurrentSession()
|
||||
await hydrateSession(restartAfterRetirement)
|
||||
|
||||
// Case 8: first activation of the never-visited target cannot resurrect retired authority.
|
||||
// Case 8: first activation preserves explicit retirement and cannot resurrect authority.
|
||||
const beforeActivation = useAppStore.getState()
|
||||
expect(beforeActivation.everActivatedWorktreeIds.has(WORKTREE_ID)).toBe(false)
|
||||
expect(beforeActivation.agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined()
|
||||
expect(beforeActivation.sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined()
|
||||
expect(Object.keys(beforeActivation.pendingStartupByTabId)).toEqual([])
|
||||
const tabCountBeforeActivation = beforeActivation.tabsByWorktree[WORKTREE_ID]?.length ?? 0
|
||||
activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false })
|
||||
const activated = useAppStore.getState()
|
||||
const replacementTabs = (activated.tabsByWorktree[WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.id !== ORIGINAL_TAB_ID
|
||||
)
|
||||
// The provider-ownership gate separately proves this request becomes one transport spawn.
|
||||
const coldSpawnRequests = replacementTabs.flatMap((tab) => {
|
||||
const startup = activated.pendingStartupByTabId[tab.id]
|
||||
if (!startup?.resumeProviderSession) {
|
||||
return []
|
||||
}
|
||||
const tokens = tokenizeStartupCommand(startup.command, 'posix')
|
||||
expect(tokens.ok).toBe(true)
|
||||
const showSessionRestoredBanner = vi.fn()
|
||||
seedStartupSessionRestoredBanner(startup, 1, showSessionRestoredBanner)
|
||||
return [
|
||||
{
|
||||
providerSession: startup.resumeProviderSession,
|
||||
command: startup.command,
|
||||
argv: tokens.ok ? tokens.tokens : [],
|
||||
restoredBannerCount: showSessionRestoredBanner.mock.calls.length
|
||||
}
|
||||
]
|
||||
expect(beforeActivation.tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
const activationResult = activateAndRevealWorktree(WORKTREE_ID, {
|
||||
notifyHostRuntime: false
|
||||
})
|
||||
expect(activationResult).not.toBe(false)
|
||||
const activated = useAppStore.getState()
|
||||
|
||||
expect(tabCountBeforeActivation).toBe(0)
|
||||
expect(replacementTabs).toHaveLength(1)
|
||||
expect(activated.tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === ORIGINAL_TAB_ID)).toBe(
|
||||
false
|
||||
)
|
||||
expect(activated.tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
expect(activated.terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined()
|
||||
expect(activated.ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined()
|
||||
expect(coldSpawnRequests).toEqual([])
|
||||
expectCanaryUnchanged()
|
||||
expect(Object.keys(activated.pendingStartupByTabId)).toHaveLength(0)
|
||||
expect(Object.keys(activated.automaticAgentResumeClaimsByTabId)).toHaveLength(0)
|
||||
|
||||
// Required invariant: explicit completion plus retirement must revoke provider-resume authority.
|
||||
expect(coldSpawnRequests).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
} from './helpers/store'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { RuntimeClient } from '../../src/cli/runtime/client'
|
||||
import { RuntimeRpcFailureError } from '../../src/cli/runtime/types'
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalSplit
|
||||
RuntimeTerminalSplit,
|
||||
RuntimeWorktreeRecord
|
||||
} from '../../src/shared/runtime-types'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
@@ -56,6 +58,24 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl
|
||||
expect(await getWorktreeTabs(firstLaunch.page, worktreeId)).toHaveLength(1)
|
||||
|
||||
const client = new RuntimeClient(session.userDataDir, 30_000)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const shown = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
return shown.result.worktree.id
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcFailureError && error.code === 'selector_not_found') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ message: 'Split target did not become runtime-worktree-resolvable' }
|
||||
)
|
||||
.toBe(worktreeId)
|
||||
let activeHandle: string | null = null
|
||||
await expect
|
||||
.poll(
|
||||
@@ -122,23 +142,13 @@ test('durable whole-tab close removes a split tab across restart', async (// oxl
|
||||
// Why: wait past initial worktree effects so this checks resurrection, not
|
||||
// only the first hydrated frame before default-tab logic has run.
|
||||
await secondLaunch.page.waitForTimeout(1_000)
|
||||
// Why: reattaching to an emptied worktree intentionally spawns a fresh
|
||||
// "Terminal 1" tab (Terminal.tsx's shouldAutoCreateInitialTerminal
|
||||
// fallback fires whenever the active worktree has zero renderable tabs —
|
||||
// true for a durably-closed worktree just like a brand-new one). That
|
||||
// fallback is unrelated to this test and reproduces even mid-session with
|
||||
// no restart at all, so asserting an eternally-empty tab list here is
|
||||
// wrong. What "durable" actually promises is that the specific closed
|
||||
// split tab never comes back — assert on its identity, not on tab count.
|
||||
const restoredTabs = await getWorktreeTabs(secondLaunch.page, worktreeId)
|
||||
expect(restoredTabs.some((tab) => tab.id === closedTabId)).toBe(false)
|
||||
expect(restoredTabs).toEqual([])
|
||||
|
||||
const afterRestart = await client.call<RuntimeTerminalListResult>('terminal.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
expect(
|
||||
afterRestart.result.terminals.filter((terminal) => terminal.tabId === closedTabId)
|
||||
).toEqual([])
|
||||
expect(afterRestart.result.terminals).toEqual([])
|
||||
} finally {
|
||||
if (firstApp) {
|
||||
await session.close(firstApp)
|
||||
|
||||
Reference in New Issue
Block a user