diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md new file mode 100644 index 00000000000..688e37d2898 --- /dev/null +++ b/docs/reference/terminal-session-lifecycle.md @@ -0,0 +1,551 @@ +# Terminal Session Ownership and Teardown + +**Status:** Phase 1 implemented; focused verification complete +**Date:** 2026-07-13 +**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md) +**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md), +[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) + +## Summary + +Terminal process lifetime must be owned by explicit product intent, never by +whether a React view happens to be mounted. + +Orca currently preserves a PTY when a terminal view detaches, which is correct +for view parking, tab moves, renderer reload, and warm reattach. It also relies +on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view +parking invalidated that implicit invariant: a parked tab has no mounted pane, +so closing it removes renderer state and observers without terminating the PTY. +Agent resume records survive the same close and later interpret the missing pane +as failed recovery, launching the deliberately closed session again. + +This design establishes one authoritative lifecycle contract: + +- **Close tab or pane:** permanently retire the surface, terminate every PTY it + owns, and revoke every resume record for its panes. +- **Detach or park:** remove only the view connection; preserve the PTY and its + durable identity. +- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and + preserve only the checkpoint needed for wake. +- **Quit or reload:** follow the configured persistence policy; warm detach is + allowed, but it is not a tab close. + +The immediate implementation makes the existing terminal-tab state close the +authoritative compatibility boundary for explicit retirement while adding an +explicit natural-exit reason for callers that are only reconciling an already +dead PTY. A follow-up separates the command and reducer APIs once all direct +store callers have migrated. + +## Implementation status + +Phase 1 now ships the retirement planner, provider-aware close routing, parked +watcher/candidate disposal, resume-authority revocation, explicit natural-exit +handling, late-binding rejection in `updateTabPtyId`, and post-spawn ownership +checks for both direct background launchers. Focused tests cover local, SSH, +split, parked, shared, paired-host, ordinary runtime, and late-spawn paths. + +Origin-aware defensive expiry remains a follow-up. Immediate deletion on +explicit close fixes the incident without applying a wall-clock policy that +could invalidate intentional long-lived `worktree-sleep` checkpoints. + +## Goals + +1. Closing a terminal tab submits retirement for all exclusively owned local, + daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its + views are mounted or parked. +2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to + another tab preserves it. The pane-scoped resume transfer is a Phase 2 item. +3. Closing a tab permanently removes its agent resume authority. App restart, + worktree activation, mobile wake, and periodic capture must not resurrect it. +4. Parking, tab-group moves, renderer reload, and warm reattach keep their + existing process-preservation behavior. +5. Remote-runtime and web-session surfaces are terminated by their owning host, + not by sending a host-scoped ID to the local PTY provider. +6. Teardown is idempotent and safe under duplicate close requests, late PTY + exits, late agent-hook events, in-flight spawns, and provider disconnects. +7. A future bookkeeping regression cannot grow without bound: unowned defensive + resume records have an origin-aware expiry, and provider-owned detached + sessions gain a bounded orphan policy in the daemon-lease phase. + +## Non-goals + +- Changing terminal output, snapshot, replay, query-response, or hidden-delivery + behavior. +- Killing processes that deliberately daemonize away from the terminal process + group. This contract covers the PTY and its attached foreground process tree. +- Changing agent-provider resume commands or permission flags. +- Making a UI close wait for a remote process to exit before the tab disappears. +- Replacing worktree sleep with tab close. Sleep remains resumable by design. +- Claiming that a disconnected SSH relay process is already dead when main can + only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a + follow-up. + +## Terminology + +- **Surface:** a terminal tab or a pane within a split terminal tab. +- **View:** a renderer xterm or pane-less parked watcher observing a surface. +- **Session:** the provider-owned PTY identified by a PTY ID. +- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits + Orca to launch an agent-provider resume command. +- **Retire:** permanently close a surface and revoke its process and resume + ownership. +- **Detach:** disconnect a view while preserving the session for reattachment. + +## Open-source prior art + +The lifecycle distinction is established in mature terminal implementations: + +- VS Code's terminal instance disposal calls process-manager disposal, and the + process manager marks the process `KilledByUser` before shutdown. Its separate + `detachFromProcess` path deliberately clears the client reference without + shutting down the process. It also shuts down a process whose asynchronous + creation completes after the manager was disposed. See + [`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325) + and + [`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239). +- tmux exposes the same contract as separate commands: `detach-client` removes + a client while leaving the session intact, whereas `kill-session` destroys + the session and its windows. See + [`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218). + +Orca needs the same semantic split, extended across local, WSL, SSH, persistent +daemon, and remote-runtime ownership. The important precedent is the explicit +intent boundary, not a particular framework or process API. + +## Non-negotiable invariants + +1. View lifetime and process lifetime are independent. Mount and unmount may + attach or detach observers; they do not decide whether a session should live. +2. Every live PTY has an owning surface or an explicit bounded detached state. +3. A user close is terminal: once accepted, no status event, session snapshot, + or runtime replay may recreate the closed surface or resume its agent. +4. A tab's retirement candidates are the union of its live index, tab row, + persisted split layout, last-known relay ID, deferred SSH ID, and pending + reconnect ID. A candidate is killable only when no other live surface claims + it. +5. Provider routing follows the execution owner: + - local/daemon/WSL and app-scoped SSH IDs use `pty.kill`; + - `remote:` runtime mirror IDs are never passed to the local PTY provider; + - paired web tabs are closed through the host session RPC. +6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and + close-after-exit must converge on the same empty state. +7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records + are not expired by agent-status freshness; unowned defensive `live` and + `quit` records use a documented decision-time horizon. +8. Natural process exit and explicit user retirement are distinct intents. + Reconciliation after an exit must not silently adopt destructive user-close + semantics. + +## Lifecycle matrix + +| Intent | View | PTY | Resume record | Persistent tab state | +| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- | +| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep | +| Move pane/tab group | detach, then remount | keep | keep | move | +| Renderer reload | detach | keep when warm persistence applies | keep | keep | +| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep | +| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers | +| Close split pane | destroy | terminate that pane | delete that pane record | remove pane | +| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab | +| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree | +| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface | + +## Current failure + +The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then +relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A +parked tab has already run the detach branch and has no mounted cleanup left. +`disposeParkedTabWatchers` only unregisters observers. The provider session is +therefore alive after Orca discards its last renderer-side owner. + +The same state close drops live agent status but not +`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes +records that no preserved pane can own. A deliberately closed pane therefore +looks like a failed restore. The existing age check compares `capturedAt` with +`updatedAt`; that detects a status that was stale at capture time but never +expires a record as wall time advances. + +## Proposed architecture + +### 1. Authoritative tab retirement plan + +Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from +one store snapshot: + +```ts +type TerminalTabRetirementPlan = { + tabId: string + worktreeId: string | null + ptyIds: string[] + localOrSshPtyIds: string[] + runtimeTerminals: Array<{ environmentId: string; handle: string }> + sharedPtyIds: string[] + paneKeys: string[] +} +``` + +`ptyIds` is the deduplicated union of: + +- `ptyIdsByTabId[tabId]`; +- the tab row's legacy `ptyId`; +- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`; +- `lastKnownRelayPtyIdByTabId[tabId]`; +- `deferredSshSessionIdsByTabId[tabId]`; +- `pendingReconnectPtyIdByTabId[tabId]`. + +This snapshot must be built before any of those maps are pruned. PTY IDs are +classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host +or platform from the current active workspace. Before shutdown, the planner +subtracts IDs referenced by another tab row, live index, split layout, or relay +and reconnect map. A partially completed pane move must not let closing the +source tab kill the target tab's session. + +The immediate compatibility boundary is `closeTab`, because several production +paths still call the store action directly. Its options carry an explicit +`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while +`pty-exit` reconciles an already-dead session without killing siblings or +revoking crash-recovery policy by accident. Making only the rendered tab-bar +action destructive would leave background launch cleanup, floating terminal, +onboarding, runtime notifications, and future direct callers vulnerable. + +### 2. Teardown ordering + +A local tab retirement executes in this order: + +1. Snapshot the retirement plan and its execution-owner classifications. +2. Mark the tab recently closed so late agent-hook events cannot reintroduce + status or resume authority. +3. Dispose the whole tab's parked registry entry and captured candidates, and + silence teardown side-effect handlers while preserving deterministic exit + observation. +4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for + exclusive ordinary runtime handles. A paired host-session close remains + owned by `session.tabs.close` and is not duplicated locally. +5. Atomically remove the tab, layout/binding maps, agent status, and resume + records from renderer state. +6. Let subsequent React unmount cleanup run idempotently. It is no longer the + process-lifetime authority. + +Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation +remains synchronous and does not await provider exit. Completion is observed +with `Promise.allSettled` so rejections cannot become unhandled promises. The +current Phase 1 fallback for a rejected retirement is provider `listSessions` +plus Resource Manager orphan cleanup; durable retry inventory belongs to the +main-owned retirement phase. + +Remote-runtime mirrors are different: their `remote:` IDs are excluded from +local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`. +Paired host-session tabs retire through `session.tabs.close`, which owns the +entire host tab graph. The local mirror may be pruned optimistically only after +the host close intent is recorded, as it is today. + +### 3. Resume-authority revocation + +Tab retirement removes every sleeping record whose: + +- map key begins with `${tabId}:`; or +- record has `tabId === tabId`. + +The explicit `record.tabId` check covers migrated or legacy keys whose key no +longer encodes the current tab identity. Sibling tabs and other worktrees retain +their records by reference when unchanged. + +The existing recently-closed tab registry remains the short-lived race guard +for hook events already in flight. It must prevent those events from creating a +new `origin: 'live'` resume record after retirement. Tests must cover both event +orders: close then late status, and status queued in the same turn as close. + +Periodic capture iterates only current live status entries. Once close removes +the live entry and resume record, the periodic pass has nothing to persist. + +### 4. Origin-aware defensive record expiry + +An unowned defensive `live` or `quit` record may expire when: + +```ts +Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS +``` + +Intentional `worktree-sleep` records are exempt from the agent-status freshness +window. Originless legacy behavior remains unchanged until its migration policy +is explicitly chosen. The same validator must run before desktop activation and +mobile/background wake so one entry point cannot bypass it. + +Expiry is defense in depth, not the primary close mechanism. A correctly closed +record is deleted immediately; it is not retained for 30 minutes. + +### 5. Pane close remains pane-scoped + +`PaneManager.closePane` already distinguishes `reason: 'close'` from +`reason: 'detach'`. Preserve that distinction, but treat the complete +pane-identity transfer as Phase 2: + +- `close` must clear the exact pane's resume authority, add a pane-scoped late + event tombstone, and destroy the pane transport; +- `detach` must atomically transfer or alias resume ownership because the agent + process retains its immutable source `ORCA_PANE_KEY`. + +The last-pane path routes to terminal-tab retirement so it receives the same +parked, split, SSH, and resume cleanup behavior as a tab-bar close. + +### 6. Provider-side safety net + +Client correctness is necessary but not sufficient for a daemon deliberately +designed to outlive renderer and app processes. Add a follow-up provider-owned +orphan policy: + +- track attachments/ownership leases rather than treating `detach` as a no-op; +- distinguish warm-reattach grace from ownerless retirement; +- reap sessions that have no owner after a bounded grace period; +- never idle-reap a session merely because it produces no output; +- scope leases to native, WSL, SSH provider, relay connection, or runtime host; +- expose orphan count and oldest orphan age in Resource Manager diagnostics. + +This hardening is not required to land the immediate close fix, because a lease +protocol changes daemon compatibility and needs its own migration. It is the +defense against a future client bookkeeping regression. + +## Provider behavior + +| Provider/session kind | Close operation | Notes | +| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | +| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. | +| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. | +| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. | +| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. | +| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. | +| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. | +| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. | +| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. | +| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. | + +## Failure and race handling + +### Duplicate close + +The first close snapshots and retires the tab. Later closes find no target and +no-op. Provider shutdown is idempotent for an already exited session. + +### PTY exit races close + +Observers are unregistered before shutdown. If an exit event was already +queued, the recently-closed tab guard and missing tab make it a no-op. It must +not recreate a tab, completion row, resume record, or notification. + +### Spawn completes after close + +The transport already kills a PTY when its spawn resolves after `destroyed` is +set, but the two direct background launchers do not use that transport. Each +direct launcher must revalidate tab ownership immediately after `pty.spawn` or +`terminal.create` resolves and before writing any binding, layout, eager buffer, +subscription, or mount request. A retired local or SSH result is killed; a +retired runtime result is closed through `terminal.close`. + +### Provider shutdown fails + +The tab remains closed; reappearing would violate user intent. Report the +provider class through structured diagnostics and leave the session visible in +provider inventory so existing Resource Manager orphan cleanup can retry. A +durable pending-retirement inventory is a later main-process API and is not +claimed by Phase 1. Do not restore resume authority after a failed terminal +kill. + +### Remote host is unavailable + +Keep the local close intent until either a host snapshot confirms removal or the +existing intent TTL expires. If it expires, the host remains authoritative and +may republish the tab; log the failed close. Never pretend a local mirror prune +terminated the host PTY. + +### App exits during close + +Submit shutdown IPC before discarding the only local PTY identifiers. The main +process/provider owns completion after accepting the request. A future batched +shutdown IPC may return per-ID acceptance, but UI close does not wait for it. + +## Implementation plan + +### Phase 1: correctness patch (implemented) + +1. Add a small store-independent terminal-tab retirement planner that collects + all six ID sources, classifies execution owners, and excludes IDs shared by + another live surface. +2. Add an explicit close reason, update natural-exit callers, and make explicit + `closeTab` retirement dispose the whole parked-tab registry, submit local, + SSH, or runtime shutdown, atomically clear all reconnect maps, and remove + tab-owned resume records. +3. Add post-await owner checks to both direct background launchers so a late + local, SSH, or runtime spawn is terminated before any renderer binding. +4. Defer decision-time expiry until unowned defensive `live` or `quit` records + can be distinguished without changing intentional `worktree-sleep` or legacy + recovery behavior. +5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one + local Electron parked-close proof as an RC release gate. + +### Phase 2: API cleanup + +1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean + either UI cleanup or lifecycle teardown. +2. Route all user and runtime close call sites through one domain command. +3. Add exact pane retirement and atomically transfer or alias resume identity on + pane-to-tab detach. +4. Guard `closeUnifiedTab` and unified bulk APIs from removing terminal wrappers + without terminal retirement. +5. Keep explicit `detach` and `sleep` APIs; do not encode them as close options. +6. Add a development assertion when tab state is removed with provider PTY IDs + but no retirement plan. + +### Phase 3: daemon ownership leases + +1. Define versioned attach/detach/retire lease messages. +2. Preserve warm reattach across renderer/app restarts within a bounded grace. +3. Reap ownerless sessions and expose pressure telemetry. +4. Add compatibility tests across the supported daemon protocol versions and + native/WSL/SSH host isolation. + +## Test plan + +### Unit/store tests + +- Closing an active single-pane tab submits exactly its PTY for shutdown and + removes all tab state. +- Closing a parked tab with no mounted transport still submits its PTY. +- Closing a split tab submits every unique PTY found across tab row, index, and + layout plus relay/deferred/reconnect maps, including IDs present in only one + source. +- Duplicate IDs are submitted once by the authoritative retirement plan. +- An ID referenced by another live tab is not killed when the source tab closes. +- `remote:` IDs are excluded from local PTY shutdown. +- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs. +- All sleeping records for the tab are removed by key prefix or `record.tabId`; + sibling records retain identity. +- Periodic capture after close does not recreate the record. +- Late agent status after close cannot recreate live status or resume authority. +- Closing twice and close-after-exit are no-ops after the first retirement. +- A spawn resolving after close kills the newly returned PTY. +- A defensive live/quit record at the expiry boundary is retained; one + millisecond past it is cleared and never launched, while an older intentional + worktree-sleep record still resumes. + +### Renderer integration tests + +- Mounted tab close still destroys its xterm and terminates the PTY. +- Parked watcher disposal occurs before or with PTY shutdown and emits no final + completion/bell notification. +- Closing one split pane preserves the sibling; closing the last pane retires + the tab. +- Pane-to-tab detach preserves the PTY and resume record. +- Pinned-tab cancellation performs no shutdown; confirmed close does. +- Bulk close (`close others`, `close right`, kill-all) uses the same semantics. + +### Provider/main tests + +- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the + correct provider. +- Disconnected SSH close tombstones the app-scoped ID and never falls through to + a local provider. +- Repeated `pty.kill` for the same ID is benign. +- Remote-runtime IDs cannot reach the local kill handler from renderer close. + +### Electron end-to-end regression + +1. Create a worktree terminal and start a deterministic long-lived child. +2. Switch worktrees and wait until the source worktree is cold-parked. +3. Close the parked tab. +4. Assert its exact PTY disappears from `pty:listSessions` and the child exits. +5. Restart/reload Orca and activate the worktree. +6. Assert no terminal or agent resume command is recreated for the closed pane. +7. Repeat with two split panes and with an SSH fixture where CI supports it. + +### Soak and pressure gate + +Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count +must return to baseline after each cycle and remain bounded over the run. Record +the peak and final provider session count in CI artifacts. + +## Observability + +Emit a synchronous scheduled summary and an asynchronous completion summary: + +- tab/worktree identifiers in hashed or existing diagnostic form; +- number of PTY IDs discovered by each source; +- provider classification counts; +- submitted count, then fulfilled/rejected counts after `Promise.allSettled`; +- number of resume records removed; +- whether the tab was parked or mounted when retired. + +After the daemon-lease phase, Resource Manager should distinguish: + +- attached sessions; +- warm-detached sessions eligible for reattach; +- ownerless/orphan sessions; +- pending/failed explicit retirement. + +Alert locally before app-owned PTYs approach platform pressure. The warning is a +defense, not a substitute for lifecycle correctness. + +## Rollout + +1. Land the correctness patch without a feature flag. User close semantics are + restorative behavior, not an experiment. +2. Keep hidden-view parking enabled; disabling it masks the ownership bug and + forfeits its memory benefit. +3. Run focused unit/integration tests plus the live parked-close Electron gate. +4. Cut an RC and soak create/close/restart cycles while monitoring provider and + OS PTY counts. +5. Ship daemon leases separately behind protocol compatibility and telemetry. + +## Alternatives rejected + +### Disable hidden-view parking + +This restores the old accidental unmount behavior at significant memory cost +and leaves every other state-only close path fragile. It does not repair resume +records or daemon ownership. + +### Kill only from `TerminalPane` cleanup + +There is intentionally no pane during parking and some headless/runtime flows. +View cleanup cannot be process authority. + +### Clear only resume records + +This prevents resurrection but still leaks the live PTY until app/daemon exit. + +### Kill only the tab row's `ptyId` + +Split panes and partially reconciled layouts can own multiple IDs. The full +union is required. + +### Rely only on a daemon idle timeout + +Interactive agents can be legitimately idle for hours. Reaping by output +idleness loses user work; ownership, not activity, is the safe signal. + +### Await every provider exit before hiding the tab + +Remote and disconnected providers can take seconds or fail. UI retirement must +be immediate once the user confirms; provider completion is asynchronous and +observable. + +## Phase 1 acceptance criteria + +- A confirmed terminal-tab close submits retirement for every exclusively owned + session Orca can currently reach and reports rejected requests without an + unhandled promise. +- A closed agent session is never resumed by restart, activation, or mobile wake. +- Parking, move/detach, sleep/wake, and warm app reattach retain their documented + behavior. +- Split, local, SSH, and remote-runtime ownership are covered by deterministic + tests; Windows/WSL process-tree behavior remains a live-platform follow-up. +- PTY counts remain bounded across the soak scenario. +- No process-lifetime decision depends on React component mount state. + +## Full-contract follow-ups + +- Pane-scoped resume revocation and immutable pane-key transfer/aliasing. +- Durable main-owned retirement intent and bounded retry inventory. +- Disconnected SSH relay kill-on-reconnect. +- Serializable daemon attachment leases with protocol-version fallback. +- Positive ownership validation for late hook events beyond the bounded recent- + close registry. diff --git a/pty-exhaustion-agent-session-leak.md b/pty-exhaustion-agent-session-leak.md new file mode 100644 index 00000000000..64cbb3d93ce --- /dev/null +++ b/pty-exhaustion-agent-session-leak.md @@ -0,0 +1,178 @@ +# Design Doc: PTY Exhaustion from Leaked Agent Sessions + +**Date:** 2026-07-13 +**Status:** Root cause identified; fixes proposed +**Severity:** P0 — machine-wide outage. Once the pty cap is hit, _nothing_ on the host can open a terminal (Orca, Ghostty, IDEs, `ssh`, agent spawns all fail with "cannot allocate pty"). +**Source refs:** file:line references below are against the main checkout at `~/orca/orca` as of 2026-07-13. + +--- + +## 1. Summary + +An Orca desktop instance exhausted the macOS pseudo-terminal limit (`kern.tty.ptmx_max = 511`). Investigation on the live machine found **526 allocated ptys**, of which **~476 belonged to three `Orca Helper` processes** — hundreds of idle `login → zsh → codex resume ` chains, some alive for almost 4 days, all at 0% CPU. Sessions the user had explicitly closed in the UI were still running, and closed sessions kept _coming back_ in bulk waves after restarts. + +The root cause is **two independent defects that compound into a self-perpetuating leak**: + +- **Bug A (teardown):** Closing a session tab never kills the underlying pty. Tab close is pure renderer-state cleanup; the actual `pty.kill` only happens as a side effect of a mounted `TerminalPane` unmounting. For "cold-parked" tabs in background worktrees — whose panes are unmounted by design — closing the tab silently orphans the live process chain. +- **Bug B (resume):** Closing a tab never removes the session's record from the persisted resume list (`sleepingAgentSessionsByPaneKey`), and the record staleness check is a no-op (it compares `capturedAt - updatedAt`, which is always ≈ 0, instead of `now - updatedAt`). Every workspace open / app restart therefore bulk-launches `codex resume` for the entire orphaned backlog — and a periodic capture timer re-persists all of them, so the backlog only grows. + +Every restart makes it worse: Bug B resurrects the dead-but-remembered sessions (each burning a fresh pty), Bug A guarantees closing them doesn't actually free anything, and the capture loop re-persists the ever-larger set. + +--- + +## 2. Observed incident (live-machine evidence) + +Measurements taken 2026-07-13 on the affected desktop: + +| Metric | Value | +| ------------------------------------------------- | ----------------------------------------------------------------------- | +| Allocated ptys (`/dev/ttys*`) | 526 (cap: `kern.tty.ptmx_max = 511`) | +| `login` + `zsh` session pairs | 506 | +| Sessions parented to installed `Orca.app` helpers | 476 (helper pids 53960: **377**, 31662: 58, 1510: 41) | +| Sessions from dev-build Orca instances | ~25 | +| `codex` agent processes | ~280 (~270 **unique** session IDs; a handful resumed 2–3× concurrently) | +| `claude` agent processes | ~38 | +| Processes using >1% CPU | 7 of 763 — everything else fully idle | + +Key observations: + +- **`Orca Helper` pid 53960 alone held 376 open ptmx master fds.** Every leaked chain was still fully parented: `Orca Helper → /usr/bin/login → zsh → node → codex`. Nothing was orphaned to launchd — the app-side master fd was simply never closed. +- **Working directories were overwhelmingly finished PR-review worktrees** (`review-p1-pr-7511-issue-7026` alone had 79 codex processes; `review-p0-pr-8370-issue-8212` had 76; `review-p0-pr-8279-issue-8260` had 67). +- **Start times cluster in same-second waves** — batches on Fri Jul 10 (~15:41–18:14) and large bursts on Sun Jul 12 (14:03, 14:27, 14:36–14:40, 15:09). These are bulk resumes on workspace open / app restart, not user-opened sessions. +- **The user had closed these tabs.** The tabs were gone from the UI; the processes survived (Bug A) and were then re-resumed in later waves (Bug B). +- Nearly all resumed with `codex --dangerously-bypass-approvals-and-sandbox resume ` — i.e., they idle at an interactive prompt and never exit on their own. + +--- + +## 3. Background: pty ownership architecture + +- Terminal sessions are spawned in an Electron utility/helper process which holds the **pty master** (hence the ptmx fds on `Orca Helper`). +- A **daemon** hosts sessions so they survive app restarts for warm reattach: it is forked `detached: true` + `unref()` and deliberately outlives the app (`daemon/daemon-init.ts:306-319`; comment at `:741` — "sessions stay alive for warm reattach"). +- Background (non-active) worktrees are **"cold-parked"**: their `TerminalPane` React components unmount, the transport calls `detach()` (`pty-transport.ts:929` — "keep the PTY exit observer alive"), and pane-less byte watchers observe the still-running pty. +- Agent sessions are additionally tracked in a persisted map, `sleepingAgentSessionsByPaneKey` (`src/renderer/src/store/slices/agent-status.ts:121`), so they can be resumed (`codex resume `) after sleep/quit. + +Keep-alive-on-detach is **intentional**. The defects are in what is supposed to end a session's life. + +--- + +## 4. Bug A: closing a tab never kills the pty + +### The close path contains no kill + +`closeTerminalTab()` (`terminal-tab-actions.ts:152`) → `closeLocalTerminalTabState` → `state.closeTab()` (`store/slices/terminals.ts:1192-1381`). `closeTab` scrubs ~20 per-tab maps — including `delete ptyIdsByTabId[tabId]` (`terminals.ts:1217-1218`) — but issues **no `pty.kill`, no shutdown, no IPC**. It just _forgets the pty id_. + +### The only kill is a React-unmount side effect + +The pty dies only when a _mounted_ `TerminalPane` unmounts with the tab gone: `shouldDetachPaneTransportOnUnmount()` (`use-terminal-pane-lifecycle.ts:456`) returns false → unmount cleanup (`use-terminal-pane-lifecycle.ts:1725-1826`, kill branch at `:1807`) → `transport.destroy()` (`pty-transport.ts:1031`) → `window.api.pty.kill(id)` → main `pty:kill` (`ipc/pty.ts:4953`) → `provider.shutdown(id, {immediate: true})`. + +This works for the **active** worktree. But for a **cold-parked** tab there is no mounted pane; closing it runs `syncParkedTerminalTabWatchers` → `disposeParkedTabWatchers` (`terminal-parked-watcher-registry.ts:53-63`), which stops the watchers and **never kills the pty**. The `login → zsh → codex` chain is orphaned with nothing pointing at it. **This is the zombie source.** + +### Nothing else ever reaps it + +Every existing reaper is scoped to an event _other than_ tab close: + +| Mechanism | Why it doesn't help | +| --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Daemon `detach` handler | Logging no-op — "full implementation would track tokens" (`daemon/daemon-server.ts:508-512`). No idle timeout exists anywhere. | +| `reapSession` (`daemon/terminal-host.ts:215`) | Fires only on the child's natural exit or explicit kill; an idle interactive `codex` never exits. | +| App-quit `killAllPty()` (`ipc/pty.ts:5191`), reload `killOrphanedPtys` (`ipc/pty.ts:2851`) | Both guarded by `if (localProvider instanceof LocalPtyProvider)` — false for the daemon adapter, so daemon-hosted sessions are excluded _by design_ (warm reattach). | +| Worktree removal (`runtime/worktree-teardown.ts:39`, `killAllProcessesForWorktree`) | Works, but only on full worktree delete. | +| Sleep flow (`sidebar/sleep-worktree-flow.ts:148` → `shutdownWorktreeTerminals`, `terminals.ts:2331`, kill at `:2665`) | Works, but only when the user explicitly sleeps the worktree. | + +### The codebase already knows close ≠ kill + +`kill-all-terminal-surfaces.ts` performs daemon `killAll()`, then `closeTerminalTab({force: true})` per tab, **then an explicit `window.api.pty.kill(ptyId)` loop over `ptyIdsByTabId`** (`:140-147`, `:188-190`) — direct evidence that tab close alone is known not to kill. There is even a manual mop-up UI: **Manage Sessions → "Kill orphan terminals"** for sessions "that have no tab in this Orca instance" (`status-bar/ResourceUsageStatusSegment.tsx:~1114-1155`) — a hand-operated workaround for exactly this leak. + +--- + +## 5. Bug B: closed sessions are resurrected in bulk forever + +### The resume list never learns about tab close + +Agent sessions are recorded in `sleepingAgentSessionsByPaneKey` (`agent-status.ts:121`; record shape in `src/shared/agent-session-resume.ts:38-59`, with `state`, `capturedAt`, `updatedAt`, and `origin: 'worktree-sleep' | 'quit' | 'live'`). Records are written: + +- **Live:** on every non-done agent-status update, an `origin: 'live'` record is captured (`agent-status.ts:1492-1544`). +- **Quit + periodic:** `captureAllSleepingAgentSessions` writes an `origin: 'quit'` record for _every currently-live pane_ (`agent-status.ts:2312-2350`), called on quit (`App.tsx:1367`) **and on a periodic interval** (`App.tsx:1394`). + +`closeTab` (`terminals.ts:1192-1381`) calls `dropAgentStatusByTabPrefix` (`terminals.ts:1894-2010`), which removes the agent-_status_ entry but **never touches `sleepingAgentSessionsByPaneKey`**. There is no concept of "user closed this tab" in the resume store. The orphaned record persists to disk with the workspace session state (`workspace-session-sleeping-agents.ts` → `workspace-session.ts:393`). + +### The staleness gate is a no-op + +On workspace/worktree open, `resumeSleepingAgentSessionsForWorktree` (`resume-sleeping-agent-session.ts:143-218`) launches `codex resume ` (argv built in `agent-session-resume.ts:195-220`; full command in `tui-agent-startup.ts:189-238`; launched via `sleeping-agent-session-launch.ts:65-128`) for every record unless a filter rejects it: + +- `isPassiveCompletedHibernationEvidence` (`sleeping-agent-pane-ownership.ts:16-18`): only skips records that are `done` **and** not `live`/`quit` origin. Orphaned records are `live`/`quit`, so they pass. +- `isInvalidWorktreeActivationRecord` (`resume-sleeping-agent-session.ts:131-141`): rejects records where `state !== 'done' && (capturedAt - updatedAt) > 30 min` (`AGENT_STATUS_STALE_AFTER_MS`, `shared/agent-status-types.ts:256`). **But live records are captured with `capturedAt === updatedAt`** (`agent-status.ts:1501`) and quit records stamp `capturedAt = Date.now()` immediately after the last update — so the difference is always ≈ 0. The check should compare against **`now`**; as written, a record from three days ago is perpetually "fresh." +- `recordPaneIsOwnedByPreservedPane`: skips only if the pane still exists in the restored layout — but the user closed the tab, so it doesn't. + +Result: orphaned records are neither passive, nor stale, nor pane-owned → **all of them get `codex resume`'d**, dozens in the same second. Triggers: folder-workspace open (`worktree-activation.ts:229`), worktree open (`:363`), startup hydration of the active worktree (`Terminal.tsx:1062-1075`), and mobile wake (`wake-sleeping-agents-in-background.ts:165-212`, wired at `useIpcEvents.ts:836`). + +### The feedback loop + +1. Workspace opens → every orphaned record is resumed → N fresh ptys, N idle `codex` processes. +2. Resumed sessions idle at a prompt and never reach `done`, so done-cleanup (`agent-status.ts:1541-1544`) never runs. +3. The periodic/quit capture (`agent-status.ts:2312-2350`) re-persists a record for every one of them. +4. User closes the tabs → Bug A leaves the processes alive; Bug B leaves the records in place. +5. Next open/restart → goto 1, with a strictly larger backlog. + +Distinct historical session IDs produce distinct claim keys, so per-claim dedup doesn't collapse them — which is exactly why one worktree accumulated 79 live codex processes across ~270 unique session IDs machine-wide. + +--- + +## 6. Proposed fixes + +### Fix 1 (primary, Bug A): make tab close actually kill the pty + +In `closeTab` (`terminals.ts:1192`) / `closeTerminalTab` (`terminal-tab-actions.ts:152`), issue `window.api.pty.kill()` for the tab's pty ids **before** `delete ptyIdsByTabId[tabId]`, mirroring the explicit kill loop in `kill-all-terminal-surfaces.ts:140-147`. This closes the parked-pane gap directly: the kill no longer depends on a mounted `TerminalPane` unmounting. Cover the split-pane close paths (`use-terminal-pane-lifecycle.ts`, `TerminalPaneOverlayLayer.tsx`) as well. + +Design decision needed: if warm-reattach semantics should be preserved for _some_ tab closes, distinguish "close tab" (kill) from "sleep/detach" (keep alive) explicitly in the UI action — today the distinction exists in the code (Sleep uses `keepIdentifiers: true`, `sleep-worktree-flow.ts:148`) but plain tab close falls into an unintended third state: forgotten but alive. + +### Fix 2 (primary, Bug B): remove resume records on tab close + +Have `closeTab` (and the split-pane close paths) delete the closed panes' entries from `sleepingAgentSessionsByPaneKey`. A user closing a tab is expressing "I'm done with this session" — it must not remain resumable-by-default. + +### Fix 3 (secondary, Bug B): origin-aware wall-clock expiry + +Do not globally expire resume records by wall time: intentional `worktree-sleep` +checkpoints may remain valid for days. A defensive expiry can apply only to +unowned `live` or `quit` records after their origin and ownership are validated +at every wake entry point. Explicit tab close must delete its records +immediately and remains the primary fix. + +### Fix 4 (secondary, Bug A): daemon-side reaper + +Implement the daemon `detach` handler (`daemon-server.ts:508-512`) and an idle reaper for sessions that are detached with **no owning tab**. The daemon deliberately outlives the app and is excluded from every `instanceof LocalPtyProvider` sweep, so it needs its own lifecycle policy — otherwise any future client-side bookkeeping bug leaks forever again. + +### Defense in depth + +- Surface a pty-pressure warning (the data already exists for the "Kill orphan terminals" feature) when app-owned ptys exceed a threshold (e.g. 300), since exhaustion is a machine-wide outage. +- The `--dangerously-bypass-approvals-and-sandbox` resume flag amplifies blast radius: hundreds of unattended, sandbox-less agents idling for days. Consider whether bulk background resume should ever use it. + +--- + +## 7. Verification plan + +1. **Repro (pre-fix):** in a background worktree, open an agent session, switch to another worktree (parking the pane), close the tab → `ps` shows the `login/zsh/codex` chain still alive and the helper still holds its ptmx fd. Restart Orca → the session is resumed again. +2. **Post-fix:** same steps → chain dies on tab close (Fix 1); record removed so restart resumes nothing (Fix 2). Origin-aware defensive expiry is verified separately when implemented (Fix 3). +3. **Regression:** worktree Sleep → wake still resumes sessions; quit → relaunch still warm-reattaches sessions whose tabs were _not_ closed; daemon warm-reattach unaffected. +4. **Soak:** count `Orca Helper` ptmx fds (`lsof -p | grep -c ptmx`) across a day of open/close/restart cycles — should stay flat. + +## 8. Immediate mitigation (ops, no code) + +- Use **Manage Sessions → "Kill orphan terminals"** or exact-session termination + to release the leaked ptys. A normal Orca.app restart is insufficient because + the persistent terminal daemon deliberately survives for warm reattach. +- `sudo sysctl kern.tty.ptmx_max=999` raises the cap to its macOS hard max — buys headroom only, hours at current leak rate. +- Restart the terminal daemon only when exact-session cleanup is unavailable; + this terminates all daemon-backed sessions, including legitimate in-flight work. + +--- + +## Appendix: diagnostic commands used + +```sh +ls /dev/ttys* | wc -l && sysctl kern.tty.ptmx_max # allocation vs cap +ps -ax -o pid,tty,etime,comm | grep -E 'ttys[0-9]+' # who holds ptys +lsof -a -d cwd -c codex | awk '{print $NF}' | sort | uniq -c | sort -rn # per-worktree counts +lsof -p | grep -c ptmx # master fds per Orca Helper +ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes +``` diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f8ae284a365..e956c04acd9 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1334,9 +1334,9 @@ function Terminal(): React.JSX.Element | null { if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { return } - handleCloseTab(tabId) + closeTerminalTab(tabId, { reason: 'pty-exit' }) }, - [consumeSuppressedPtyExit, handleCloseTab] + [consumeSuppressedPtyExit] ) const handleCloseOthers = useCallback( diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 228ddcbf53a..822c7207091 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -2227,7 +2227,7 @@ describe('FloatingTerminalPanel close behavior', () => { const terminalPane = findByTypeName(element, 'TerminalPane') ;(terminalPane.props.onPtyExit as () => void)() - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1') + expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'pty-exit' }) expect(onOpenChange).not.toHaveBeenCalled() mocks.closeTab.mockClear() diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 706e3d76e77..ee85d577804 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -1519,7 +1519,7 @@ export function FloatingTerminalPanel({ // atlas to corrupt) while hidden, and the resume on // reopen rebuilds the renderer from scratch. isVisible={isActive && open} - onPtyExit={() => closeTab(tab.id)} + onPtyExit={() => closeTab(tab.id, { reason: 'pty-exit' })} onCloseTab={() => closeFloatingItem(tab.id)} /> diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 9f7c8da1398..07027c00dd5 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -296,7 +296,7 @@ export function OnboardingInlineCommandTerminal({ isVisible onPtyExit={() => { onTerminalExit?.() - closeTab(tabId, { recordInteraction: false }) + closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' }) }} onCloseTab={() => closeTab(tabId, { recordInteraction: false })} /> diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index c30f5754327..af3468484ca 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -15,6 +15,7 @@ import { shouldMountBackgroundWorktreeTab } from '../terminal/background-termina import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' type TerminalOverlayAssignment = { unifiedTabId: string @@ -61,7 +62,7 @@ type TerminalOverlaySlotProps = { activityTerminalPortal: ActivityTerminalPortalTarget | null onFocusOwningGroup: ((groupId: string) => void) | undefined consumeSuppressedPtyExit: (ptyId: string) => boolean - closeTab: (tabId: string) => void + closeTab: (tabId: string, options?: { reason?: TerminalTabCloseReason }) => void leaveWorktreeIfEmpty: () => void } @@ -246,7 +247,7 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) { return } - closeTab(terminalTabId) + closeTab(terminalTabId, { reason: 'pty-exit' }) leaveWorktreeIfEmpty() }} onCloseTab={() => { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index d6dd76e817a..53086e28361 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -62,6 +62,13 @@ export function disposeParkedTabWatchers(tabId: string): void { entry.disposersByPtyId.clear() } +export function retireParkedTerminalTab(tabId: string): void { + // Why: explicit tab retirement permanently invalidates both live parked + // observers and unmounted-pane candidates; neither may reattach later. + disposeParkedTabWatchers(tabId) + capturedPanesByTabId.delete(tabId) +} + /** * Synchronously disposes any parked watcher subscribed to these PTYs. * shutdownWorktreeTerminals silences the live transports' final teardown diff --git a/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts b/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts new file mode 100644 index 00000000000..2c146991956 --- /dev/null +++ b/src/renderer/src/components/terminal/close-local-terminal-tab-state.ts @@ -0,0 +1,31 @@ +import { useAppStore } from '@/store' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' + +export function closeLocalTerminalTabState( + terminalTabId: string, + options?: { reason?: TerminalTabCloseReason; remoteCloseOwnedByHost?: boolean } +): void { + const state = useAppStore.getState() + if ( + Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId)) + ) { + if (options?.reason || options?.remoteCloseOwnedByHost) { + state.closeTab(terminalTabId, options) + } else { + state.closeTab(terminalTabId) + } + return + } + + for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) { + const unified = tabs.find( + (tab) => + tab.contentType === 'terminal' && + (tab.entityId === terminalTabId || tab.id === terminalTabId) + ) + if (unified) { + state.closeUnifiedTab(unified.id) + return + } + } +} diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index a070cdc9331..b42f5124ec6 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -197,7 +197,10 @@ describe('closeTerminalTab', () => { closeTerminalTab('local-tab-1') - expect(closeTab).toHaveBeenCalledWith('local-tab-1') + expect(closeTab).toHaveBeenCalledWith('local-tab-1', { + reason: undefined, + remoteCloseOwnedByHost: true + }) expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'host-tab-1', @@ -319,7 +322,10 @@ describe('closeTerminalTab', () => { closeTerminalTab('plain-uuid-tab') - expect(closeTab).toHaveBeenCalledWith('plain-uuid-tab') + expect(closeTab).toHaveBeenCalledWith('plain-uuid-tab', { + reason: undefined, + remoteCloseOwnedByHost: true + }) expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledWith({ worktreeId: 'wt-1', tabId: 'plain-uuid-tab', diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 9991b7648ea..bdf3b346d0f 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -12,6 +12,8 @@ import { import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' +import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' const EDITOR_TAB_CONTENT_TYPES = new Set([ 'editor', @@ -65,28 +67,6 @@ function getWorktreeTerminalTabIds(state: TerminalTabActionState, worktreeId: st return [...ids] } -function closeLocalTerminalTabState(terminalTabId: string): void { - const state = useAppStore.getState() - if ( - Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId)) - ) { - state.closeTab(terminalTabId) - return - } - - for (const tabs of Object.values(state.unifiedTabsByWorktree ?? {})) { - const unified = tabs.find( - (tab) => - tab.contentType === 'terminal' && - (tab.entityId === terminalTabId || tab.id === terminalTabId) - ) - if (unified) { - state.closeUnifiedTab(unified.id) - return - } - } -} - function isPinnedVisibleTab( state: TerminalTabActionState, worktreeId: string, @@ -149,7 +129,10 @@ export function createNewTerminalTab( state.setTabBarOrder(activeWorktreeId, order) } -export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void { +export function closeTerminalTab( + tabId: string, + options?: { force?: boolean; reason?: TerminalTabCloseReason } +): void { const state = useAppStore.getState() const target = resolveCloseTerminalTabTarget(state, tabId) if (!target) { @@ -159,7 +142,11 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): // Why: a pinned tab routes through the confirmation guard instead of closing // outright. `force` is the post-confirmation re-entry, which skips the guard. - if (!options?.force && isPinnedVisibleTab(state, owningWorktreeId, terminalTabId)) { + if ( + options?.reason !== 'pty-exit' && + !options?.force && + isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) + ) { guardPinnedTabClose({ isPinned: true, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), @@ -187,7 +174,10 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): }) ?? toHostSessionTabId(terminalTabId) // Why: prune local mirrors immediately so close feels responsive while the // host session snapshot catches up. - closeLocalTerminalTabState(terminalTabId) + closeLocalTerminalTabState(terminalTabId, { + reason: options?.reason, + remoteCloseOwnedByHost: true + }) void closeWebRuntimeSessionTab({ worktreeId: owningWorktreeId, tabId: hostBackedTabId, @@ -198,7 +188,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): const currentTerminalTabIds = getWorktreeTerminalTabIds(state, owningWorktreeId) if (currentTerminalTabIds.length <= 1) { - closeLocalTerminalTabState(terminalTabId) + closeLocalTerminalTabState(terminalTabId, { reason: options?.reason }) if (state.activeWorktreeId === owningWorktreeId) { // Why: only deactivate the worktree when no tabs of any kind remain. // Editor files are a separate tab type; closing the last terminal tab @@ -229,7 +219,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }): } } - closeLocalTerminalTabState(terminalTabId) + closeLocalTerminalTabState(terminalTabId, { reason: options?.reason }) } export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void { diff --git a/src/renderer/src/lib/agent-background-draft-delivery.ts b/src/renderer/src/lib/agent-background-draft-delivery.ts new file mode 100644 index 00000000000..745248f7239 --- /dev/null +++ b/src/renderer/src/lib/agent-background-draft-delivery.ts @@ -0,0 +1,17 @@ +import type { TuiAgent } from '../../../shared/types' +import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' +import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' + +export function scheduleAgentBackgroundDraft( + tabId: string, + content: string, + agent: TuiAgent +): void { + void pasteDraftWhenAgentReady({ + tabId, + content, + agent, + submit: true, + onTimeout: () => showAutomationPromptNotSentToast(agent) + }) +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 652fddb9bcf..1c24ae7683a 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -68,6 +68,7 @@ const state = { } ] }, + tabsByWorktree: { 'wt-1': [] as { id: string; title: string }[] }, allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1']), createTab: mockCreateTab, setTabCustomTitle: mockSetTabCustomTitle, @@ -143,7 +144,15 @@ describe('launchAgentBackgroundSession', () => { } ] } - mockCreateTab.mockReturnValue({ id: 'tab-1', title: 'Terminal 1' }) + state.tabsByWorktree = { 'wt-1': [] } + mockCreateTab.mockImplementation(() => { + const tab = { id: 'tab-1', title: 'Terminal 1' } + state.tabsByWorktree['wt-1'].push(tab) + return tab + }) + mockCloseTab.mockImplementation((tabId: string) => { + state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId) + }) mockSpawn.mockResolvedValue({ id: 'pty-1' }) mockRuntimeEnvironmentCall.mockResolvedValue({ ok: true, @@ -273,6 +282,84 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('kills a local PTY when its tab closes before spawn resolves', async () => { + let resolveSpawn!: (result: { id: string }) => void + mockSpawn.mockReturnValueOnce( + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const launch = launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run slowly' + }) + await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) + state.tabsByWorktree['wt-1'] = [] + resolveSpawn({ id: 'pty-after-close' }) + + await expect(launch).resolves.toBeNull() + expect(mockKill).toHaveBeenCalledWith('pty-after-close') + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockSubscribeToPtyData).not.toHaveBeenCalled() + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) + + it('closes a runtime terminal when its tab closes before creation resolves', async () => { + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } + let resolveCreate!: (result: { + ok: true + result: { terminal: { handle: string; worktreeId: string; title: null } } + }) => void + const createResult = new Promise<{ + ok: true + result: { terminal: { handle: string; worktreeId: string; title: null } } + }>((resolve) => { + resolveCreate = resolve + }) + mockRuntimeEnvironmentCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.create') { + return createResult + } + return Promise.resolve({ ok: true, result: {} }) + }) + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + const launch = launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run remotely' + }) + await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) + await vi.waitFor(() => + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.create' }) + ) + ) + state.tabsByWorktree['wt-1'] = [] + resolveCreate({ + ok: true, + result: { terminal: { handle: 'terminal-after-close', worktreeId: 'wt-1', title: null } } + }) + + await expect(launch).resolves.toBeNull() + expect(mockRuntimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.close', + params: { terminal: 'terminal-after-close' } + }) + ) + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockRuntimeEnvironmentSubscribe).not.toHaveBeenCalled() + expect(mockDispatchEvent).not.toHaveBeenCalled() + }) + it('records effective launch config returned by local PTY spawn', async () => { const effectiveLaunchConfig = { agentCommand: "claude '--dangerously-skip-permissions'", diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index edb4c6d9f68..bb2f102f681 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -7,8 +7,7 @@ import type { import { getAgentLaunchPlatformForRepo } from '@/lib/agent-launch-platform' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { tuiAgentToAgentKind } from '@/lib/telemetry' -import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' -import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' +import { scheduleAgentBackgroundDraft } from '@/lib/agent-background-draft-delivery' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { @@ -28,6 +27,7 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { retireProvider, retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' import { createBrowserUuid } from '@/lib/browser-uuid' import { subscribeToRuntimeTerminalData, @@ -115,12 +115,13 @@ export async function launchAgentBackgroundSession( const leafId = createBrowserUuid() const paneKey = makePaneKey(tab.id, leafId) const launchToken = createBrowserUuid() - store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, { + const launchRegistration = { agentType: agent, launchToken, tabId: tab.id, leafId - }) + } + store.registerAgentLaunchConfig(paneKey, startupPlan.launchConfig, launchRegistration) // Why: `title` labels the tab/worktree entry. Pane titles render as an // in-terminal title row, so background sessions must not persist it there. store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId)) @@ -148,6 +149,7 @@ export async function launchAgentBackgroundSession( ) let ptyId = '' let runtimeTerminalHandle: string | null = null + let returnedLaunchConfig: typeof startupPlan.launchConfig | undefined let exitHandled = false let eagerPtyBuffer: EagerPtyHandle | null = null let unsubscribeExit = (): void => {}, @@ -236,14 +238,25 @@ export async function launchAgentBackgroundSession( } }) ptyId = result.id - if (result.launchConfig) { - store.registerAgentLaunchConfig(paneKey, result.launchConfig, { - agentType: agent, - launchToken, - tabId: tab.id, - leafId - }) - } + returnedLaunchConfig = result.launchConfig + } + if ( + await retireUnownedTerminal({ + tabId: tab.id, + ptyId, + runtimeTarget, + runtimeTerminalHandle, + onRetire: () => { + exitHandled = true + sshStartupDelivery.clear() + store.clearAgentLaunchConfig(paneKey) + } + }) + ) { + return null + } + if (returnedLaunchConfig) { + store.registerAgentLaunchConfig(paneKey, returnedLaunchConfig, launchRegistration) } store.updateTabPtyId(tab.id, ptyId) store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) @@ -297,13 +310,7 @@ export async function launchAgentBackgroundSession( requestBackgroundTerminalWorktreeMount({ worktreeId, tabIds: [tab.id] }) if (pasteDraftAfterLaunch !== null) { - void pasteDraftWhenAgentReady({ - tabId: tab.id, - content: pasteDraftAfterLaunch, - agent, - submit: true, - onTimeout: () => showAutomationPromptNotSentToast(agent) - }) + scheduleAgentBackgroundDraft(tab.id, pasteDraftAfterLaunch, agent) } return { tabId: tab.id, paneKey, ptyId, startupPlan } @@ -317,17 +324,7 @@ export async function launchAgentBackgroundSession( runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId)) runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey)) if (ptyId) { - try { - if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) { - await callRuntimeRpc(runtimeTarget, 'terminal.close', { - terminal: runtimeTerminalHandle - }) - } else if (runtimeTarget.kind === 'local') { - await window.api.pty.kill(ptyId) - } - } catch { - // Best-effort close; retiring the invalid hidden tab must still proceed. - } + await retireProvider({ ptyId, runtimeTarget, runtimeTerminalHandle }) } runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false })) throw error diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index a33671bf9df..10dce9d658a 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockSpawn = vi.fn() +const mockKill = vi.fn() const mockCreateTab = vi.fn() const mockSetTabCustomTitle = vi.fn() const mockSetTabColor = vi.fn() @@ -31,6 +32,7 @@ const state = { } ] }, + tabsByWorktree: { 'wt-1': [] as { id: string }[] }, allWorktrees: vi.fn(() => state.worktreesByRepo['repo-1'] ?? []), createTab: mockCreateTab, setTabCustomTitle: mockSetTabCustomTitle, @@ -78,15 +80,24 @@ describe('launchWorktreeBackgroundTerminals', () => { displayName: 'Worktree' } ] + state.tabsByWorktree = { 'wt-1': [] } let tabIndex = 0 - mockCreateTab.mockImplementation(() => ({ id: `tab-${++tabIndex}` })) + mockCreateTab.mockImplementation(() => { + const tab = { id: `tab-${++tabIndex}` } + state.tabsByWorktree['wt-1'].push(tab) + return tab + }) + mockCloseTab.mockImplementation((tabId: string) => { + state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId) + }) let ptyIndex = 0 mockSpawn.mockImplementation(async () => ({ id: `pty-${++ptyIndex}` })) mockGetActiveRuntimeTarget.mockReturnValue({ kind: 'local' }) vi.stubGlobal('window', { api: { pty: { - spawn: mockSpawn + spawn: mockSpawn, + kill: mockKill } } }) @@ -288,4 +299,63 @@ describe('launchWorktreeBackgroundTerminals', () => { expect(mockCreateTab).not.toHaveBeenCalled() expect(mockSpawn).not.toHaveBeenCalled() }) + + it('kills a PTY whose tab is closed before the spawn resolves', async () => { + let resolveSpawn!: (result: { id: string }) => void + mockSpawn.mockReturnValueOnce( + new Promise<{ id: string }>((resolve) => { + resolveSpawn = resolve + }) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + const launch = launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + defaultTabs: { runCommands: true, tabs: [{ command: 'pnpm dev' }] } + }) + await vi.waitFor(() => expect(mockCreateTab).toHaveBeenCalledOnce()) + state.tabsByWorktree['wt-1'] = [] + resolveSpawn({ id: 'pty-after-close' }) + await launch + + expect(mockKill).toHaveBeenCalledWith('pty-after-close') + expect(mockUpdateTabPtyId).not.toHaveBeenCalled() + expect(mockRegisterEagerPtyBuffer).not.toHaveBeenCalled() + warn.mockRestore() + }) + + it('kills a late setup split PTY when its parent tab closes', async () => { + state.settings = { activeRuntimeEnvironmentId: null, setupScriptLaunchMode: 'split-horizontal' } + let resolveSetupSpawn!: (result: { id: string }) => void + mockSpawn.mockResolvedValueOnce({ id: 'pty-primary' }).mockReturnValueOnce( + new Promise<{ id: string }>((resolve) => { + resolveSetupSpawn = resolve + }) + ) + const { launchWorktreeBackgroundTerminals } = + await import('./launch-worktree-background-terminals') + + const launch = launchWorktreeBackgroundTerminals({ + worktreeId: 'wt-1', + setup: setupLaunch + }) + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2)) + state.closeTab('tab-1') + resolveSetupSpawn({ id: 'pty-setup-after-close' }) + await launch + + expect(mockKill).toHaveBeenCalledWith('pty-setup-after-close') + expect(mockUpdateTabPtyId).toHaveBeenCalledTimes(1) + expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-primary') + expect(mockSetTabLayout).not.toHaveBeenCalledWith( + 'tab-1', + expect.objectContaining({ + ptyIdsByLeafId: expect.objectContaining({ + '00000000-0000-4000-8000-000000000002': 'pty-setup-after-close' + }) + }) + ) + }) }) diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 896da11f8e5..568f68e6f22 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -6,6 +6,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' +import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' @@ -180,6 +181,15 @@ async function createBackgroundTab(args: { store.closeTab(tab.id, { recordInteraction: false }) throw error } + if ( + await retireUnownedTerminal({ + tabId: tab.id, + ptyId, + runtimeTarget: { kind: 'local' } + }) + ) { + throw new Error('The terminal tab was closed before its session finished starting.') + } store.updateTabPtyId(tab.id, ptyId) store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId, ptyId)) registerBackgroundPaneBuffer(tab.id, leafId, ptyId) @@ -203,6 +213,15 @@ async function addSetupSplit(args: { command: buildSetupCommand(args.setup), env: args.setup.envVars }) + if ( + await retireUnownedTerminal({ + tabId: args.tab.tabId, + ptyId: setupPtyId, + runtimeTarget: { kind: 'local' } + }) + ) { + return + } store.updateTabPtyId(args.tab.tabId, setupPtyId) store.setTabLayout( args.tab.tabId, diff --git a/src/renderer/src/lib/retire-unowned-background-terminal.ts b/src/renderer/src/lib/retire-unowned-background-terminal.ts new file mode 100644 index 00000000000..7a7f128125a --- /dev/null +++ b/src/renderer/src/lib/retire-unowned-background-terminal.ts @@ -0,0 +1,38 @@ +import { useAppStore } from '@/store' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { isTerminalTabPresent } from '@/store/slices/terminal-tab-retirement' + +export async function retireUnownedTerminal(args: { + tabId: string + ptyId: string + runtimeTarget: RuntimeClientTarget + runtimeTerminalHandle?: string | null + onRetire?: () => void +}): Promise { + if (isTerminalTabPresent(useAppStore.getState(), args.tabId)) { + return false + } + // Why: close can win while provider creation is in flight, before the + // returned handle is bindable to store state or visible to tab retirement. + args.onRetire?.() + await retireProvider(args) + return true +} + +export async function retireProvider(args: { + ptyId: string + runtimeTarget: RuntimeClientTarget + runtimeTerminalHandle?: string | null +}): Promise { + try { + if (args.runtimeTarget.kind === 'environment' && args.runtimeTerminalHandle) { + await callRuntimeRpc(args.runtimeTarget, 'terminal.close', { + terminal: args.runtimeTerminalHandle + }) + } else if (args.runtimeTarget.kind === 'local') { + await window.api.pty.kill(args.ptyId) + } + } catch { + // Best-effort provider teardown; the retired tab must not be recreated. + } +} diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts new file mode 100644 index 00000000000..a511acc17ad --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' + +const mockKill = vi.fn().mockResolvedValue(undefined) +const mockRuntimeCall = vi.fn().mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'local-runtime' } +}) + +vi.stubGlobal('window', { + api: { + pty: { kill: mockKill }, + runtime: { call: mockRuntimeCall }, + runtimeEnvironments: { call: vi.fn() } + } +}) + +import { + capturedPanesByTabId, + parkedWatchersByTabId +} from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { createTestStore, makeTab, seedStore } from './store-test-helpers' + +function sleepingRecord(paneKey: string, tabId: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId, + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: paneKey }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +describe('terminal tab retirement store boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + mockKill.mockResolvedValue(undefined) + mockRuntimeCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'local-runtime' } + }) + parkedWatchersByTabId.clear() + capturedPanesByTabId.clear() + }) + + it('retires split, relay, deferred, and pending sessions for a parked tab', async () => { + const store = createTestStore() + const dispose = vi.fn() + const siblingRecord = sleepingRecord('tab-2:leaf-2', 'tab-2') + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-primary' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-primary', 'pty-split'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf1: 'pty-primary', leaf2: 'pty-split' } + } + }, + lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@relay' }, + deferredSshSessionIdsByTabId: { 'tab-1': 'pty-deferred' }, + pendingReconnectPtyIdByTabId: { 'tab-1': 'pty-pending' }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': sleepingRecord('tab-1:leaf-1', 'tab-1'), + 'legacy-key': sleepingRecord('legacy-key', 'tab-1'), + 'tab-2:leaf-2': siblingRecord + } + }) + parkedWatchersByTabId.set('tab-1', { + worktreeId: 'wt-1', + tabPtyId: 'pty-primary', + paneIdByPtyId: new Map([['pty-primary', 1]]), + disposersByPtyId: new Map([['pty-primary', dispose]]) + }) + capturedPanesByTabId.set('tab-1', { worktreeId: 'wt-1', panes: [] }) + + store.getState().closeTab('tab-1') + await vi.waitFor(() => expect(mockKill).toHaveBeenCalledTimes(5)) + + expect(new Set(mockKill.mock.calls.map(([ptyId]) => ptyId))).toEqual( + new Set(['pty-primary', 'pty-split', 'ssh:ssh-1@@relay', 'pty-deferred', 'pty-pending']) + ) + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + expect(store.getState().deferredSshSessionIdsByTabId['tab-1']).toBeUndefined() + expect(store.getState().pendingReconnectPtyIdByTabId['tab-1']).toBeUndefined() + expect(store.getState().sleepingAgentSessionsByPaneKey).toEqual({ + 'tab-2:leaf-2': siblingRecord + }) + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-2:leaf-2']).toBe(siblingRecord) + expect(dispose).toHaveBeenCalledOnce() + expect(parkedWatchersByTabId.has('tab-1')).toBe(false) + expect(capturedPanesByTabId.has('tab-1')).toBe(false) + }) + + it('routes runtime handles to runtime close and preserves shared PTYs', async () => { + const store = createTestStore() + seedStore(store, { + tabsByWorktree: { + 'wt-1': [ + makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'remote:terminal-1' }), + makeTab({ id: 'tab-2', worktreeId: 'wt-1', ptyId: 'pty-shared' }) + ] + }, + ptyIdsByTabId: { + 'tab-1': ['remote:terminal-1', 'pty-shared'], + 'tab-2': ['pty-shared'] + } + }) + + store.getState().closeTab('tab-1') + await vi.waitFor(() => expect(mockRuntimeCall).toHaveBeenCalled()) + + expect(mockRuntimeCall).toHaveBeenCalledWith({ + method: 'terminal.close', + params: { terminal: 'terminal-1' } + }) + expect(mockKill).not.toHaveBeenCalled() + }) + + it('reconciles natural exit without issuing teardown or revoking resume authority', async () => { + const store = createTestStore() + const record = sleepingRecord('tab-1:leaf-1', 'tab-1') + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-dead' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-dead'] }, + sleepingAgentSessionsByPaneKey: { 'tab-1:leaf-1': record } + }) + + store.getState().closeTab('tab-1', { reason: 'pty-exit' }) + await Promise.resolve() + + expect(mockKill).not.toHaveBeenCalled() + expect(mockRuntimeCall).not.toHaveBeenCalled() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(record) + }) + + it('does not recreate PTY indexes for a tab that no longer exists', () => { + const store = createTestStore() + + store.getState().updateTabPtyId('closed-tab', 'pty-after-close') + + expect(store.getState().ptyIdsByTabId['closed-tab']).toBeUndefined() + expect(store.getState().lastKnownRelayPtyIdByTabId['closed-tab']).toBeUndefined() + }) + + it('lets a paired host own runtime teardown while pruning local state', async () => { + const store = createTestStore() + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'remote:terminal-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['remote:terminal-1'] } + }) + + store.getState().closeTab('tab-1', { remoteCloseOwnedByHost: true }) + await Promise.resolve() + + expect(mockRuntimeCall).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + }) + + it('keeps the tab retired and reports provider rejection without an unhandled promise', async () => { + const store = createTestStore() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockKill.mockRejectedValueOnce(new Error('provider unavailable')) + seedStore(store, { + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-1' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }) + + store.getState().closeTab('tab-1') + await vi.waitFor(() => + expect(warn).toHaveBeenCalledWith('[terminal-retirement] provider teardown failed', { + tabId: 'tab-1', + localOrSshFailures: 1, + runtimeFailures: 0 + }) + ) + + expect(store.getState().tabsByWorktree['wt-1']).toEqual([]) + warn.mockRestore() + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts new file mode 100644 index 00000000000..a0142f752e1 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-retirement.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../../shared/types' +import { + buildTerminalTabRetirementPlan, + isTerminalTabPresent, + removeSleepingAgentSessionsForTab +} from './terminal-tab-retirement' + +type RetirementState = Parameters[0] + +function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { + return { + id, + worktreeId, + ptyId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeState(overrides: Partial = {}): RetirementState { + return { + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + deferredSshSessionIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {}, + ...overrides + } +} + +function makeSleepingRecord(paneKey: string, tabId?: string): SleepingAgentSessionRecord { + return { + paneKey, + tabId, + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: paneKey }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +describe('terminal tab retirement planning', () => { + it('collects and deduplicates every ownership source before routing providers', () => { + const state = makeState({ + tabsByWorktree: { + 'wt-1': [makeTab('tab-1', 'wt-1', 'pty-row')] + }, + ptyIdsByTabId: { 'tab-1': ['pty-index', 'pty-row'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { + leaf1: 'pty-layout', + leaf2: 'remote:env-1@@terminal-1' + } + } + }, + lastKnownRelayPtyIdByTabId: { 'tab-1': 'ssh:ssh-1@@relay-pty' }, + deferredSshSessionIdsByTabId: { 'tab-1': 'pty-deferred' }, + pendingReconnectPtyIdByTabId: { 'tab-1': 'pty-pending' } + }) + + expect(buildTerminalTabRetirementPlan(state, 'tab-1')).toEqual({ + tabId: 'tab-1', + worktreeId: 'wt-1', + ptyIds: [ + 'pty-index', + 'pty-row', + 'pty-layout', + 'remote:env-1@@terminal-1', + 'ssh:ssh-1@@relay-pty', + 'pty-deferred', + 'pty-pending' + ], + localOrSshPtyIds: [ + 'pty-index', + 'pty-row', + 'pty-layout', + 'ssh:ssh-1@@relay-pty', + 'pty-deferred', + 'pty-pending' + ], + runtimeTerminals: [ + { + ptyId: 'remote:env-1@@terminal-1', + environmentId: 'env-1', + handle: 'terminal-1' + } + ], + sharedPtyIds: [], + unroutablePtyIds: [] + }) + expect(isTerminalTabPresent(state, 'tab-1')).toBe(true) + }) + + it('does not retire a PTY still referenced by another live surface', () => { + const shared = 'pty-in-transfer' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [makeTab('tab-1', 'wt-1', shared), makeTab('tab-2', 'wt-1', null)] + }, + ptyIdsByTabId: { 'tab-1': [shared], 'tab-2': [shared] }, + terminalLayoutsByTabId: { + 'tab-2': { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: { leaf2: shared } + } + } + }) + + const plan = buildTerminalTabRetirementPlan(state, 'tab-1') + expect(plan.sharedPtyIds).toEqual([shared]) + expect(plan.localOrSshPtyIds).toEqual([]) + expect(plan.runtimeTerminals).toEqual([]) + }) + + it('ignores stale ownership maps and never routes malformed remote ids locally', () => { + const malformedRemote = 'remote:' + const state = makeState({ + tabsByWorktree: { + 'wt-1': [makeTab('tab-1', 'wt-1', malformedRemote)] + }, + ptyIdsByTabId: { + 'tab-1': [malformedRemote, 'pty-live'], + 'stale-tab': ['pty-live'] + } + }) + + const plan = buildTerminalTabRetirementPlan(state, 'tab-1') + expect(plan.unroutablePtyIds).toEqual([malformedRemote]) + expect(plan.localOrSshPtyIds).toEqual(['pty-live']) + expect(plan.sharedPtyIds).toEqual([]) + }) +}) + +describe('sleeping agent retirement', () => { + it('removes key- and metadata-owned records while preserving siblings by reference', () => { + const sibling = makeSleepingRecord('tab-2:leaf-2', 'tab-2') + const records = { + 'tab-1:leaf-1': makeSleepingRecord('tab-1:leaf-1'), + 'legacy-pane-key': makeSleepingRecord('legacy-pane-key', 'tab-1'), + 'tab-2:leaf-2': sibling + } + + const next = removeSleepingAgentSessionsForTab(records, 'tab-1') + expect(next).toEqual({ 'tab-2:leaf-2': sibling }) + expect(next['tab-2:leaf-2']).toBe(sibling) + expect(removeSleepingAgentSessionsForTab(next, 'missing-tab')).toBe(next) + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-retirement.ts b/src/renderer/src/store/slices/terminal-tab-retirement.ts new file mode 100644 index 00000000000..5efdbaf5bf7 --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -0,0 +1,184 @@ +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { AppState } from '../types' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' + +export type TerminalTabCloseReason = 'user' | 'cleanup' | 'pty-exit' + +type TerminalTabRetirementState = Pick< + AppState, + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' + | 'lastKnownRelayPtyIdByTabId' + | 'deferredSshSessionIdsByTabId' + | 'pendingReconnectPtyIdByTabId' +> + +export type TerminalTabRetirementPlan = { + tabId: string + worktreeId: string | null + ptyIds: string[] + localOrSshPtyIds: string[] + runtimeTerminals: { + ptyId: string + environmentId: string | null + handle: string + }[] + sharedPtyIds: string[] + unroutablePtyIds: string[] +} + +function appendPtyId(ids: Set, ptyId: string | null | undefined): void { + if (ptyId) { + ids.add(ptyId) + } +} + +function collectTerminalTabPtyIds(state: TerminalTabRetirementState, tabId: string): string[] { + const ids = new Set() + for (const ptyId of state.ptyIdsByTabId[tabId] ?? []) { + appendPtyId(ids, ptyId) + } + for (const tabs of Object.values(state.tabsByWorktree)) { + appendPtyId(ids, tabs.find((tab) => tab.id === tabId)?.ptyId) + } + + for (const ptyId of Object.values(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})) { + appendPtyId(ids, ptyId) + } + appendPtyId(ids, state.lastKnownRelayPtyIdByTabId[tabId]) + appendPtyId(ids, state.deferredSshSessionIdsByTabId[tabId]) + appendPtyId(ids, state.pendingReconnectPtyIdByTabId[tabId]) + return [...ids] +} + +function referencesPtyFromAnotherTab( + state: TerminalTabRetirementState, + closingTabId: string, + ptyId: string +): boolean { + for (const tabs of Object.values(state.tabsByWorktree)) { + if (tabs.some((tab) => tab.id !== closingTabId && tab.ptyId === ptyId)) { + return true + } + } + + const liveTabIds = new Set() + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + liveTabIds.add(tab.id) + } + } + for (const tabs of Object.values(state.unifiedTabsByWorktree)) { + for (const tab of tabs) { + if (tab.contentType === 'terminal') { + liveTabIds.add(tab.entityId) + } + } + } + + const indexedMaps: readonly Record[] = [ + state.ptyIdsByTabId, + state.lastKnownRelayPtyIdByTabId, + state.deferredSshSessionIdsByTabId, + state.pendingReconnectPtyIdByTabId + ] + for (const indexedMap of indexedMaps) { + for (const [tabId, value] of Object.entries(indexedMap)) { + if (tabId === closingTabId || !liveTabIds.has(tabId)) { + continue + } + if (Array.isArray(value) ? value.includes(ptyId) : value === ptyId) { + return true + } + } + } + + for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) { + if ( + tabId !== closingTabId && + liveTabIds.has(tabId) && + Object.values(layout.ptyIdsByLeafId ?? {}).includes(ptyId) + ) { + return true + } + } + return false +} + +export function isTerminalTabPresent( + state: Pick, + tabId: string +): boolean { + return Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === tabId)) +} + +export function buildTerminalTabRetirementPlan( + state: TerminalTabRetirementState, + tabId: string +): TerminalTabRetirementPlan { + const worktreeId = + Object.entries(state.tabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.id === tabId) + )?.[0] ?? + Object.entries(state.unifiedTabsByWorktree).find(([, tabs]) => + tabs.some((tab) => tab.contentType === 'terminal' && tab.entityId === tabId) + )?.[0] ?? + null + const ptyIds = collectTerminalTabPtyIds(state, tabId) + const sharedPtyIds: string[] = [] + const localOrSshPtyIds: string[] = [] + const runtimeTerminals: TerminalTabRetirementPlan['runtimeTerminals'] = [] + const unroutablePtyIds: string[] = [] + + for (const ptyId of ptyIds) { + if (referencesPtyFromAnotherTab(state, tabId, ptyId)) { + sharedPtyIds.push(ptyId) + continue + } + const remote = parseRemoteRuntimePtyId(ptyId) + if (remote) { + if (!remote.handle) { + unroutablePtyIds.push(ptyId) + continue + } + runtimeTerminals.push({ + ptyId, + environmentId: remote.environmentId?.trim() || null, + handle: remote.handle + }) + } else if (ptyId.startsWith('remote:')) { + unroutablePtyIds.push(ptyId) + } else { + localOrSshPtyIds.push(ptyId) + } + } + + return { + tabId, + worktreeId, + ptyIds, + localOrSshPtyIds, + runtimeTerminals, + sharedPtyIds, + unroutablePtyIds + } +} + +export function removeSleepingAgentSessionsForTab( + records: Record, + tabId: string +): Record { + let next = records + for (const [paneKey, record] of Object.entries(records)) { + if (!paneKey.startsWith(`${tabId}:`) && record.tabId !== tabId) { + continue + } + if (next === records) { + next = { ...records } + } + delete next[paneKey] + } + return next +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index be20477bc78..a68dde715e1 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -67,7 +67,10 @@ import { // Why: import the store-free registry, not terminal-parked-tab-watchers — // that module imports @/store, and a slice importing it would re-enter store // creation before this slice finishes evaluating. -import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' +import { + disposeParkedTerminalWatchersForPtyIds, + retireParkedTerminalTab +} from '@/components/terminal-pane/terminal-parked-watcher-registry' import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId @@ -94,6 +97,12 @@ import { removeSleepingRecordsReplacedByManualWorktreeSleep, type AgentStatusWorktreeShutdownReason } from './agent-status' +import { + buildTerminalTabRetirementPlan, + isTerminalTabPresent, + removeSleepingAgentSessionsForTab, + type TerminalTabCloseReason +} from './terminal-tab-retirement' function getNextTerminalOrdinal(tabs: TerminalTab[]): number { const usedOrdinals = new Set() @@ -593,7 +602,14 @@ export type TerminalSlice = { } ) => TerminalTab openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise - closeTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void + closeTab: ( + tabId: string, + opts?: { + recordInteraction?: boolean + reason?: TerminalTabCloseReason + remoteCloseOwnedByHost?: boolean + } + ) => void reorderTabs: (worktreeId: string, tabIds: string[]) => void setTabBarOrder: (worktreeId: string, order: string[]) => void setActiveTab: (tabId: string) => void @@ -1197,7 +1213,59 @@ export const createTerminalSlice: StateCreator }, closeTab: (tabId, opts) => { + const closeReason = opts?.reason ?? 'user' + const retiresSession = closeReason === 'user' || closeReason === 'cleanup' + const retirementPlan = buildTerminalTabRetirementPlan(get(), tabId) let closingWorktreeId: string | null = null + + // Why: a parked tab has no mounted TerminalPane cleanup. Retirement must + // synchronously revoke its observer/candidate state before provider exit races. + retireParkedTerminalTab(tabId) + if (retiresSession) { + const fallbackRuntimeEnvironmentId = retirementPlan.worktreeId + ? getRuntimeEnvironmentIdForWorktree(get(), retirementPlan.worktreeId) + : null + const retirementTasks: Promise[] = retirementPlan.localOrSshPtyIds.map( + async (ptyId) => window.api.pty.kill(ptyId) + ) + const localOrSshTaskCount = retirementTasks.length + if (!opts?.remoteCloseOwnedByHost) { + for (const terminal of retirementPlan.runtimeTerminals) { + const environmentId = terminal.environmentId ?? fallbackRuntimeEnvironmentId + retirementTasks.push( + callRuntimeRpc( + environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, + 'terminal.close', + { terminal: terminal.handle } + ) + ) + } + } + if (retirementPlan.unroutablePtyIds.length > 0) { + console.warn('[terminal-retirement] skipped unroutable runtime handles', { + tabId, + count: retirementPlan.unroutablePtyIds.length + }) + } + // Why: close remains synchronous and idempotent; provider failures must + // not reject into the UI or prevent renderer ownership from being revoked. + void Promise.allSettled(retirementTasks).then((results) => { + const localOrSshFailures = results + .slice(0, localOrSshTaskCount) + .filter((result) => result.status === 'rejected').length + const runtimeFailures = results + .slice(localOrSshTaskCount) + .filter((result) => result.status === 'rejected').length + if (localOrSshFailures > 0 || runtimeFailures > 0) { + console.warn('[terminal-retirement] provider teardown failed', { + tabId, + localOrSshFailures, + runtimeFailures + }) + } + }) + } + set((s) => { const next = { ...s.tabsByWorktree } let closingPtyId: string | null = null @@ -1225,6 +1293,10 @@ export const createTerminalSlice: StateCreator delete nextPtyIdsByTabId[tabId] const nextLastKnownRelay = { ...s.lastKnownRelayPtyIdByTabId } delete nextLastKnownRelay[tabId] + const nextDeferredSshSessionIdsByTabId = { ...s.deferredSshSessionIdsByTabId } + delete nextDeferredSshSessionIdsByTabId[tabId] + const nextPendingReconnectPtyIdByTabId = { ...s.pendingReconnectPtyIdByTabId } + delete nextPendingReconnectPtyIdByTabId[tabId] const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } delete nextRuntimePaneTitlesByTabId[tabId] // Why: preserve the unreadTerminalTabs reference when the closing tab had @@ -1260,6 +1332,9 @@ export const createTerminalSlice: StateCreator delete nextLastTerminalInputAtByPaneKey[paneKey] } } + const nextSleepingAgentSessionsByPaneKey = retiresSession + ? removeSleepingAgentSessionsForTab(s.sleepingAgentSessionsByPaneKey, tabId) + : s.sleepingAgentSessionsByPaneKey const nextPendingStartupByTabId = { ...s.pendingStartupByTabId } delete nextPendingStartupByTabId[tabId] const nextAutomaticAgentResumeClaimsByTabId = { ...s.automaticAgentResumeClaimsByTabId } @@ -1307,14 +1382,20 @@ export const createTerminalSlice: StateCreator // prevent unbounded store growth across restarts. let nextSnapshots = s.pendingSnapshotByPtyId let nextColdRestores = s.pendingColdRestoreByPtyId - if (closingPtyId) { - if (closingPtyId in nextSnapshots) { + const closingPtyIds = new Set([ + ...retirementPlan.localOrSshPtyIds, + ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), + ...retirementPlan.unroutablePtyIds, + ...(closingPtyId ? [closingPtyId] : []) + ]) + for (const closingId of closingPtyIds) { + if (closingId in nextSnapshots) { nextSnapshots = { ...nextSnapshots } - delete nextSnapshots[closingPtyId] + delete nextSnapshots[closingId] } - if (closingPtyId in nextColdRestores) { + if (closingId in nextColdRestores) { nextColdRestores = { ...nextColdRestores } - delete nextColdRestores[closingPtyId] + delete nextColdRestores[closingId] } } @@ -1324,7 +1405,12 @@ export const createTerminalSlice: StateCreator activeTabIdByWorktree: nextActiveTabIdByWorktree, ptyIdsByTabId: nextPtyIdsByTabId, lastKnownRelayPtyIdByTabId: nextLastKnownRelay, + deferredSshSessionIdsByTabId: nextDeferredSshSessionIdsByTabId, + pendingReconnectPtyIdByTabId: nextPendingReconnectPtyIdByTabId, runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + ...(nextSleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey: nextSleepingAgentSessionsByPaneKey } + : {}), // Why: skip writing unreadTerminalTabs when the reference is unchanged — // avoids a no-op top-level state allocation that would force re-evaluation // of full-state selectors. Mirrors the sibling pattern in tabs.ts. @@ -1872,6 +1958,11 @@ export const createTerminalSlice: StateCreator }, updateTabPtyId: (tabId, ptyId) => { + // Why: async spawn owners must perform provider teardown themselves, but + // this final guard prevents any late caller from recreating retired tab maps. + if (!isTerminalTabPresent(get(), tabId)) { + return + } let worktreeId: string | null = null let wasActivationSpawn = false const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId)