# Terminal Session Ownership and Teardown **Status:** Incident fix and pane-authority transfer implemented **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 The incident patch ships the retirement planner, provider-aware close routing, unified-only retirement, parked watcher/candidate disposal, resume-authority revocation, explicit natural-exit handling, late-binding rejection, and direct background-launch ownership checks. It also adds exact pane retirement and a persisted physical-to-owner pane-key alias for detach, plus a live Electron parked-close gate. Focused tests cover local, SSH, split, parked, shared, paired-host, ordinary runtime, pane-transfer, and late-spawn paths. `orca terminal close --tab` extends that same ownership boundary to automation. With an attached renderer, main sends a request/reply close, the renderer runs the canonical retirement path, persists a fresh host-partitioned session, and forces the main store to disk before acknowledging success. Without a renderer, the runtime applies the equivalent immutable projection to the persisted aggregate tab, removes every split binding and resume record, safely selects a surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, and flushes before returning. The ordinary `terminal close` pane/session contract is unchanged. This durability boundary does not use replay fences or tombstones. Renderer session writes are ordered through the same IPC sender, and a pending debounced write rebuilds from the latest store state, so the forced fresh snapshot is not followed by a captured pre-close topology patch. Headless closes update the single main-owned session projection directly. 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 its PTY, hook identity, and resume authority under the new owning pane. 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 a plain user terminal's process group (nohup-style survivors remain user intent there). For **agent sessions**, close/kill additionally terminates the snapshotted descendant tree — including detached-pgid children the PTY's SIGHUP cannot reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL). Completed process tables are never reused as signal targets, and identity checks use C-locale timestamps from the source scan. Later requests start a fresh same-turn-coalesced successor inside their own deadline instead of waiting behind older scans. A session is marked as terminating before capture and keeps request ownership through natural exit, so reattach, duplicate kill, and graceful-to-immediate upgrade cannot race the snapshot; descendant signals still require the exact root session/handle to be live. Windows and SSH-hosted PTYs keep the previous foreground-tree contract for now. - 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 with explicit pane authority: - `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: remaining 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. Keep explicit `detach` and `sleep` APIs; do not encode them as close options. 4. Add a development assertion when tab state is removed with provider PTY IDs but no retirement plan. Exact pane retirement, persisted detach authority transfer, and unified-only terminal routing landed with Phase 1 because exact-head review found they were required to avoid introducing adjacent close regressions. ### 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 - 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.