mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
fix(terminal): retire sessions when tabs close
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user