fix(terminal): retire sessions when tabs close

This commit is contained in:
Brennan Benson
2026-07-13 17:23:28 -07:00
parent 83588b43a7
commit 097708b7b7
21 changed files with 1705 additions and 76 deletions
@@ -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.
+178
View File
@@ -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 <session-id>` 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 <uuid>` — 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 <id>`) 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 <id>` (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 <helper> | 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 <helper-pid> | grep -c ptmx # master fds per Orca Helper
ps -ax -o args | grep -oE '[0-9a-f-]{36}' | sort | uniq -c # duplicate session resumes
```
+2 -2
View File
@@ -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(
@@ -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()
@@ -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)}
/>
</div>
@@ -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 })}
/>
@@ -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={() => {
@@ -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
@@ -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
}
}
}
@@ -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',
@@ -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<TabContentType>([
'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 {
@@ -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)
})
}
@@ -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'",
@@ -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
@@ -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'
})
})
)
})
})
@@ -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,
@@ -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<boolean> {
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<void> {
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.
}
}
@@ -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()
})
})
@@ -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<typeof buildTerminalTabRetirementPlan>[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> = {}): 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)
})
})
@@ -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<string>, ptyId: string | null | undefined): void {
if (ptyId) {
ids.add(ptyId)
}
}
function collectTerminalTabPtyIds(state: TerminalTabRetirementState, tabId: string): string[] {
const ids = new Set<string>()
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<string>()
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<string, string | string[] | undefined>[] = [
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<AppState, 'tabsByWorktree'>,
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<string, SleepingAgentSessionRecord>,
tabId: string
): Record<string, SleepingAgentSessionRecord> {
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
}
+98 -7
View File
@@ -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<number>()
@@ -593,7 +602,14 @@ export type TerminalSlice = {
}
) => TerminalTab
openNewTerminalTabInActiveWorkspace: (groupId: string) => Promise<void>
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<AppState, [], [], TerminalSlice>
},
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<unknown>[] = 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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
// 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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
},
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)