mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/terminal-reveal-self-heal
This commit is contained in:
+4
-4
@@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md
|
||||
validation-screenshots/
|
||||
.stably-browser
|
||||
|
||||
# PR verification evidence screenshots are referenced from notes but should not
|
||||
# be committed.
|
||||
notes/artifacts/
|
||||
# Local scratch notes and PR evidence screenshots (not part of the product).
|
||||
/notes/
|
||||
/pr-evidence/
|
||||
|
||||
# Playwright
|
||||
test-results/
|
||||
@@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
|
||||
# Bench result JSONs are working artifacts
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
@@ -590,7 +590,7 @@
|
||||
"https://github.com/stablyai/orca/issues/8001"
|
||||
],
|
||||
"invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.",
|
||||
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
|
||||
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending and between bounded close batches; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts"
|
||||
],
|
||||
@@ -609,7 +609,7 @@
|
||||
"current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion",
|
||||
"the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep",
|
||||
"management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics",
|
||||
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms"
|
||||
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, and 49 event-loop yields; ownership is revalidated at most once after each yield when the store changed"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -658,11 +658,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed."
|
||||
"evidence": "The initial 100-tab implementation exposed an oversized close batch; two-close event-loop batching fixed the structural issue. The gate now uses deterministic batch instrumentation because full-suite CPU saturation made wall-clock assertions flaky; saved CI artifacts and intentional-break evidence are still needed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms."
|
||||
"evidence": "The coordinator performs one management sweep, builds an initial live-owner index, and revalidates at most once after each two-close yield when the Zustand state changed (at most 49 replans for the 100-tab fixture). It closes each present unique target once and sends at most one exact kill per unique current non-runtime PTY not already settled by daemon management. The fixture asserts 100 close attempts, 100 local kill calls, at least 100 store writes, and 49 yields; planner-level tests prove each individual plan build scans terminal and unified ownership stores once. Production diagnostics report measured close-batch duration, while the deterministic gate makes no machine-load-sensitive latency claim."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
@@ -676,7 +676,124 @@
|
||||
"Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.",
|
||||
"Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug."
|
||||
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, duplicates provider shutdown, or performs more than one ownership replan per bounded yield."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.explicit-close-retirement",
|
||||
"title": "Explicit terminal close retires parked PTYs and agent authority exactly once",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "main-preload-renderer-electron-contract",
|
||||
"surfaces": [
|
||||
"terminal tab close",
|
||||
"split pane close and detach",
|
||||
"hidden terminal parking",
|
||||
"agent resume authority"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"runtime"
|
||||
],
|
||||
"coverageNotes": "A live macOS Electron test proves exact local PTY disappearance after a parked-tab close. Deterministic unit/store tests cover daemon and SSH routing, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/8628"
|
||||
],
|
||||
"invariant": "Close permanently removes the owned provider session and resume authority even when no TerminalPane is mounted; detach and park preserve both; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; unit tests assert canonical owner dedupe, one teardown owner, exact pane tombstones, chained detach transfer, and restart alias restoration.",
|
||||
"commands": [
|
||||
"pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/agent-hooks/server-pane-authority.test.ts",
|
||||
"src/main/ipc/agent-hooks.test.ts",
|
||||
"src/main/ipc/agent-pane-authority-ownership.test.ts",
|
||||
"src/main/ipc/pty-management.test.ts",
|
||||
"src/main/persistence.test.ts",
|
||||
"src/renderer/src/store/slices/agent-pane-authority.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-tab-retirement.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts",
|
||||
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
|
||||
"tests/e2e/terminal-parked-close-retirement.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "tests/e2e/terminal-parked-close-retirement.spec.ts",
|
||||
"assertions": [
|
||||
"a long-lived exact PTY remains alive after its terminal view is parked",
|
||||
"closing the parked tab removes the exact PTY from the provider inventory and the visible tab model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/agent-pane-authority-ownership.test.ts",
|
||||
"assertions": [
|
||||
"pane authority transfer accepts only the PTY bound to the physical local pane or the canonical legacy/scoped runtime handle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/agent-pane-authority.test.ts",
|
||||
"assertions": [
|
||||
"exact pane retirement removes resume and launch authority while preserving siblings",
|
||||
"chained detach keeps physical hooks and resume authority routed to the current owner until that owner closes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 40.3,
|
||||
"summary": "A fresh E2E build launched an isolated Electron profile, parked a live terminal, closed it through closeTab, and observed its exact PTY disappear."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 60,
|
||||
"scope": "fresh E2E build plus one isolated local Electron parked-close test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The Electron gate passed three times locally, including the final review-fix head through the registered fresh-build command; CI and soak history are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The test exercises the original parked-view failure shape and passed with the retirement boundary; an archived intentional-break run is not yet attached."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "The close path is user-triggered and bounded by canonical live-owner indexing; kill-all scale remains covered by terminal-session.kill-all-surface-cleanup."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate 100 clean runs or 14 days on required CI platforms.",
|
||||
"Add live Windows/ConPTY, Linux, WSL, SSH, and ordinary runtime process-absence evidence.",
|
||||
"Add restart/no-resurrection and repeated park-close soak coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live Electron proof currently covers macOS local PTYs only.",
|
||||
"Disconnected SSH relay death still requires reconnect-aware provider ownership.",
|
||||
"Daemon owner leases and durable retry inventory remain follow-up hardening."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if exact PTY disappearance flakes, a sibling/detached pane is retired, or late hooks can recreate closed authority."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.startup-cwd-missing-dir-recovery",
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
# Terminal Session Ownership and Teardown
|
||||
|
||||
**Status:** Incident fix and pane-authority transfer implemented
|
||||
**Date:** 2026-07-13
|
||||
**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md)
|
||||
**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md),
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Terminal process lifetime must be owned by explicit product intent, never by
|
||||
whether a React view happens to be mounted.
|
||||
|
||||
Orca currently preserves a PTY when a terminal view detaches, which is correct
|
||||
for view parking, tab moves, renderer reload, and warm reattach. It also relies
|
||||
on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view
|
||||
parking invalidated that implicit invariant: a parked tab has no mounted pane,
|
||||
so closing it removes renderer state and observers without terminating the PTY.
|
||||
Agent resume records survive the same close and later interpret the missing pane
|
||||
as failed recovery, launching the deliberately closed session again.
|
||||
|
||||
This design establishes one authoritative lifecycle contract:
|
||||
|
||||
- **Close tab or pane:** permanently retire the surface, terminate every PTY it
|
||||
owns, and revoke every resume record for its panes.
|
||||
- **Detach or park:** remove only the view connection; preserve the PTY and its
|
||||
durable identity.
|
||||
- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and
|
||||
preserve only the checkpoint needed for wake.
|
||||
- **Quit or reload:** follow the configured persistence policy; warm detach is
|
||||
allowed, but it is not a tab close.
|
||||
|
||||
The immediate implementation makes the existing terminal-tab state close the
|
||||
authoritative compatibility boundary for explicit retirement while adding an
|
||||
explicit natural-exit reason for callers that are only reconciling an already
|
||||
dead PTY. A follow-up separates the command and reducer APIs once all direct
|
||||
store callers have migrated.
|
||||
|
||||
## Implementation status
|
||||
|
||||
The incident patch ships the retirement planner, provider-aware close routing,
|
||||
unified-only retirement, parked watcher/candidate disposal, resume-authority
|
||||
revocation, explicit natural-exit handling, late-binding rejection, and direct
|
||||
background-launch ownership checks. It also adds exact pane retirement and a
|
||||
persisted physical-to-owner pane-key alias for detach, plus a live Electron
|
||||
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
|
||||
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
|
||||
|
||||
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
|
||||
explicit close fixes the incident without applying a wall-clock policy that
|
||||
could invalidate intentional long-lived `worktree-sleep` checkpoints.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Closing a terminal tab submits retirement for all exclusively owned local,
|
||||
daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its
|
||||
views are mounted or parked.
|
||||
2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to
|
||||
another tab preserves its PTY, hook identity, and resume authority under the
|
||||
new owning pane.
|
||||
3. Closing a tab permanently removes its agent resume authority. App restart,
|
||||
worktree activation, mobile wake, and periodic capture must not resurrect it.
|
||||
4. Parking, tab-group moves, renderer reload, and warm reattach keep their
|
||||
existing process-preservation behavior.
|
||||
5. Remote-runtime and web-session surfaces are terminated by their owning host,
|
||||
not by sending a host-scoped ID to the local PTY provider.
|
||||
6. Teardown is idempotent and safe under duplicate close requests, late PTY
|
||||
exits, late agent-hook events, in-flight spawns, and provider disconnects.
|
||||
7. A future bookkeeping regression cannot grow without bound: unowned defensive
|
||||
resume records have an origin-aware expiry, and provider-owned detached
|
||||
sessions gain a bounded orphan policy in the daemon-lease phase.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
|
||||
behavior.
|
||||
- Killing processes that deliberately daemonize away from 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 with explicit pane authority:
|
||||
|
||||
- `close` must clear the exact pane's resume authority, add a pane-scoped late
|
||||
event tombstone, and destroy the pane transport;
|
||||
- `detach` must atomically transfer or alias resume ownership because the agent
|
||||
process retains its immutable source `ORCA_PANE_KEY`.
|
||||
|
||||
The last-pane path routes to terminal-tab retirement so it receives the same
|
||||
parked, split, SSH, and resume cleanup behavior as a tab-bar close.
|
||||
|
||||
### 6. Provider-side safety net
|
||||
|
||||
Client correctness is necessary but not sufficient for a daemon deliberately
|
||||
designed to outlive renderer and app processes. Add a follow-up provider-owned
|
||||
orphan policy:
|
||||
|
||||
- track attachments/ownership leases rather than treating `detach` as a no-op;
|
||||
- distinguish warm-reattach grace from ownerless retirement;
|
||||
- reap sessions that have no owner after a bounded grace period;
|
||||
- never idle-reap a session merely because it produces no output;
|
||||
- scope leases to native, WSL, SSH provider, relay connection, or runtime host;
|
||||
- expose orphan count and oldest orphan age in Resource Manager diagnostics.
|
||||
|
||||
This hardening is not required to land the immediate close fix, because a lease
|
||||
protocol changes daemon compatibility and needs its own migration. It is the
|
||||
defense against a future client bookkeeping regression.
|
||||
|
||||
## Provider behavior
|
||||
|
||||
| Provider/session kind | Close operation | Notes |
|
||||
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. |
|
||||
| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. |
|
||||
| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. |
|
||||
| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. |
|
||||
| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. |
|
||||
| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. |
|
||||
| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. |
|
||||
| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. |
|
||||
| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. |
|
||||
|
||||
## Failure and race handling
|
||||
|
||||
### Duplicate close
|
||||
|
||||
The first close snapshots and retires the tab. Later closes find no target and
|
||||
no-op. Provider shutdown is idempotent for an already exited session.
|
||||
|
||||
### PTY exit races close
|
||||
|
||||
Observers are unregistered before shutdown. If an exit event was already
|
||||
queued, the recently-closed tab guard and missing tab make it a no-op. It must
|
||||
not recreate a tab, completion row, resume record, or notification.
|
||||
|
||||
### Spawn completes after close
|
||||
|
||||
The transport already kills a PTY when its spawn resolves after `destroyed` is
|
||||
set, but the two direct background launchers do not use that transport. Each
|
||||
direct launcher must revalidate tab ownership immediately after `pty.spawn` or
|
||||
`terminal.create` resolves and before writing any binding, layout, eager buffer,
|
||||
subscription, or mount request. A retired local or SSH result is killed; a
|
||||
retired runtime result is closed through `terminal.close`.
|
||||
|
||||
### Provider shutdown fails
|
||||
|
||||
The tab remains closed; reappearing would violate user intent. Report the
|
||||
provider class through structured diagnostics and leave the session visible in
|
||||
provider inventory so existing Resource Manager orphan cleanup can retry. A
|
||||
durable pending-retirement inventory is a later main-process API and is not
|
||||
claimed by Phase 1. Do not restore resume authority after a failed terminal
|
||||
kill.
|
||||
|
||||
### Remote host is unavailable
|
||||
|
||||
Keep the local close intent until either a host snapshot confirms removal or the
|
||||
existing intent TTL expires. If it expires, the host remains authoritative and
|
||||
may republish the tab; log the failed close. Never pretend a local mirror prune
|
||||
terminated the host PTY.
|
||||
|
||||
### App exits during close
|
||||
|
||||
Submit shutdown IPC before discarding the only local PTY identifiers. The main
|
||||
process/provider owns completion after accepting the request. A future batched
|
||||
shutdown IPC may return per-ID acceptance, but UI close does not wait for it.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Phase 1: correctness patch (implemented)
|
||||
|
||||
1. Add a small store-independent terminal-tab retirement planner that collects
|
||||
all six ID sources, classifies execution owners, and excludes IDs shared by
|
||||
another live surface.
|
||||
2. Add an explicit close reason, update natural-exit callers, and make explicit
|
||||
`closeTab` retirement dispose the whole parked-tab registry, submit local,
|
||||
SSH, or runtime shutdown, atomically clear all reconnect maps, and remove
|
||||
tab-owned resume records.
|
||||
3. Add post-await owner checks to both direct background launchers so a late
|
||||
local, SSH, or runtime spawn is terminated before any renderer binding.
|
||||
4. Defer decision-time expiry until unowned defensive `live` or `quit` records
|
||||
can be distinguished without changing intentional `worktree-sleep` or legacy
|
||||
recovery behavior.
|
||||
5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one
|
||||
local Electron parked-close proof as an RC release gate.
|
||||
|
||||
### Phase 2: remaining API cleanup
|
||||
|
||||
1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean
|
||||
either UI cleanup or lifecycle teardown.
|
||||
2. Route all user and runtime close call sites through one domain command.
|
||||
3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options.
|
||||
4. Add a development assertion when tab state is removed with provider PTY IDs
|
||||
but no retirement plan.
|
||||
|
||||
Exact pane retirement, persisted detach authority transfer, and unified-only
|
||||
terminal routing landed with Phase 1 because exact-head review found they were
|
||||
required to avoid introducing adjacent close regressions.
|
||||
|
||||
### Phase 3: daemon ownership leases
|
||||
|
||||
1. Define versioned attach/detach/retire lease messages.
|
||||
2. Preserve warm reattach across renderer/app restarts within a bounded grace.
|
||||
3. Reap ownerless sessions and expose pressure telemetry.
|
||||
4. Add compatibility tests across the supported daemon protocol versions and
|
||||
native/WSL/SSH host isolation.
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit/store tests
|
||||
|
||||
- Closing an active single-pane tab submits exactly its PTY for shutdown and
|
||||
removes all tab state.
|
||||
- Closing a parked tab with no mounted transport still submits its PTY.
|
||||
- Closing a split tab submits every unique PTY found across tab row, index, and
|
||||
layout plus relay/deferred/reconnect maps, including IDs present in only one
|
||||
source.
|
||||
- Duplicate IDs are submitted once by the authoritative retirement plan.
|
||||
- An ID referenced by another live tab is not killed when the source tab closes.
|
||||
- `remote:` IDs are excluded from local PTY shutdown.
|
||||
- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs.
|
||||
- All sleeping records for the tab are removed by key prefix or `record.tabId`;
|
||||
sibling records retain identity.
|
||||
- Periodic capture after close does not recreate the record.
|
||||
- Late agent status after close cannot recreate live status or resume authority.
|
||||
- Closing twice and close-after-exit are no-ops after the first retirement.
|
||||
- A spawn resolving after close kills the newly returned PTY.
|
||||
- A defensive live/quit record at the expiry boundary is retained; one
|
||||
millisecond past it is cleared and never launched, while an older intentional
|
||||
worktree-sleep record still resumes.
|
||||
|
||||
### Renderer integration tests
|
||||
|
||||
- Mounted tab close still destroys its xterm and terminates the PTY.
|
||||
- Parked watcher disposal occurs before or with PTY shutdown and emits no final
|
||||
completion/bell notification.
|
||||
- Closing one split pane preserves the sibling; closing the last pane retires
|
||||
the tab.
|
||||
- Pane-to-tab detach preserves the PTY and resume record.
|
||||
- Pinned-tab cancellation performs no shutdown; confirmed close does.
|
||||
- Bulk close (`close others`, `close right`, kill-all) uses the same semantics.
|
||||
|
||||
### Provider/main tests
|
||||
|
||||
- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the
|
||||
correct provider.
|
||||
- Disconnected SSH close tombstones the app-scoped ID and never falls through to
|
||||
a local provider.
|
||||
- Repeated `pty.kill` for the same ID is benign.
|
||||
- Remote-runtime IDs cannot reach the local kill handler from renderer close.
|
||||
|
||||
### Electron end-to-end regression
|
||||
|
||||
1. Create a worktree terminal and start a deterministic long-lived child.
|
||||
2. Switch worktrees and wait until the source worktree is cold-parked.
|
||||
3. Close the parked tab.
|
||||
4. Assert its exact PTY disappears from `pty:listSessions` and the child exits.
|
||||
5. Restart/reload Orca and activate the worktree.
|
||||
6. Assert no terminal or agent resume command is recreated for the closed pane.
|
||||
7. Repeat with two split panes and with an SSH fixture where CI supports it.
|
||||
|
||||
### Soak and pressure gate
|
||||
|
||||
Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count
|
||||
must return to baseline after each cycle and remain bounded over the run. Record
|
||||
the peak and final provider session count in CI artifacts.
|
||||
|
||||
## Observability
|
||||
|
||||
Emit a synchronous scheduled summary and an asynchronous completion summary:
|
||||
|
||||
- tab/worktree identifiers in hashed or existing diagnostic form;
|
||||
- number of PTY IDs discovered by each source;
|
||||
- provider classification counts;
|
||||
- submitted count, then fulfilled/rejected counts after `Promise.allSettled`;
|
||||
- number of resume records removed;
|
||||
- whether the tab was parked or mounted when retired.
|
||||
|
||||
After the daemon-lease phase, Resource Manager should distinguish:
|
||||
|
||||
- attached sessions;
|
||||
- warm-detached sessions eligible for reattach;
|
||||
- ownerless/orphan sessions;
|
||||
- pending/failed explicit retirement.
|
||||
|
||||
Alert locally before app-owned PTYs approach platform pressure. The warning is a
|
||||
defense, not a substitute for lifecycle correctness.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Land the correctness patch without a feature flag. User close semantics are
|
||||
restorative behavior, not an experiment.
|
||||
2. Keep hidden-view parking enabled; disabling it masks the ownership bug and
|
||||
forfeits its memory benefit.
|
||||
3. Run focused unit/integration tests plus the live parked-close Electron gate.
|
||||
4. Cut an RC and soak create/close/restart cycles while monitoring provider and
|
||||
OS PTY counts.
|
||||
5. Ship daemon leases separately behind protocol compatibility and telemetry.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Disable hidden-view parking
|
||||
|
||||
This restores the old accidental unmount behavior at significant memory cost
|
||||
and leaves every other state-only close path fragile. It does not repair resume
|
||||
records or daemon ownership.
|
||||
|
||||
### Kill only from `TerminalPane` cleanup
|
||||
|
||||
There is intentionally no pane during parking and some headless/runtime flows.
|
||||
View cleanup cannot be process authority.
|
||||
|
||||
### Clear only resume records
|
||||
|
||||
This prevents resurrection but still leaks the live PTY until app/daemon exit.
|
||||
|
||||
### Kill only the tab row's `ptyId`
|
||||
|
||||
Split panes and partially reconciled layouts can own multiple IDs. The full
|
||||
union is required.
|
||||
|
||||
### Rely only on a daemon idle timeout
|
||||
|
||||
Interactive agents can be legitimately idle for hours. Reaping by output
|
||||
idleness loses user work; ownership, not activity, is the safe signal.
|
||||
|
||||
### Await every provider exit before hiding the tab
|
||||
|
||||
Remote and disconnected providers can take seconds or fail. UI retirement must
|
||||
be immediate once the user confirms; provider completion is asynchronous and
|
||||
observable.
|
||||
|
||||
## Phase 1 acceptance criteria
|
||||
|
||||
- A confirmed terminal-tab close submits retirement for every exclusively owned
|
||||
session Orca can currently reach and reports rejected requests without an
|
||||
unhandled promise.
|
||||
- A closed agent session is never resumed by restart, activation, or mobile wake.
|
||||
- Parking, move/detach, sleep/wake, and warm app reattach retain their documented
|
||||
behavior.
|
||||
- Split, local, SSH, and remote-runtime ownership are covered by deterministic
|
||||
tests; Windows/WSL process-tree behavior remains a live-platform follow-up.
|
||||
- PTY counts remain bounded across the soak scenario.
|
||||
- No process-lifetime decision depends on React component mount state.
|
||||
|
||||
## Full-contract follow-ups
|
||||
|
||||
- Durable main-owned retirement intent and bounded retry inventory.
|
||||
- Disconnected SSH relay kill-on-reconnect.
|
||||
- Serializable daemon attachment leases with protocol-version fallback.
|
||||
- Positive ownership validation for late hook events beyond the bounded recent-
|
||||
close registry.
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert
|
||||
Alert,
|
||||
RefreshControl
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
useReconnectAttempt,
|
||||
useLastConnectedAt
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import {
|
||||
classifyConnection,
|
||||
type ConnectionVerdict
|
||||
@@ -59,7 +61,7 @@ import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
@@ -459,6 +461,14 @@ export function HostScreen({
|
||||
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
|
||||
)
|
||||
setWorktreesLoaded(true)
|
||||
// Why (#8498): the host detail screen seeds its list from the
|
||||
// home-written cache, so a partial home fetch could poison it until a
|
||||
// focus poll corrected it. Write the confirmed snapshot back through
|
||||
// the same cache so a reconnect refetch (or a remount) can't serve a
|
||||
// stale worktree list.
|
||||
if (hostId) {
|
||||
setCachedWorktrees(hostId, result.worktrees)
|
||||
}
|
||||
// Drop the optimistic active override once the host confirms it (the
|
||||
// activate RPC has landed and worktree.ps now reports it active), so we
|
||||
// stop overriding and respect any later desktop-driven change.
|
||||
@@ -613,6 +623,17 @@ export function HostScreen({
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
|
||||
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
|
||||
// useWorktreeResync so this screen stays under its max-lines budget. The
|
||||
// steady-state focus/embedded polls don't cover the transition INTO
|
||||
// 'connected' after a background/sleep, which is when the cache is stalest.
|
||||
const { refreshing, onRefresh } = useWorktreeResync({
|
||||
client,
|
||||
connState,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
})
|
||||
|
||||
const updateLocalPins = useCallback(
|
||||
(worktreeId: string, pinned: boolean) => {
|
||||
setPinnedIds((prev) => {
|
||||
@@ -1229,6 +1250,16 @@ export function HostScreen({
|
||||
)
|
||||
}}
|
||||
ItemSeparatorComponent={ListSeparator}
|
||||
// Why (#8498): manual pull-to-refresh forces a fresh worktree
|
||||
// snapshot after a reconnect or whenever the cache looks stale.
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.textSecondary}
|
||||
colors={[colors.textSecondary]}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<WorktreeListRow
|
||||
item={item}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
|
||||
|
||||
// Why: AC #8498 guarantees a reconnect refetch writes through the
|
||||
// same cache path the host detail screen seeds from, so a reconnect can't
|
||||
// serve a stale snapshot. This unit pins the write-through contract.
|
||||
describe('worktree-cache write-through', () => {
|
||||
it('returns the most-recently written snapshot, not a stale one', () => {
|
||||
const hostId = 'host-write-through'
|
||||
const stale = [{ worktreeId: 'a', name: 'stale' }]
|
||||
const fresh = [
|
||||
{ worktreeId: 'a', name: 'fresh' },
|
||||
{ worktreeId: 'b', name: 'added' }
|
||||
]
|
||||
|
||||
setCachedWorktrees(hostId, stale)
|
||||
expect(getCachedWorktrees(hostId)).toEqual(stale)
|
||||
|
||||
// Why: simulates the reconnect refetch write-through — the fresh
|
||||
// worktree.ps snapshot must fully replace the poisoned cache entry.
|
||||
setCachedWorktrees(hostId, fresh)
|
||||
expect(getCachedWorktrees(hostId)).toEqual(fresh)
|
||||
expect(getCachedWorktrees(hostId)).not.toEqual(stale)
|
||||
})
|
||||
|
||||
it('exposes a fresh snapshot to a remounting screen after reconnect', () => {
|
||||
// Why: the host detail screen reads getCachedWorktrees(hostId)
|
||||
// on (re)mount as its initialCache. A reconnect that writes
|
||||
// through must therefore surface here instead of the pre-reconnect data.
|
||||
const hostId = 'host-remount'
|
||||
setCachedWorktrees(hostId, [{ worktreeId: 'old', name: 'pre-reconnect' }])
|
||||
|
||||
// Reconnect refetch lands a fresh snapshot and writes it through.
|
||||
const reconnected = [
|
||||
{ worktreeId: 'old', name: 'post-reconnect' },
|
||||
{ worktreeId: 'new', name: 'now-visible' }
|
||||
]
|
||||
setCachedWorktrees(hostId, reconnected)
|
||||
|
||||
// A fresh screen mount reads the cache — must see the connected set.
|
||||
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
setScheduledNotificationsMaxForTests,
|
||||
subscribeToDesktopNotifications
|
||||
} from './mobile-notifications'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
@@ -20,6 +21,16 @@ vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' }
|
||||
}))
|
||||
|
||||
// Why: mobile-notifications now persists the catch-up watermark to
|
||||
// AsyncStorage. The package isn't resolvable in the node test env (other
|
||||
// mobile tests mock it the same way), so we provide a no-op mock.
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
loadPushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
@@ -316,3 +327,288 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
|
||||
// client must fetch missed notifications from its watermark and push exactly
|
||||
// the ones it had not yet delivered — never re-pushing an already-delivered id.
|
||||
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
|
||||
const AsyncStorageMock = vi.mocked(AsyncStorage)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
AsyncStorageMock.getItem.mockResolvedValue(null)
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
function flushAsync(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 10)
|
||||
})
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
let onData: ((data: unknown) => void) | null = null
|
||||
const sentRequests: { method: string; params: unknown }[] = []
|
||||
const client = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
|
||||
onData = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn(
|
||||
async (method: string, _params: unknown = {}) =>
|
||||
({
|
||||
ok: true,
|
||||
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
|
||||
}) as never
|
||||
)
|
||||
}
|
||||
// Why: onData is captured live via a getter (not destructured) because the
|
||||
// subscribe mock assigns it asynchronously as a side effect of
|
||||
// subscribeToDesktopNotifications calling client.subscribe.
|
||||
return {
|
||||
client: client as unknown as RpcClient,
|
||||
get onData() {
|
||||
return onData
|
||||
},
|
||||
sentRequests
|
||||
}
|
||||
}
|
||||
|
||||
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// First ready = cold open.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
|
||||
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
|
||||
'notifications.getMissedSince',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
|
||||
|
||||
const sub = makeClient()
|
||||
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
|
||||
// because seq 11 (agent:dup) was already delivered on the live stream and
|
||||
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed',
|
||||
body: 'b',
|
||||
notificationId: 'agent:missed',
|
||||
notificationSeq: 10
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
// First ready = cold open (no fetch).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream already delivered agent:dup (seq 11) before reap.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
})
|
||||
await flushAsync()
|
||||
// Reconnect ready → fetchMissed sends the watermark (11).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// The watermark passed to getMissedSince is the delivered seq.
|
||||
const missedCall = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 })
|
||||
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
|
||||
const scheduledIds = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
|
||||
)
|
||||
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
|
||||
// (already delivered live) alongside a new seq 12.
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
},
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'new',
|
||||
body: 'b',
|
||||
notificationId: 'agent:new',
|
||||
notificationSeq: 12
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream delivered agent:dup (seq 11) before reap.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 'dup',
|
||||
body: 'b',
|
||||
notificationId: 'agent:dup',
|
||||
notificationSeq: 11
|
||||
})
|
||||
await flushAsync()
|
||||
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
const scheduledIds = vi
|
||||
.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
|
||||
)
|
||||
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
|
||||
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// Live stream delivers seq 5.
|
||||
sub.onData?.({
|
||||
type: 'notification',
|
||||
title: 't',
|
||||
body: 'b',
|
||||
notificationId: 'agent:live',
|
||||
notificationSeq: 5
|
||||
})
|
||||
await flushAsync()
|
||||
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-1',
|
||||
'5'
|
||||
)
|
||||
})
|
||||
|
||||
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
|
||||
// and persist the watermark. This is the exact case the seq/notificationSeq
|
||||
// field mismatch broke — the desktop replay path returns `notificationSeq`
|
||||
// (matching the live fan-out), so the client watermark moves and the next
|
||||
// reconnect resumes from it instead of re-fetching from 0.
|
||||
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
|
||||
|
||||
const sub = makeClient()
|
||||
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
|
||||
sub.client.sendRequest = vi.fn(async (method: string) => {
|
||||
if (method === 'notifications.getMissedSince') {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
notifications: [
|
||||
{
|
||||
type: 'notification',
|
||||
title: 'missed',
|
||||
body: 'b',
|
||||
notificationId: 'agent:missed',
|
||||
notificationSeq: 8
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never
|
||||
}
|
||||
return { ok: true, result: undefined } as never
|
||||
})
|
||||
|
||||
subscribeToDesktopNotifications(sub.client, 'host-1')
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
// First reconnect → replay delivers seq 8 (no prior live delivery).
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
// Watermark advanced to the replayed seq and was persisted.
|
||||
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'orca:mobileNotificationsLastSeq:host-1',
|
||||
'8'
|
||||
)
|
||||
|
||||
// Second reconnect resumes from the advanced watermark, not 0.
|
||||
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
|
||||
await flushAsync()
|
||||
const missedCalls = vi
|
||||
.mocked(sub.client.sendRequest)
|
||||
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
|
||||
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,12 @@ import { Platform } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
import {
|
||||
createSeenNotificationGuard,
|
||||
loadLastSeenSeq,
|
||||
saveLastSeenSeq,
|
||||
seenKeyForEvent
|
||||
} from './notification-reconnect-catchup'
|
||||
|
||||
type NotificationEvent = {
|
||||
type: 'notification'
|
||||
@@ -11,11 +17,16 @@ type NotificationEvent = {
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
|
||||
// for reconnect catch-up (#8129). Optional because older runtimes / non-
|
||||
// replay events may omit it.
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type DismissNotificationEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
type SubscribeResult = {
|
||||
@@ -223,18 +234,105 @@ async function dismissLocalNotification(
|
||||
|
||||
// Why: each host connection gets its own notification subscription. When the
|
||||
// connection drops, the unsubscribe function cleans up the streaming RPC.
|
||||
// On reconnect the same subscribe stream is re-established by the RPC client;
|
||||
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
|
||||
// dispatched while the socket was reaped, watermarked by the last seq we
|
||||
// already delivered so the desktop never re-sends an already-pushed one.
|
||||
// Returns an unsubscribe function.
|
||||
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
let disposed = false
|
||||
// Highest seq delivered on the live stream or replay for this connection.
|
||||
// Persisted per-host so a cold app start still resumes from the right cut.
|
||||
let lastDeliveredSeq = 0
|
||||
// Why: per-connection dedup guard applied ONLY to the replay path
|
||||
// (fetchMissed), never the live stream. The desktop already guarantees the
|
||||
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
|
||||
// replay advance the same watermark), so live + replay never overlap there.
|
||||
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
|
||||
// old entry and a reconnect re-fetches across a boundary, an id delivered in
|
||||
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
|
||||
// long-lived session can't grow without limit.
|
||||
const seenReplay = createSeenNotificationGuard()
|
||||
|
||||
function deliverLive(
|
||||
type: 'notification' | 'dismiss',
|
||||
event: NotificationEvent | DismissNotificationEvent
|
||||
): Promise<void> {
|
||||
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
|
||||
lastDeliveredSeq = event.notificationSeq
|
||||
void saveLastSeenSeq(hostId, lastDeliveredSeq)
|
||||
}
|
||||
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
|
||||
// replay) so a replay that re-includes an id already pushed live in this
|
||||
// connection is dropped instead of double-pushed. fetchMissed also
|
||||
// pre-checks seenReplay, but without this the live path never populated it.
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
if (type === 'notification') {
|
||||
return showLocalNotification(event as NotificationEvent, hostId)
|
||||
}
|
||||
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
}
|
||||
|
||||
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
|
||||
// missed; ask for it from our persisted watermark. Because the desktop cuts
|
||||
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
|
||||
// not delivered before. The seenReplay guard is a second layer so a replay
|
||||
// that somehow re-includes an id already delivered this connection is
|
||||
// dropped instead of double-pushed.
|
||||
async function fetchMissed(): Promise<void> {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const missed = await client
|
||||
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
const result = response.result as { notifications?: unknown[] } | undefined
|
||||
return Array.isArray(result?.notifications) ? result.notifications : []
|
||||
})
|
||||
.catch(() => [])
|
||||
for (const raw of missed) {
|
||||
const event = raw as NotificationEvent | DismissNotificationEvent
|
||||
const key = seenKeyForEvent(event)
|
||||
if (key && seenReplay.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (key) {
|
||||
seenReplay.add(key)
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
await deliverLive('notification', event)
|
||||
} else if (event.type === 'dismiss') {
|
||||
await deliverLive('dismiss', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: lazily seed the watermark from durable storage on first use so we
|
||||
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
|
||||
// open) does NOT need catch-up — the live stream starts fresh; only
|
||||
// subsequent reconnect `ready` events fetch missed notifications.
|
||||
let watermarkLoaded = false
|
||||
void loadLastSeenSeq(hostId).then((seq) => {
|
||||
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
|
||||
watermarkLoaded = true
|
||||
})
|
||||
|
||||
function unsubscribeServer(id: string) {
|
||||
if (client.getState() === 'connected') {
|
||||
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
let reconnectReadyCount = 0
|
||||
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
|
||||
const event = data as
|
||||
| NotificationEvent
|
||||
@@ -243,9 +341,18 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
| { type: 'end' }
|
||||
if (event.type === 'ready') {
|
||||
subscriptionId = (event as SubscribeResult).subscriptionId
|
||||
reconnectReadyCount += 1
|
||||
if (disposed) {
|
||||
unsubscribeServer(subscriptionId)
|
||||
unsubscribeStream()
|
||||
return
|
||||
}
|
||||
// Why: first ready is the cold-open live stream — no catch-up needed.
|
||||
// Every later ready is a reconnect; fetch what we missed from the
|
||||
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
|
||||
// fetch from a stale 0 watermark (which would re-push everything).
|
||||
if (reconnectReadyCount > 1 && watermarkLoaded) {
|
||||
void fetchMissed()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -259,9 +366,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
|
||||
return
|
||||
}
|
||||
if (event.type === 'notification') {
|
||||
void showLocalNotification(event as NotificationEvent, hostId)
|
||||
void deliverLive('notification', event as NotificationEvent)
|
||||
} else if (event.type === 'dismiss') {
|
||||
void dismissLocalNotification(event as DismissNotificationEvent, hostId)
|
||||
void deliverLive('dismiss', event as DismissNotificationEvent)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
|
||||
// from mobile-notifications.ts so that file stays under its max-lines budget.
|
||||
// The highest desktop notification seq this device has delivered is persisted
|
||||
// per-host so it survives app restarts. On reconnect we send it to
|
||||
// notifications.getMissedSince as the catch-up watermark — the desktop then
|
||||
// returns only notifications dispatched after it, so we never re-push a
|
||||
// notification we already delivered. The in-memory seen-set is a second guard
|
||||
// against double-delivery for events that arrive on both the live stream and a
|
||||
// replay (e.g. a brief liveness spell before a reap).
|
||||
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
|
||||
|
||||
function lastSeqStorageKey(hostId: string): string {
|
||||
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
|
||||
}
|
||||
|
||||
export async function loadLastSeenSeq(hostId: string): Promise<number> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId))
|
||||
const parsed = raw == null ? 0 : Number(raw)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
|
||||
if (!Number.isFinite(seq) || seq <= 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq))
|
||||
} catch {
|
||||
// Why: persisting the watermark is best-effort. If it fails (or lags), the
|
||||
// stored value stays BELOW what we delivered, so a later cold start can
|
||||
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
|
||||
// delivered notification. That's the accepted at-least-once trade-off;
|
||||
// within a live session the in-memory watermark is authoritative, so only
|
||||
// post-restart reconnects are affected.
|
||||
}
|
||||
}
|
||||
|
||||
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
|
||||
// on the current connection. The desktop already dedupes by seq on replay, but
|
||||
// a socket that flickers background→foreground→background can deliver an event
|
||||
// on the live stream and again in a replay; the seen-set guarantees each
|
||||
// notificationId maps to at most one local push for the connection lifetime.
|
||||
// Bounded so a long-lived session can't grow without limit — a 2x superset of
|
||||
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
|
||||
const RECENTLY_SEEN_CAP = 512
|
||||
|
||||
export function createSeenNotificationGuard(): {
|
||||
has: (id: string) => boolean
|
||||
add: (id: string) => void
|
||||
} {
|
||||
const seen = new Set<string>()
|
||||
return {
|
||||
has(id: string): boolean {
|
||||
return seen.has(id)
|
||||
},
|
||||
add(id: string): void {
|
||||
seen.add(id)
|
||||
if (seen.size > RECENTLY_SEEN_CAP) {
|
||||
// Why: insertion order; the oldest entries are first. Drop one to stay
|
||||
// bounded without disturbing the more-recently-relevant keys.
|
||||
const first = seen.values().next().value
|
||||
if (first !== undefined) {
|
||||
seen.delete(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: key for the replay dedup guard. Uses notificationId when present, but
|
||||
// disambiguates by seq so a legitimate live re-delivery of the same id at a
|
||||
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
|
||||
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
|
||||
// live is suppressed. Replay events always carry a seq (the desktop assigns
|
||||
// one), so the guard is effective on the reconnect path.
|
||||
export function seenKeyForEvent(event: {
|
||||
notificationId?: string
|
||||
notificationSeq?: number
|
||||
}): string | null {
|
||||
const id = event.notificationId
|
||||
if (id != null && event.notificationSeq != null) {
|
||||
return `id:${id}#${event.notificationSeq}`
|
||||
}
|
||||
if (id != null) {
|
||||
return `id:${id}`
|
||||
}
|
||||
if (event.notificationSeq != null) {
|
||||
return `seq:${event.notificationSeq}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -66,6 +66,8 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
|
||||
return `A ${copy.reviewLabel} already exists for this branch.`
|
||||
case 'fork_head_unsupported':
|
||||
return `Creating a ${copy.reviewLabel} from this fork is not supported.`
|
||||
case 'base_not_on_remote':
|
||||
return `Push the base branch before creating a ${copy.reviewLabel}.`
|
||||
case 'needs_push':
|
||||
case null:
|
||||
case undefined:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
// Why (#8498): extracted from HostScreen so the file stays under its
|
||||
// max-lines budget. Handles the reconnect edge (refetch on transition into
|
||||
// 'connected') and manual pull-to-refresh, neither of which the steady-state
|
||||
// focus/embedded polls cover.
|
||||
export function useWorktreeResync(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
fetchWorktrees: (opts?: { allowDuringModal?: boolean }) => Promise<void>
|
||||
fetchRepoMetadata: () => Promise<void>
|
||||
}): { refreshing: boolean; onRefresh: () => Promise<void> } {
|
||||
const { client, connState, fetchWorktrees, fetchRepoMetadata } = args
|
||||
|
||||
// Why (#8498): socket-only reconnect left a stale cached snapshot after
|
||||
// background/sleep. Refetch on the transition INTO 'connected', not every poll tick.
|
||||
const prevConnStateRef = useRef(connState)
|
||||
useEffect(() => {
|
||||
const prev = prevConnStateRef.current
|
||||
prevConnStateRef.current = connState
|
||||
if (prev !== 'connected' && connState === 'connected' && client) {
|
||||
void fetchWorktrees({ allowDuringModal: true })
|
||||
void fetchRepoMetadata()
|
||||
}
|
||||
}, [connState, client, fetchWorktrees, fetchRepoMetadata])
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Why (#8498): let the user force a fresh snapshot instead of the possibly-poisoned cache.
|
||||
const onRefresh = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetchWorktrees({ allowDuringModal: true })
|
||||
await fetchRepoMetadata()
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [client, connState, fetchWorktrees, fetchRepoMetadata])
|
||||
|
||||
return { refreshing, onRefresh }
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
# Garble differential fuzz — divergence log
|
||||
|
||||
Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz
|
||||
(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is
|
||||
a case where restoring a hidden terminal from its main-side snapshot
|
||||
(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal)
|
||||
produces a screen that differs from an always-visible renderer terminal fed the
|
||||
same bytes. Any such diff is a user-visible garble on reveal.
|
||||
|
||||
## Method
|
||||
|
||||
- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane
|
||||
sizes, PTY-style random chunk splitting.
|
||||
- Differential: production `HeadlessEmulator` snapshot replayed into a fresh
|
||||
renderer-parity terminal, compared cell-by-cell (text, per-cell style,
|
||||
cursor, modes, scrollback) against an always-visible renderer-parity twin.
|
||||
- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's
|
||||
buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`,
|
||||
`vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified
|
||||
against `buildDefaultTerminalOptions` in
|
||||
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and
|
||||
`pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`,
|
||||
`drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored
|
||||
cell attributes, so their omission is not a source of false diffs.
|
||||
`windowsMode` is unset in both (matches renderer). Addon versions:
|
||||
`@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all
|
||||
`*-beta.287` (headless `6.1.0-beta.287`).
|
||||
- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug
|
||||
(predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is
|
||||
listed below.
|
||||
|
||||
## Inventory
|
||||
|
||||
| bug | found by | seeds | classification |
|
||||
| --- | --- | --- | --- |
|
||||
| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN |
|
||||
| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) |
|
||||
| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) |
|
||||
| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) |
|
||||
| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) |
|
||||
|
||||
Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and
|
||||
their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D
|
||||
carries position only (saved SGR/charset are not re-established — the synthetic
|
||||
ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate
|
||||
snapshot field written LAST by restorers because any later ESC (e.g. the
|
||||
post-replay reset) would abort the dangling sequence; its bytes are already
|
||||
counted by the snapshot seq, so tail-slice arithmetic is unchanged.
|
||||
|
||||
All five bug classes are reproduced by dedicated minimal `test.skip` repros so
|
||||
they cannot silently regress, AND each is tolerated + counted by its suite's
|
||||
corpus loop so deep mode surfaces only genuinely NEW divergences:
|
||||
|
||||
- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via
|
||||
`snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized
|
||||
snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a
|
||||
full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`.
|
||||
- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the
|
||||
Bug-C cursor cascade are kept out of the corpus by an append-only racing tail
|
||||
(no DECSC/cursor motion) and pinned only as standalone repros. Green at the
|
||||
default 200 and at `FUZZ_ITERATIONS=1000`.
|
||||
|
||||
Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts
|
||||
tripping on most seeds fails the suite instead of silently swallowing it.
|
||||
|
||||
Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored
|
||||
text mid-line") does not diverge on the current harness. It is a `savedCursor
|
||||
Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the
|
||||
emulator and the serializer here. It was most likely an earlier observation
|
||||
folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a
|
||||
transient during harness construction. No live divergence at 113.
|
||||
|
||||
---
|
||||
|
||||
## Bug A — SerializeAddon drops null cells at a soft-wrap boundary
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found
|
||||
and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz
|
||||
suite (V1 seed 31, V2 seed 157). Full mechanism documented in
|
||||
`bufferHasSerializeHostileWrappedRow` and the suite's headline comment.
|
||||
|
||||
- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell
|
||||
passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps
|
||||
at the right margin, overwriting the previous row's last cell and shifting the
|
||||
tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`.
|
||||
- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null
|
||||
takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so
|
||||
the ECH erase lands one cell right and the first filler `-` survives.
|
||||
|
||||
**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake,
|
||||
mobile subscribe replay) paints lost/shifted characters or stray `-` fillers
|
||||
when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite;
|
||||
unskip the repros when upstream fixes or a local serialize post-processor lands.
|
||||
|
||||
---
|
||||
|
||||
## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
|
||||
|
||||
**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops).
|
||||
|
||||
**Minimal repro (isolated, no fuzz corpus needed), cols=20:**
|
||||
|
||||
```
|
||||
live bytes: "\x1b[2mA\x1b[22m\x1b[1mB"
|
||||
SerializeAddon → : "\x1b[2mA\x1b[1;22mB"
|
||||
live cell B: bold=1 dim=0 (style flags 100000)
|
||||
restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST
|
||||
```
|
||||
|
||||
**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen
|
||||
from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in
|
||||
xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the
|
||||
emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is
|
||||
neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`.
|
||||
(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is
|
||||
specific to a dim-cell → bold-only-cell attribute transition.)
|
||||
|
||||
**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a
|
||||
bold status/spinner line (Claude Code, Codex). On the live screen the status
|
||||
line is bold; after a hide→reveal snapshot restore it renders normal-weight.
|
||||
The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored.
|
||||
|
||||
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
|
||||
`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`.
|
||||
Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`.
|
||||
|
||||
---
|
||||
|
||||
## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin
|
||||
|
||||
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
|
||||
|
||||
**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK).
|
||||
|
||||
**Minimal repro (isolated, pure serializer replay), cols=10:**
|
||||
|
||||
```
|
||||
live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5)
|
||||
SerializeAddon → : "0123456789\x1b[2B\x1b[6D"
|
||||
live cursor: { x: 4, y: 2 }
|
||||
restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT
|
||||
```
|
||||
|
||||
Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"`
|
||||
and round-trips the cursor exactly, isolating the trigger to a full-width final
|
||||
content row.
|
||||
|
||||
**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in
|
||||
the *wrap-pending* state (cursor visually on the last column, logically "one
|
||||
past"). The serializer computes its final cursor-restore as relative
|
||||
`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed
|
||||
one column short, so the restored cursor lands at `x-1`. Reproduced with pure
|
||||
`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble
|
||||
or normalization involved**, confirming it is upstream, not Orca's snapshot
|
||||
path.
|
||||
|
||||
**Why it garbles a real pane:** the cursor is where the next keystroke echoes
|
||||
and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line
|
||||
reached the right edge (wide status lines, long prompts), the cursor sits one
|
||||
cell left of where the live pane had it — visible as a mispositioned prompt
|
||||
caret or spinner, and subsequent input can overwrite the wrong cell.
|
||||
|
||||
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
|
||||
`it.skip('restores the cursor exactly when the last content row fills the right margin')`.
|
||||
Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`.
|
||||
|
||||
---
|
||||
|
||||
## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary
|
||||
|
||||
**Classification: (a) real bug — a structural snapshot limitation.** New
|
||||
finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity
|
||||
fuzz.
|
||||
|
||||
**Minimal repro (cols=20):**
|
||||
|
||||
```
|
||||
hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2,
|
||||
move to r3c9, write CD)
|
||||
tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X)
|
||||
|
||||
live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 }
|
||||
reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 }
|
||||
^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2
|
||||
snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all)
|
||||
```
|
||||
|
||||
**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few
|
||||
rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` /
|
||||
`CSI u`) is runtime state that never appears in the serialized buffer, so it
|
||||
cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before
|
||||
the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or
|
||||
`\x1b[u`), the restore targets the fresh terminal's default saved position
|
||||
(home) instead of where the TUI saved it — the next writes land at the wrong
|
||||
cell and overwrite live content.
|
||||
|
||||
**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and
|
||||
status-line redraws (save cursor, jump to a corner to paint a clock/token
|
||||
counter, restore). If the save happens while the pane is hidden and the restore
|
||||
fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2
|
||||
seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and
|
||||
whose `\x1b8` fell in the racing tail after chunk-splitting).
|
||||
|
||||
**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor
|
||||
motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is
|
||||
tested in isolation from this and the other terminal-state-loss garbles. Bug D
|
||||
is instead pinned as a standalone repro,
|
||||
`hidden-reveal-reconciliation.fuzz.test.ts` →
|
||||
`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`.
|
||||
|
||||
**Fix (applied):** the snapshot epilogue re-establishes the register with
|
||||
`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`,
|
||||
reading the active buffer's core register via `readSavedCursorRegister`
|
||||
(alt screen yields its own register). Position-only: saved SGR/charset are not
|
||||
carried.
|
||||
|
||||
---
|
||||
|
||||
## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence
|
||||
|
||||
**Classification: (a) real bug — a structural snapshot limitation.** New
|
||||
finding, surfaced by the reveal-reconciliation fuzz (suite 2).
|
||||
|
||||
**Minimal repro (cols=20):**
|
||||
|
||||
```
|
||||
hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet)
|
||||
tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD)
|
||||
|
||||
live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic)
|
||||
reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character
|
||||
snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone)
|
||||
```
|
||||
|
||||
**Mechanism:** a PTY read (one delivery record) can split an escape sequence.
|
||||
If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the
|
||||
serialized SCREEN cannot carry the partial sequence (it lives in the parser
|
||||
state machine, not the buffer). The racing tail supplies the sequence's
|
||||
remaining bytes, but with the prefix gone the terminal parses them as literal
|
||||
text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`.
|
||||
|
||||
**Why it garbles a real pane:** any TUI whose output is heavy with escape
|
||||
sequences (all of them) can have a read boundary fall mid-escape; if a reveal
|
||||
lands in that window the continuation renders as stray literal bytes (a rogue
|
||||
`m`, `H`, digits) injected into the visible text.
|
||||
|
||||
**Reachability:** requires the reveal/snapshot to fire in the gap between the two
|
||||
halves of a split escape. `main` writes each PTY read to the emulator and
|
||||
records it as one delivery unit (`session.ts emitSubprocessOutput`), and the
|
||||
snapshot is taken synchronously at a drain — so the window is a single delivered
|
||||
record that ended mid-escape. Narrow but real.
|
||||
|
||||
**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends
|
||||
mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the
|
||||
serialize wrap bug — it fired on ~24% of the corpus, confirming the class is
|
||||
common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` →
|
||||
`it.skip('completes an escape sequence split across the hide/reveal boundary')`.
|
||||
|
||||
**Fix (applied):** the emulator tracks the unparsed trailing partial escape at
|
||||
ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse
|
||||
mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers
|
||||
write it LAST, after their post-replay resets, so the racing tail's
|
||||
continuation completes it exactly as live. Snapshot seq already counted those
|
||||
ingested bytes, so reconcile slicing is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Known-legitimate normalization (NOT bugs)
|
||||
|
||||
- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link
|
||||
cells underlined; SerializeAddon never re-emits OSC 8. Production restores the
|
||||
link ranges out-of-band via `snapshot.oscLinks`
|
||||
(`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the
|
||||
underline by design. Pinned by the passing
|
||||
`it('drops OSC 8 underline from byte replay but preserves the range …')`.
|
||||
- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette
|
||||
indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored
|
||||
cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the
|
||||
same 16 theme slots — no visual difference. Canonicalized by
|
||||
`canonicalColorMode` in the parity fixture.
|
||||
|
||||
---
|
||||
|
||||
## Corpus vs deep mode
|
||||
|
||||
- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default
|
||||
`FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A,
|
||||
B, and C are each tolerated + counted by a predicate, so the corpus fails only
|
||||
on a genuinely new divergence.
|
||||
- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default
|
||||
`FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail
|
||||
is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`).
|
||||
- Combined default runtime is ~19s (well under the 60s gate).
|
||||
- `FUZZ_SEED=<n>`: re-run exactly one seed for a repro (both suites).
|
||||
@@ -1,532 +0,0 @@
|
||||
# orca-performance Branch Guide
|
||||
|
||||
Agent-facing map of every optimization on this branch: what it does, why it exists,
|
||||
where it lives, and the invariants you must not break when adding to it. The
|
||||
chronological evidence trail (benchmarks, retractions, A/B protocols) is in
|
||||
`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view.
|
||||
|
||||
**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load
|
||||
(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s).
|
||||
As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput
|
||||
11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms
|
||||
(10× Terminal.app).
|
||||
|
||||
## The pipeline
|
||||
|
||||
```
|
||||
shell → pty → daemon (persistence, headless model) → unix socket
|
||||
→ main (ipc/pty.ts: batching, delivery gate, flow control, snapshots)
|
||||
→ IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm)
|
||||
```
|
||||
|
||||
Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer
|
||||
MessagePort). The daemon owns sessions so they survive app restarts; it also runs
|
||||
a headless xterm emulator per pty — the _model_ — which is the source of truth
|
||||
for screen contents. The renderer terminal is a _view_ that can be discarded and
|
||||
rebuilt from model snapshots.
|
||||
|
||||
## Optimization inventory
|
||||
|
||||
### 1. Renderer parse-path fixes (the original 16× on agent TUIs)
|
||||
|
||||
- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain
|
||||
cadence follows xterm's actual parse completion instead of fixed timers, so the
|
||||
queue never outruns the parser.
|
||||
- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only
|
||||
re-process a bounded window instead of the full retained tail. Guarded by
|
||||
differential fuzz `retained-tail-redraw-window.equivalence.test.ts`.
|
||||
- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent
|
||||
wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at
|
||||
50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's
|
||||
per-chunk cost.
|
||||
|
||||
### 2. term-speed-2 chain (model/view contract — the architecture)
|
||||
|
||||
Revived from ~38 never-merged branches; kill-switched, default ON. Docs:
|
||||
`docs/reference/terminal-model-view-contract.md`.
|
||||
|
||||
- **Hidden view parking**: hidden tabs tear down their xterm view entirely
|
||||
(memory: parked panes cost ~0).
|
||||
- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are
|
||||
dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a
|
||||
model snapshot + live chunks after the snapshot's seq.
|
||||
- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd)
|
||||
from the model so parked panes stay live in the UI without a view.
|
||||
- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC
|
||||
colors) deterministically from the model for hidden panes.
|
||||
- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal
|
||||
reconciliation drops duplicates already covered by the snapshot baseline.
|
||||
|
||||
### 3. Batching & scheduling cadence
|
||||
|
||||
- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts`
|
||||
(`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`).
|
||||
At 9% utilization there is no queue — latency was literally the sum of fixed
|
||||
batch windows. This one change took dev DSR-load 19→8ms.
|
||||
- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`):
|
||||
Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks
|
||||
without the clamp, preserving cooperative yield (input/paint still serviced).
|
||||
Vitest keeps the timer path (fake timers can't advance channel posts).
|
||||
- **Input write coalescing** (from main, #7205): renderer input writes coalesce
|
||||
instead of queuing macrotask-per-keystroke.
|
||||
|
||||
### 4. Backpressure (the correctness spine — read before touching delivery)
|
||||
|
||||
Three cooperating layers, innermost first:
|
||||
|
||||
- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler
|
||||
`ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the
|
||||
chunk is legitimately discarded), not when IPC delivered it.
|
||||
**INVARIANT: every delivered chunk credits exactly once — parsed or
|
||||
discarded.** Every scheduler/pty-connection discard path (backlog replacement,
|
||||
disposed terminal, reconcile drop, split remainders) must fire the credit.
|
||||
- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`,
|
||||
`applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals
|
||||
(TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a
|
||||
fully-gated pty triggers a resync probe instead of a timeout reset. The only
|
||||
timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight
|
||||
gate + 2MB pendingData cap sit on top.
|
||||
- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`,
|
||||
`pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed
|
||||
wedge where every main→renderer PUSH channel dies while invoke stays alive
|
||||
(v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden
|
||||
resync probe can never reach. The 15s heartbeat costs one Map upsert per
|
||||
received chunk and does no IPC while output flows; mutation stays
|
||||
verified-state-only (the timer decides when to REPORT; the write-off derives
|
||||
entirely from the renderer's cumulative received totals, never wall-clock,
|
||||
and a received-but-unparsed window is never written off). Heal = re-attach
|
||||
push listeners + pull restore markers through the modelRestoreNeeded router.
|
||||
E2e blackhole harness: `__terminalDeliveryWatchdog`,
|
||||
`terminal-push-delivery-loss-recovery.spec.ts`.
|
||||
- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts`
|
||||
and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts):
|
||||
recovers the field-confirmed wedge where macOS occlusion tracking pins
|
||||
`document.visibilityState` at `'hidden'` after display sleep and never fires
|
||||
another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate
|
||||
dropped across 2 pane-level-visible ptys, transport healthy). Real user
|
||||
input (keydown/pointerdown/window focus) while the document claims hidden is
|
||||
a physical contradiction — it latches an override, runs each pane's existing
|
||||
visibilitychange resync (gate unhide + hidden-output restore), and a genuine
|
||||
visibilitychange hands authority back. No timers; recovery is purely
|
||||
event-proven, and the failure bias is safe (a wrong override only restores
|
||||
pre-gate delivery cost, never drops bytes). Hot-path cost: zero when
|
||||
visible (same single comparison); one property read per user-interaction
|
||||
event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the
|
||||
freeze repro and the keystroke recovery, plus the
|
||||
`hiddenDeliveryGatedVisiblePtyCount` field discriminator).
|
||||
- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed
|
||||
`await window.__orcaTerminalFreezeReport()`): a single DevTools command that
|
||||
returns renderer state (visibilityState + stale override, pty:data listener
|
||||
count, watchdog totals), main's snapshot with a per-pty delivery table
|
||||
(sent/acked/pending, hidden vs visible-set membership, last send/ACK ages,
|
||||
window focus flags, power suspend/resume ages, app version), and bounded
|
||||
breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared
|
||||
ring: 100 entries, same-kind coalescing) recording gate marks, visibility
|
||||
trust changes, watchdog stalls/heals, restore markers, heal write-offs,
|
||||
renderer lifecycle resets. Pty ids are redacted to their `@@` suffix —
|
||||
daemon session ids embed worktree paths. Recording happens only on rare
|
||||
transitions; the table/report is built only when read. This exists so a
|
||||
field freeze report never needs a follow-up ask.
|
||||
- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit
|
||||
guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that
|
||||
keep a split pane's death near the hidden/park boundary from stranding state
|
||||
(field incident: a closed setup-split leaf persisted in `root` with no
|
||||
binding and remounted as a permanently blank pane, unreachable by
|
||||
dead-session reconcile — it skips ptyId-null panes by design).
|
||||
(1) The "keep a fresh split whose newborn PTY died" branch is **gated on
|
||||
`isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output"
|
||||
proves nothing there; a hidden newborn death must `closePane`, or the kept
|
||||
pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked**
|
||||
reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a
|
||||
mounted TerminalPane), so the sidecar itself collapses the dead leaf out of
|
||||
the stored layout via `detachTerminalLayoutLeaf` — a stale binding left
|
||||
behind reattaches on reveal and the daemon re-creates the exited session id
|
||||
as a fresh shell (silent pane resurrection). E2e:
|
||||
`terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every
|
||||
lifecycle phase and asserts leaves(root) == bindings == live panes.
|
||||
- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause /
|
||||
32KB resume watermarks, keyed off **pendingData only** — never renderer
|
||||
counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's
|
||||
buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous
|
||||
(drops impossible); for daemon ptys the pause notify has ~20-30ms socket
|
||||
latency, so wire-speed bursts can still cross the 2MB cap (known follow-up:
|
||||
daemon-side self-pacing watermark).
|
||||
- **Shallow stream-socket write gate + per-session fairness**
|
||||
(`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices /
|
||||
4KB small-session bypass / 32MB write-through valve; kill switch
|
||||
`ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for
|
||||
every session — bytes already written can never be overtaken, so a deep
|
||||
user-space buffer buries a visible pane's echo behind other panes' bulk
|
||||
(measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at
|
||||
the gate and hold in the batcher, where the interactive flushSession path
|
||||
and the deterministic small-session bypass still jump them; socket `drain`
|
||||
refills. This layer alone bounds echo latency by the shallow depth.
|
||||
- **Background keep-tail stream thinning + daemon fact authority**
|
||||
(`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail,
|
||||
`daemon-background-transient-facts.ts`; kill switch
|
||||
`ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from
|
||||
pendingData flow control (main drops their bytes after ingestion), which
|
||||
let N background agents run unbounded ahead of main. Main mirrors the
|
||||
hidden-delivery gate to the daemon via the wire-tolerated
|
||||
`setSessionBackground` notification (introduced in v19; authoritative
|
||||
thinning requires v20 snapshots, so preserved v19 sessions are explicitly
|
||||
unthinned; older daemons swallow it) — but a live remote view subscriber
|
||||
(mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`).
|
||||
Backgrounded sessions' queued output is keep-tail dropped (oldest bytes
|
||||
replaced by an in-order `dataGap` event; reply-eliciting query bytes are
|
||||
salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused,
|
||||
so reveal stays instant with zero catch-up. Un-background neither discards
|
||||
nor force-flushes the queued tail — restore paths read MAIN's model
|
||||
(hidden-output recovery buffer), so a discarded tail loses a finished
|
||||
program's last output forever (caught by the ACK-backpressure e2e), and a
|
||||
16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own
|
||||
bytes; the ordered drain loop delivers it within the budget below. Two
|
||||
aggregate bounds make that budget real: (1) a GLOBAL background keep
|
||||
budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more
|
||||
backgrounded sessions hold queued data, and tighten retroactively when the
|
||||
count grows — without this, N sub-cap sessions queue N×cap and a worktree
|
||||
switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden
|
||||
restore vs the 1.5s budget, probe-verified drain overlap); (2) a
|
||||
kernel-flush refill sentinel: a held flush pass arms one ~90B empty data
|
||||
event whose write callback re-flushes when the kernel accepts the
|
||||
in-flight bytes — without it, held bulk advances one gate-depth per
|
||||
'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy
|
||||
daemon). NOTE: an empty `socket.write('')`'s callback fires immediately
|
||||
even with megabytes buffered (verified) — the sentinel must be a real
|
||||
protocol no-op line. Notifications are
|
||||
structurally lossless: while backgrounded, the DAEMON runs the same shared
|
||||
scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133
|
||||
command-finished / pr-link / DECSET 2031) over every byte BEFORE drop
|
||||
decisions and relays facts as in-order `transientFact` events; ordered
|
||||
`sessionBackgroundMarker` events hand scan authority back and forth
|
||||
(main suppresses just those four scanners in between), and the emulator's
|
||||
`partialEscapeTailAnsi` seeds each side's fresh scanner carry so a
|
||||
sequence split across the handoff neither phantom-fires nor goes missing.
|
||||
Titles/agent-status stay main-side (they converge from the kept tail and
|
||||
fuse with synthetic spinner frames). On `dataGap`, main resets its
|
||||
cross-chunk parse carries, drops the mobile headless mirror (rebuilds from
|
||||
tail/seeds), and sends the model-restore-needed marker so any
|
||||
renderer-side buffer heals from the snapshot. Visible ptys are never
|
||||
touched by this layer. Backlog observability:
|
||||
`ORCA_DAEMON_STREAM_BACKLOG_FILE=<path>` JSONL
|
||||
(`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`,
|
||||
`setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`).
|
||||
Causation A/B (`bench:multi-workspace-typing`): realistic steady rates
|
||||
(8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine
|
||||
do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50
|
||||
29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms.
|
||||
|
||||
### 5. Flood resilience (why bulk output can't wedge or lie anymore)
|
||||
|
||||
- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop
|
||||
abandons immediately when a foreground pane's live-chunk queue overflows
|
||||
(3-iteration hard cap), and a 2s flood-suppression window stops main's own
|
||||
backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming
|
||||
restore — bytes write through, ONE deferred repaint heals after the flood.
|
||||
This killed a positive feedback loop (restore starves ACKs → main drops →
|
||||
drop re-arms restore) that caused multi-second renderer stalls.
|
||||
- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal
|
||||
queries are extracted (`terminal-reply-query-extraction.ts`) and answered by
|
||||
_synthesizing replies on the input path_ (CPR from live buffer, DA1 canned,
|
||||
OSC via direct responder) — probes and TUIs never hang on a dropped reply.
|
||||
- Drops are downstream of the model: the daemon ingests every byte, so the
|
||||
post-flood repaint restores complete, correct content.
|
||||
|
||||
### 6. Wake/sleep recovery
|
||||
|
||||
- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes
|
||||
WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed).
|
||||
- Cumulative ACKs make the historical "lost ACKs across suspend pin the global
|
||||
window forever" wedge (BMW user bug) structurally impossible.
|
||||
|
||||
### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned)
|
||||
|
||||
Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs
|
||||
found by differential fuzzing; four fixed, one tolerated:
|
||||
|
||||
- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted
|
||||
`1;22` (22 clears the bold 1 just set). Patched via pnpm patch
|
||||
(`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the
|
||||
bold/dim group (+2 sibling bare-22 defects).
|
||||
- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely:
|
||||
`serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`)
|
||||
appends absolute CUP from the source terminal's authoritative cursor
|
||||
(skipped when wrap-pending, where CUP would corrupt).
|
||||
- **D: DECSC saved-cursor register not serialized** — snapshot appends
|
||||
`CUP(saved) + ESC 7 + CUP(actual)` when a register exists.
|
||||
- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) —
|
||||
`terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the
|
||||
unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written
|
||||
LAST on restore so continuation bytes complete the sequence. Seq accounting
|
||||
unchanged (the tail is a suffix of bytes ≤ snapshot seq).
|
||||
- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by
|
||||
`bufferHasSerializeHostileWrappedRow`, the only remaining tolerance.
|
||||
|
||||
## Correctness infrastructure (run these before merging delivery/restore changes)
|
||||
|
||||
- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs
|
||||
reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep,
|
||||
`FUZZ_SEED=n` to replay.
|
||||
- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random
|
||||
hide/reveal boundaries × snapshot seq × racing chunks must equal an
|
||||
always-visible reference.
|
||||
- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped
|
||||
= regression alarms).
|
||||
- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test —
|
||||
byte-identical vs control), `terminal-parked-memory`,
|
||||
`terminal-sleep-wake-restore`.
|
||||
- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests
|
||||
(pane-manager / terminal-pane suites).
|
||||
|
||||
## Benchmarking protocol (hard-won rules)
|
||||
|
||||
- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under
|
||||
1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the
|
||||
terminal under test.
|
||||
- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12
|
||||
--rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label <build>`
|
||||
— real keystrokes (CDP) into a visible pane while N hidden-worktree panes
|
||||
replay paced agent-TUI streams through real daemon ptys; decomposes each key
|
||||
into input-half (keydown→pty, sidecar timestamps) and echo-half
|
||||
(pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s:
|
||||
p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer
|
||||
timer drift staying ~15ms while echo-half grows means the backlog is
|
||||
upstream of the renderer (daemon socket / main ingest).
|
||||
- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev
|
||||
benches used 3MB and never tripped the cap.
|
||||
- Load-controlled A/B only: alternate builds within one session; dev carries ~2×
|
||||
day-to-day variance. Never conclude from runs while agents/builds hammer the
|
||||
machine (two false convictions came from this).
|
||||
- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang).
|
||||
- Packaged builds are truth; dev has ~2× overhead.
|
||||
|
||||
## Release mechanics
|
||||
|
||||
- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance
|
||||
version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above
|
||||
its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter
|
||||
(`release-rc-history.mjs`), telemetry identity classifier, and build guard are
|
||||
all suffix-aware — a suffixed rc classifies as `rc`.
|
||||
- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release
|
||||
(PR #7278; merged here) — perf-line users self-update after one manual install.
|
||||
|
||||
## Syncing with main: MERGE, never rebase
|
||||
|
||||
`orca-performance` is a long-lived, shared, continuously-pushed integration
|
||||
branch — RCs are cut from it and agents branch off it. **Always
|
||||
`git merge origin/main`; never rebase** (rebasing rewrites pushed history and
|
||||
strands every RC tag, fix branch, and worktree based on the old commits).
|
||||
Conflict pattern, established over ~6 syncs:
|
||||
|
||||
1. **Our structure wins; main's semantics graft in.** This branch deliberately
|
||||
restructures terminal code (shared scanners, single-policy handlers,
|
||||
model/view split). When main adds a feature inside code we've restructured,
|
||||
keep our shape and port their new behavior into it. Example: main inlined an
|
||||
OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared
|
||||
`createOsc133CommandFinishedScanner` (main's side-effect tracker must parse
|
||||
byte-identically) and added 133;C support to the shared scanner instead.
|
||||
2. Preserve the invariants in **Guardrails** below through every resolution —
|
||||
especially chunk-credit, `pendingData`-keyed flow control, and the
|
||||
single `handleCommandFinished` policy (byte path AND sideEffect-fact path
|
||||
route through it).
|
||||
3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon
|
||||
suites, and both fuzz suites. Commit the merge with a message stating what
|
||||
was kept from each side; push. If the push races a moved remote, merge the
|
||||
remote tip — never `pull --rebase` a merge.
|
||||
4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench
|
||||
before the next RC cut.
|
||||
|
||||
## Known limits / next levers (in rough priority order)
|
||||
|
||||
1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at
|
||||
wire speed before `pausePty` bites. Fix: daemon enforces its own watermark
|
||||
locally (VS Code does this server-side for remotes).
|
||||
2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining
|
||||
drain cadence dominate the 13.3ms prod p50.
|
||||
3. utilityProcess router endgame: take main off the per-byte hot path
|
||||
(VS Code's ptyHost→renderer MessagePort shape).
|
||||
4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up).
|
||||
5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control →
|
||||
term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK)
|
||||
is open against main separately.
|
||||
|
||||
## Guardrails for future agents
|
||||
|
||||
- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path
|
||||
that receives, defers, drops, or splits pty data in the renderer, prove it
|
||||
credits exactly once. The credit-invariant unit tests are the gate.
|
||||
- Flow control keys off `pendingData` only. Do not couple it to renderer
|
||||
counters; the two layers compose because they are independent.
|
||||
- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes
|
||||
≤ its seq (Bug E made this true; don't regress it). Chunks after restore are
|
||||
reconciled by seq — off-by-N re-triggers duplicate-drop garble.
|
||||
- Hidden panes must receive nothing (delivery gate) but side-effects and query
|
||||
replies must stay live via the model. If you add a new query type, wire it
|
||||
through model authority AND the drop-path synthesis.
|
||||
- Never add timeout-based recovery that mutates counters (user requirement —
|
||||
design decision from #7260). Deterministic resync or nothing; hygiene timers
|
||||
may only log.
|
||||
- Any change on the delivery/restore path: run both fuzz suites, the roundtrip
|
||||
tests, the chain e2e trio, AND a 10MB bench before calling it done.
|
||||
|
||||
## Audit 1
|
||||
|
||||
Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main →
|
||||
renderer terminal path, with particular attention to hidden delivery, parking,
|
||||
stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach,
|
||||
mobile/remote composition, SSH routing, and teardown. The audit treated model
|
||||
state and user scrollback as correctness requirements, not expendable memory.
|
||||
|
||||
### Findings and fixes
|
||||
|
||||
1. **Hidden-gate handoff owners could undo one another.** A parked watcher and
|
||||
an unmounting/remounting pane shared one boolean hidden mark; likewise a
|
||||
retiring pane could report `visible=false` after its replacement had already
|
||||
reported `visible=true`. Hidden claims are now reference-counted and
|
||||
visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager
|
||||
pre-mount buffers no longer hold raw-byte delivery interest: they are not a
|
||||
side-effect consumer, and model-backed hidden output can restore from a
|
||||
snapshot. This removes the ownership and eager-interest races formerly
|
||||
listed in Known limits item 6 without weakening parked side effects. The
|
||||
remaining transient-visibility concern was checked separately: bind and
|
||||
reconnect reports read `TerminalPane`'s synchronously refreshed
|
||||
`isVisible && isWorktreeActive` ref, the global effect uses the same
|
||||
expression, and owner counting prevents a retiring pane from overriding its
|
||||
replacement. No hidden-worktree `visible=true` report site remains.
|
||||
|
||||
2. **A natural/synthetic daemon exit could overtake final output.** When a
|
||||
shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event
|
||||
directly. The final bytes could therefore arrive after `exit`. Exit is now an
|
||||
ordered control event in `DaemonStreamDataBatcher`; both natural and
|
||||
synthetic exits flush through the same FIFO. A deep-socket regression test
|
||||
pins final-data-before-exit ordering.
|
||||
|
||||
3. **Keep-tail thinning could permanently reduce scrollback to the retained
|
||||
tail.** On `dataGap`, main discarded its headless model, then rebuilt it from
|
||||
later tail bytes even though the daemon still owned the complete model. Live
|
||||
daemon snapshots now carry `outputSequence`; the provider exposes an
|
||||
authoritative `getBufferSnapshot`, accepts the requested scrollback depth,
|
||||
and main requires that provider snapshot after a gap. Reconciliation starts
|
||||
in the pre-snapshot absolute sequence domain and runtime sequence accounting
|
||||
advances across dropped bytes. The daemon remains the source of complete
|
||||
scrollback instead of making a transport optimization destructive. If that
|
||||
authoritative RPC is temporarily unavailable, main now returns no snapshot
|
||||
and lets the renderer retry; it never paints main's known-incomplete tail as
|
||||
a full recovery.
|
||||
|
||||
4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA
|
||||
bytes salvaged from a dropped region are copies of bytes already counted by
|
||||
the daemon, not new output. Stream events now distinguish delivered text
|
||||
from `sequenceChars`; salvaged query data advances by zero while the gap
|
||||
advances by the original characters. Main can still parse/deliver the query
|
||||
copy without shifting every later snapshot baseline.
|
||||
|
||||
5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK
|
||||
credit fired when bytes entered `terminal.write`, before xterm's callback.
|
||||
Split scheduler chunks also attached `onParsed` to the first slice. ACK
|
||||
credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the
|
||||
final xterm parse callback, and is released exactly once on throw, discard,
|
||||
or terminal disposal. Submitted-but-unparsed credit is retained until parse
|
||||
or disposal, so main's flow-control window measures parser work rather than
|
||||
renderer submission.
|
||||
|
||||
6. **Hidden restore ignored configured scrollback.** The renderer always asked
|
||||
for 5,000 rows, so users configured for 10k–50k silently lost older history
|
||||
on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback
|
||||
option and clamps it through the shared 0–50,000 policy
|
||||
(`terminal-hidden-restore-scrollback.ts`).
|
||||
|
||||
7. **Active alternate-screen snapshots discarded the normal shell buffer.**
|
||||
SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old
|
||||
normalization sliced away everything before the last `?1049h`. A restored
|
||||
TUI looked correct until it exited alternate mode, then returned to empty
|
||||
history. Snapshots now carry the normal buffer separately in
|
||||
`scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose
|
||||
both buffers; an already-alt renderer exits alt, clears/rebuilds the normal
|
||||
buffer, then re-enters and rebuilds alt. History replay also composes both
|
||||
buffers, including legacy empty-field compatibility.
|
||||
|
||||
Deep fuzz then found a second two-buffer issue: normal-buffer serialization
|
||||
can leave its SGR pen active while the separately serialized alternate body
|
||||
assumes default SGR. The rehydrate boundary now emits `SGR 0` before
|
||||
`?1049h`, preventing a shell color from tinting restored TUI cells. The
|
||||
regression proves the TUI is visible immediately and `?1049l` returns to the
|
||||
original shell history.
|
||||
|
||||
8. **Daemon provider wrappers forwarded only part of the recovery contract.**
|
||||
A preserved current/legacy daemon could emit `dataGap` through a provider
|
||||
wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while
|
||||
the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap
|
||||
events, snapshots, and explicit `sequenceChars`. Both wrappers now route the
|
||||
complete contract to the provider that owns the session, including requested
|
||||
50,000-row recovery and zero-advance query-salvage events.
|
||||
|
||||
9. **Sequence-safe recovery was added without advancing the daemon protocol.**
|
||||
An already-running v19 daemon could accept background-thinning hints but
|
||||
could not return the new `outputSequence`, making any resulting gap
|
||||
impossible to reconcile safely. The authoritative snapshot contract is now
|
||||
protocol v20. Preserved v19 sessions remain live but are explicitly marked
|
||||
unthinned; their stale background hint is cleared on the ordered control
|
||||
socket before `createOrAttach`, while fresh v20 sessions retain keep-tail
|
||||
performance and full-model recovery.
|
||||
|
||||
### Static audit conclusions
|
||||
|
||||
- Model query authority still captures ownership synchronously at ingestion;
|
||||
seed/hydration/snapshot writes remain reply-silent, remote view subscribers
|
||||
retain view authority, and replies use the provider input path (including
|
||||
daemon shell-ready queuing and SSH routing).
|
||||
- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared
|
||||
by the centralized PTY teardown path. Parked watcher timers, byte sidecars,
|
||||
fact consumers, exit subscriptions, hidden claims, and runtime-title slots
|
||||
dispose on reveal/exit/worktree shutdown.
|
||||
- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden
|
||||
panes still have a main-owned headless model, so mounted hidden-gate restore
|
||||
is valid; live remote viewers veto daemon background thinning. The new
|
||||
two-buffer payload is recomposed before mobile/remote snapshot frames.
|
||||
- Wake recovery keeps its focus/visibility/system-resume listener symmetry and
|
||||
cancels its settled animation frame on cleanup. No timeout was added that
|
||||
mutates ACK or delivery counters.
|
||||
- The one documented upstream SerializeAddon null-cell/wrapped-row defect
|
||||
remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row
|
||||
predicate as fidelity fuzz, rather than misclassifying that known serializer
|
||||
defect as sequence-reconciliation loss.
|
||||
|
||||
### Validation evidence
|
||||
|
||||
- Focused ownership/connection/dispatcher tests: 422 passed.
|
||||
- Daemon server/batcher/order tests passed, including deep queued-socket exit.
|
||||
- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale
|
||||
mocks were updated to assert the new explicit `sequenceChars` argument; the
|
||||
production behavior was already correct.
|
||||
- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed.
|
||||
- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed.
|
||||
- Post-protocol completion sweep: 1,106 affected main/daemon tests passed;
|
||||
484 renderer/restore tests passed with 2 expected skips.
|
||||
- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`.
|
||||
- Required hidden-view parking, parked-memory, and sleep/wake E2E trio:
|
||||
7 passed on a fresh v20 Electron build, including byte-identical output
|
||||
across 25 park/reveal cycles.
|
||||
- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s).
|
||||
- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It
|
||||
reproducibly found the SGR boundary bug at seed 16 and the known upstream
|
||||
wrapped-null-cell case at seed 1221 before the final green run.
|
||||
- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and
|
||||
remote incomplete-escape regression suites passed.
|
||||
- Fullscreen real-app headful flow: a real shell wrote normal history, entered
|
||||
a TUI while its worktree was hidden, restored on reveal, then exited with
|
||||
`?1049l` back to the original history. The BrowserWindow was fullscreen;
|
||||
no click/focus occurred before evidence; the restored frame settled for
|
||||
1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63;
|
||||
`fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap
|
||||
11px (the scrollbar/remainder, with grid and proposed dimensions equal).
|
||||
Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and
|
||||
`.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`.
|
||||
- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s
|
||||
(986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms,
|
||||
DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39,
|
||||
reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result:
|
||||
`tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`.
|
||||
- `pnpm typecheck`, oxlint on every touched TypeScript file,
|
||||
`pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production
|
||||
build all passed. No max-lines bypass was added.
|
||||
@@ -1,152 +0,0 @@
|
||||
# Orca Serve Terminal Persistence
|
||||
|
||||
## Problem
|
||||
|
||||
`orca serve` exposes terminal tabs to paired web clients through the runtime
|
||||
`session.tabs` API, but the host-side terminal tab registry was process-local.
|
||||
When the host published an empty session-tabs snapshot for a worktree, the web
|
||||
client bootstrapped a new terminal, giving the user a fresh shell instead of
|
||||
the previously running host session.
|
||||
|
||||
The browser must remain stateless for terminal identity. Browser storage is
|
||||
intentionally sanitized because remote handles become stale after a new pairing
|
||||
or host restart.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the host runtime as the source of truth for paired web terminal tabs.
|
||||
- Persist `orca serve` terminal tab, leaf, and PTY/session bindings in the
|
||||
existing workspace session model.
|
||||
- Hydrate headless `session.tabs` snapshots from host persistence before a web
|
||||
client decides a worktree has no terminals.
|
||||
- Mirror the SSH persistence model where it applies, while keeping SSH relay
|
||||
leases and local serve persistence behind their own provider checks.
|
||||
- Preserve split-pane identity by routing all activation and attachment through
|
||||
parent tab id plus leaf id.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not persist remote handles in browser local storage.
|
||||
- Do not make browser panes supported in headless `orca serve`.
|
||||
- Do not redesign the terminal daemon or SSH relay.
|
||||
- Do not treat a persisted PTY id alone as proof that a live process belongs to
|
||||
a pane.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Persist Runtime-Owned Serve Spawns
|
||||
|
||||
The runtime PTY spawn path accepts a main-only `persistHostSessionBinding` flag.
|
||||
Headless serve sets it when creating session-tab terminals. The PTY handler
|
||||
then calls `Store.persistPtyBinding` only after validating `worktreeId`,
|
||||
`tabId`, and stable `leafId`.
|
||||
|
||||
This keeps unrelated renderer-local PTY spawns from writing workspace-session
|
||||
terminal bindings.
|
||||
|
||||
### 2. Use Stable Session IDs
|
||||
|
||||
Serve-created terminals pass `tabId`, `leafId`, optional `sessionId`, and
|
||||
`persistHostSessionBinding` into `ptyController.spawn`.
|
||||
|
||||
If a pending hydrated terminal has a persisted PTY/session id, activation
|
||||
passes that id back to the provider. New serve-owned local sessions use a
|
||||
nonnumeric `serve-${uuid}` id so they cannot collide with older numeric PTY ids
|
||||
after restart.
|
||||
|
||||
### 3. Hydrate Headless Snapshots
|
||||
|
||||
Before `list`, `listAll`, subscribe initial emission, activation, close, or
|
||||
move returns an empty headless state, the runtime hydrates
|
||||
`mobileSessionTabsByWorktree` from `workspaceSession.tabsByWorktree` and
|
||||
`terminalLayoutsByTabId`.
|
||||
|
||||
Hydration preserves:
|
||||
|
||||
- parent terminal tab id
|
||||
- stable leaf id
|
||||
- title fields
|
||||
- active tab and active leaf
|
||||
- split layout and `ptyIdsByLeafId`
|
||||
- persisted PTY/session id
|
||||
|
||||
Legacy terminal tabs without layout entries are still hydrated using a
|
||||
deterministic stable leaf id derived from the parent tab id.
|
||||
|
||||
### 4. Materialize Pending Tabs On Activation
|
||||
|
||||
Hydrated terminal surfaces with no live trusted handle are exposed as
|
||||
`pending-handle`. Activating one in headless serve materializes the exact
|
||||
parent tab and leaf on the server, then returns a ready terminal surface.
|
||||
|
||||
Explicit leaf activation is exact. If `leafId` is provided and the requested
|
||||
leaf is missing, the runtime returns `tab_not_found` instead of falling back to
|
||||
a sibling.
|
||||
|
||||
### 5. Require Trusted PTY Identity
|
||||
|
||||
For headless-hydrated persisted tabs, a live PTY is safe to expose only when
|
||||
the runtime record already matches the same worktree, parent tab id, and pane
|
||||
key. A process-list entry with the same PTY id but no pane identity stays
|
||||
pending, preventing stale or numeric id collisions from attaching the wrong
|
||||
terminal.
|
||||
|
||||
Renderer-published authoritative session snapshots retain their existing
|
||||
worktree-only daemon PTY adoption path.
|
||||
|
||||
### 6. Close And Move Without A Renderer
|
||||
|
||||
Headless close and move have no-renderer mutation paths:
|
||||
|
||||
- close hydrates and refreshes first, removes the persisted terminal tab and
|
||||
layout, updates active pointers, emits a new snapshot, and kills every live
|
||||
trusted leaf under the closed parent tab
|
||||
- move updates in-memory and persisted tab order without changing PTY bindings
|
||||
|
||||
This prevents closed or reordered tabs from reverting on reconnect.
|
||||
|
||||
### 7. Renderer Guards
|
||||
|
||||
The web client still drops remote terminal identity from browser storage. It
|
||||
mirrors the host snapshot and activates pending host mirrors through
|
||||
`session.tabs.activate`.
|
||||
|
||||
Bootstrap of a default web terminal is allowed only for a fresh empty snapshot
|
||||
for the active worktree when no local terminal state already exists. Stale empty
|
||||
snapshots and fresh empty snapshots racing with staged local terminals do not
|
||||
create duplicates.
|
||||
|
||||
### 8. SSH Parity
|
||||
|
||||
Runtime-owned SSH spawns record remote PTY leases with target-local relay PTY
|
||||
ids while workspace-session PTY bindings keep app-facing ids. Lease writes are
|
||||
deferred until after binding persistence succeeds for persisted runtime-owned
|
||||
spawns, so a failed binding save cannot leave durable SSH lease metadata for a
|
||||
tab/leaf that was not saved.
|
||||
|
||||
## Edge Cases Covered
|
||||
|
||||
- Browser reload or WebSocket reconnect mirrors host-owned terminal tabs.
|
||||
- Pending headless terminal activation reuses persisted tab and leaf identity.
|
||||
- Split panes attach and activate the requested leaf only.
|
||||
- Removed split leaves fail fast even when siblings remain.
|
||||
- Legacy tabs without layouts hydrate instead of appearing empty.
|
||||
- Stale empty snapshots do not bootstrap duplicate terminals.
|
||||
- Fresh empty snapshots do not bootstrap when local terminal state already
|
||||
exists.
|
||||
- Numeric PTY id collisions remain pending until a safe reattach/spawn occurs.
|
||||
- SSH reattach failure after binding persistence failure leaves no stale lease.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
- Unit-test runtime hydration, activation, close, move, split-leaf exactness,
|
||||
stale PTY id handling, and legacy no-layout tabs.
|
||||
- Unit-test PTY persistence gates, local session-id reattach behavior, SSH
|
||||
lease parity, and persistence failure cleanup.
|
||||
- Unit-test renderer snapshot bootstrap guards and remote runtime PTY transport
|
||||
pending-mirror behavior.
|
||||
- Run adjacent session-tab RPC and web-runtime session tests.
|
||||
- Run typecheck, lint, and `git diff --check`.
|
||||
- Launch the Electron dev app from this worktree with an isolated profile and
|
||||
verify CDP attachment, app identity, store availability, visible boot, and
|
||||
zero console errors.
|
||||
@@ -1,583 +0,0 @@
|
||||
# Terminal Performance Initiative
|
||||
|
||||
Working plan for the `orca-performance` branch. Goal: make Orca's terminal as
|
||||
performant as the architecture allows, with every claim backed by a number.
|
||||
Started 2026-07-02.
|
||||
|
||||
## Why (user-reported, from the team meeting)
|
||||
|
||||
1. Typing in the terminal is sometimes laggy — occasionally seconds of delay.
|
||||
2. Users say the terminal is slower than iTerm (unclear if typing or scrolling).
|
||||
3. Scrolling in Claude Code / OpenCode is slow.
|
||||
4. Idle memory is high (1–2 GB).
|
||||
5. Battery usage is high.
|
||||
|
||||
Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling
|
||||
latency down significantly; lower memory with 0–1 agents.
|
||||
|
||||
## Ground truth (verified against source, 2026-07-02)
|
||||
|
||||
Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified
|
||||
file:line claims) — see the archived digest and the "xterm.js vs Ghostty"
|
||||
deep-dive. The Orca-specific findings below were re-verified against this
|
||||
repo's code:
|
||||
|
||||
- **Electron main sits on every terminal byte's path** (daemon → main →
|
||||
renderer). VS Code ships the same xterm.js but bypasses main entirely: its
|
||||
ptyHost is a UtilityProcess with a direct MessagePort to each renderer.
|
||||
- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in
|
||||
both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery
|
||||
is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can
|
||||
grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k
|
||||
unacked chars (kernel backpressure blocks the shell).
|
||||
- Renderer terminals share one thread with the entire React app; xterm.js
|
||||
parses in 12 ms slices at a documented 5–35 MB/s ceiling.
|
||||
- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`),
|
||||
5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines)
|
||||
reflow on column resize.
|
||||
- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms
|
||||
(same-library reference), native class 5–10 ms. Realistic target: beat
|
||||
VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99
|
||||
dominates perception).
|
||||
|
||||
## Current state
|
||||
|
||||
Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`):
|
||||
|
||||
1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig
|
||||
(see Benchmark protocol below).
|
||||
2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards,
|
||||
probe-certified replay release) + #7139 (cooperative drain: paced backlog
|
||||
draining keeps typing responsive under floods). Post-merge on this base:
|
||||
`pnpm typecheck` clean, 626 targeted tests green (scheduler, guards,
|
||||
pty/pty-connection/pty-transport suites). #7153 itself is a disposable
|
||||
testing PR; #7139 and #7150 land separately on main.
|
||||
|
||||
## Workstreams
|
||||
|
||||
### 1. Baseline benchmarks (now; human-in-terminal required)
|
||||
|
||||
Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty,
|
||||
Terminal.app, VS Code (T3Code if available):
|
||||
|
||||
```
|
||||
node tools/benchmarks/terminal-pipeline-bench.mjs --label <machine>-<date>
|
||||
node tools/benchmarks/terminal-pipeline-bench.mjs report
|
||||
```
|
||||
|
||||
These numbers answer "are we actually slower than iTerm, and where," and are
|
||||
the before/after for everything below.
|
||||
|
||||
### 2. Validate #7153 on orca-performance (this week, extended testing)
|
||||
|
||||
Watch for: typing responsiveness under agent floods, bounded memory,
|
||||
skip-notice + snapshot repaint on overflow, no permanent input loss. When
|
||||
validated, land #7139 and #7150 as separate PRs on main.
|
||||
|
||||
### 3. Revive term-speed-2 (the headline structural work)
|
||||
|
||||
History: nwparker's ~38-branch chain (+20k lines) implementing the terminal
|
||||
model/view contract — hidden view parking, hidden delivery gate, side-effect
|
||||
authority in main, model query authority, skip-grammar deletion — all
|
||||
kill-switched, documented in
|
||||
`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`.
|
||||
It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was
|
||||
never rejected and never reached main. Directly targets complaints 3–5
|
||||
(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases).
|
||||
|
||||
Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34
|
||||
conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16),
|
||||
`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5).
|
||||
`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the
|
||||
chain touch different layers; runtime interaction (drain pacing × hidden
|
||||
gate) still needs deliberate testing.
|
||||
|
||||
Execution: dedicated focused session; resolve on `revive/term-speed-2` off
|
||||
orca-performance; keep both sides' kill switches; validate with typecheck +
|
||||
the contract tests listed in the model-view-contract doc + #7153's suites;
|
||||
merge back to orca-performance for extended testing. Estimated ~1 day of
|
||||
careful resolution + validation.
|
||||
|
||||
### 4. Remaining stall-bug fixes (parallel, independently shippable)
|
||||
|
||||
The "seconds of delay" class = discrete thread-blocking events, not
|
||||
steady-state latency:
|
||||
|
||||
- PR #7105 (open): skip synchronous cold-restore replay for live daemon
|
||||
sessions in doSpawn.
|
||||
- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback
|
||||
rows (#5096 follow-up, never done). Call sites include the mobile snapshot
|
||||
path (`pty-connection.ts:2861`) and sleep/hibernate serialization.
|
||||
- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped
|
||||
buffers (repro harness exists).
|
||||
- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s
|
||||
tick; small, part of the same program).
|
||||
|
||||
### 5. Producer-side PTY flow control
|
||||
|
||||
Ack-driven pause/resume of the actual PTY through the daemon protocol
|
||||
(node-pty supports it), watermarks per the xterm.js flow-control guide
|
||||
(≤500 KB). Converts flood-induced buffered lag into shell blocking — the
|
||||
correct physics. Sequence after #7139 lands (interacts with its drain pacing).
|
||||
|
||||
Design (2026-07-03, implement after the term-speed-2 revival merges —
|
||||
same files):
|
||||
|
||||
- Signal source: main already tracks per-pty pending + in-flight
|
||||
(`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a
|
||||
pty's pending exceeds HIGH (256 KB), main asks the producer to pause;
|
||||
below LOW (32 KB), resume.
|
||||
- Producer side: two new protocol notifications (`pausePty`/`resumePty`,
|
||||
protocol vNext, version-gated like `supportsIncrementalCheckpoints`);
|
||||
daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the
|
||||
pty fd, kernel buffer fills, the shell blocks on write: true kernel
|
||||
backpressure, identical physics to VS Code's 100k/5k design.
|
||||
`LocalPtyProvider` calls pause/resume directly.
|
||||
- Safety invariants: (1) failsafe auto-resume after 5 s regardless of
|
||||
watermark, so a lost resume can never wedge a shell; (2) resume on
|
||||
detach/exit/kill/daemon-reconnect; (3) pause must not suppress the
|
||||
interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the
|
||||
HIGH watermark is only reachable during genuine floods where echo is
|
||||
already queued; (4) PTY reads never stop for model/tail ingestion
|
||||
(term-speed-2 invariant #1) — pause gates the fd read, so daemon-side
|
||||
emulator state pauses with it, which is correct (state = what was read).
|
||||
- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit
|
||||
cleanup, plus an e2e pressure scenario asserting bounded main memory and
|
||||
a blocked producer (`yes` exits promptly on SIGINT while paused).
|
||||
|
||||
### 6. Extend the measurement rig
|
||||
|
||||
- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops
|
||||
at the parser reply, before paint).
|
||||
- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling
|
||||
at 0/1/5 agents (goal-3 metric).
|
||||
- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in
|
||||
main/daemon/renderer behind a debug flag for pipeline attribution.
|
||||
|
||||
### 7. utilityProcess terminal router (structural endgame; gated on data)
|
||||
|
||||
An Electron UtilityProcess owns the daemon socket and hands each renderer a
|
||||
MessagePort — VS Code's topology while keeping Orca's detached daemon (warm
|
||||
reattach). Takes main off the terminal data path entirely; daemon-side
|
||||
history persistence falls out naturally. Prototype only after baselines show
|
||||
how much tail latency lives in the main hop.
|
||||
|
||||
### 8. Production P90 telemetry
|
||||
|
||||
Sampled keypress→echo latency + long-task/stall counts from real users;
|
||||
defines the success criterion and becomes the permanent regression gate.
|
||||
Design after the local rig stabilizes so the metrics match.
|
||||
|
||||
## Benchmark protocol
|
||||
|
||||
`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any
|
||||
terminal:
|
||||
|
||||
- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only
|
||||
after the parser reaches the query, so it proxies the input pipeline
|
||||
without keystroke injection.
|
||||
- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`,
|
||||
`agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints —
|
||||
and labeled-pathological `styles-stress`), each run ended by a DSR fence so
|
||||
xterm.js-class ingest queues can't flatter the result.
|
||||
- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI
|
||||
stream: "typing while the agent works," quantified.
|
||||
|
||||
Rules: same machine, AC power, comparable window size, no tmux/screen, hands
|
||||
off the keyboard during runs. Never compare numbers across machines.
|
||||
|
||||
## Sequencing
|
||||
|
||||
```
|
||||
now: [1] baselines [2] #7153 testing (parallel)
|
||||
next: [3] term-speed-2 revival (dedicated session)
|
||||
parallel: [4] stall fixes, [6] rig extensions
|
||||
after 2/3: [5] flow control
|
||||
gated: [7] utility router [8] telemetry
|
||||
```
|
||||
|
||||
BMW-group crash work remains the team's priority gate above all of this
|
||||
(#7150's wedge guards overlap it); this plan runs measurement and revival
|
||||
prep in parallel without displacing it.
|
||||
|
||||
## Findings log
|
||||
|
||||
### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/)
|
||||
|
||||
Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty
|
||||
1.3.1; iTerm2 not installed, VS Code pending):
|
||||
|
||||
| metric | Orca prod | Terminal.app | Ghostty |
|
||||
|---|---|---|---|
|
||||
| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 |
|
||||
| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 |
|
||||
| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 |
|
||||
| ascii-log fenced throughput | 13 MB/s | 39 | 93 |
|
||||
|
||||
Decomposition of the 51× agent-tui gap — both pipeline ends are fast:
|
||||
|
||||
- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s**
|
||||
(`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem.
|
||||
- Daemon `Session` ingest (emulator + pending-output recording + fanout):
|
||||
agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`,
|
||||
`ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem.
|
||||
|
||||
Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms,
|
||||
so the utilityProcess router is deprioritized by data; (2) the crisis is
|
||||
queueing between daemon egress and renderer parse completion — main
|
||||
per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after
|
||||
renderer write callbacks, so renderer slowness throttles delivery
|
||||
multiplicatively), and renderer per-chunk layers above xterm; (3) the
|
||||
agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain
|
||||
text inside Orca while being equal-cost everywhere else — profile it in the
|
||||
renderer first (task #9).
|
||||
|
||||
### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only)
|
||||
|
||||
Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode
|
||||
overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** —
|
||||
the cooperative-drain branch does not move the under-load class. In
|
||||
hindsight this is structural: DSR replies are ordered within the output
|
||||
stream, so the metric measures output-queue depth; #7139 paces draining to
|
||||
protect input-send responsiveness but cannot reorder the queue. Implications:
|
||||
(1) the 134 ms-class number is fixed only by shrinking the queue (producer
|
||||
flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's
|
||||
own wins (freeze class, bounded memory, input-loss guards) must be validated
|
||||
with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs
|
||||
are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom.
|
||||
|
||||
### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9)
|
||||
|
||||
The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained
|
||||
at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background)
|
||||
regardless of parse speed. Isolation bench (fake timers, instant-parse
|
||||
terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`,
|
||||
`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching
|
||||
prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only
|
||||
when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the
|
||||
sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's
|
||||
`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks
|
||||
to the queue, so floods always ride the drip.
|
||||
|
||||
Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** —
|
||||
a pacer re-arms a 0ms drain when xterm's write callback confirms the batch
|
||||
parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling:
|
||||
27 → **117.6 MB/s** (parse-limited). Background cadence deliberately
|
||||
unchanged (protects the focused pane; hidden panes are term-speed-2's job).
|
||||
`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of
|
||||
#7139 preserved; its budget-yield test still passes). 621 tests green.
|
||||
|
||||
Open follow-ups from this attribution: (a) end-to-end dev verification (in
|
||||
progress); (b) whether main's `background:true` delivery marking demotes
|
||||
visible-pane floods to the background drip — check
|
||||
`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log
|
||||
gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect
|
||||
scanning; profile after (a).
|
||||
|
||||
### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk
|
||||
|
||||
Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered
|
||||
probes (renderer scheduler counters → main whole-method timer → per-section
|
||||
timers → targeted micro-benches) attributed it fully:
|
||||
|
||||
- Renderer receives only ~350–770 KB/s — it is **starved**, not slow.
|
||||
- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the
|
||||
flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk).
|
||||
- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless
|
||||
track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the
|
||||
pty-record tail block.
|
||||
- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame
|
||||
containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32×
|
||||
the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer`
|
||||
materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and
|
||||
`finalizeRetainedTerminalRows` allocates them all again plus runs a
|
||||
trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes
|
||||
per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code
|
||||
frame (cursor-up + erase-below) takes this path; plain logs don't — which
|
||||
is exactly the measured agent-tui vs ascii asymmetry.
|
||||
|
||||
Chain: TUI flood → O(tail) work per chunk in main → main event loop
|
||||
saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s →
|
||||
deep queue → 134 ms DSR-under-load.
|
||||
|
||||
Fix (in progress): run the existing algorithm on a lazy suffix window (the
|
||||
cursor's maximum upward reach, computed from the chunk) with the untouched
|
||||
prefix shared by reference; differential fuzz test proves output equality
|
||||
against the original implementation. Worst case (pathological full-height
|
||||
cursor-up) falls back to today's cost.
|
||||
|
||||
### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued
|
||||
|
||||
Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same
|
||||
protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under-
|
||||
load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the
|
||||
first time, but the pipeline is still far from the renderer's 27–117 MB/s
|
||||
capacity — another main-side consumer remains hot.
|
||||
|
||||
Next cycle (exact recipe): re-apply the whole-method main probe
|
||||
(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed
|
||||
build. If onPtyData still dominates, the remaining O(tail)/per-chunk
|
||||
suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full
|
||||
tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in
|
||||
dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured);
|
||||
(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If
|
||||
onPtyData no longer dominates, probe the main→renderer delivery batching
|
||||
next. The probe/bench cycle is mechanical: relaunch dev
|
||||
(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command
|
||||
"<bench> --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log.
|
||||
|
||||
### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale
|
||||
|
||||
Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still
|
||||
~90% of main's event loop (~930 ms/s). Bucket split per second:
|
||||
**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed
|
||||
fix confirmed), normalize ≈ 7, preview ≈ 0.
|
||||
|
||||
Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its
|
||||
callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText`
|
||||
joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both
|
||||
(another ~512KB of string allocation per chunk) and runs multi-pattern
|
||||
blocked/ready scans (`findTerminalWaitBlockedSignal`,
|
||||
`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text)
|
||||
— all to timestamp `waitBlockedAt` for `terminal wait`.
|
||||
|
||||
Fix design (next session): blocked/ready prompts are end-anchored — an
|
||||
actionable prompt is at the END of output. (1) Run the check on a bounded
|
||||
suffix of the wait text (last ~64 lines / 16KB) instead of the full tail;
|
||||
(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small
|
||||
carry for split keywords) can contain a blocked keyword; (3) build the two
|
||||
wait texts only when the check runs. Verification mirrors the windowed-tail
|
||||
pattern: keep the full-text check as reference + differential fuzz over
|
||||
randomized tails/prompts (split-across-chunks cases included — the
|
||||
`appendCandidateSignal` ordering semantics at :23146 must be preserved),
|
||||
plus the terminal-wait contract tests. Expected effect: removes ~85% of
|
||||
remaining onPtyData cost; combined with the two landed fixes should
|
||||
finally unlock the pipeline toward the renderer's measured 27–117 MB/s.
|
||||
|
||||
### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency
|
||||
|
||||
Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243,
|
||||
windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label
|
||||
dev-blockedfix, same protocol/config as prior dev rows:
|
||||
|
||||
| metric | pre-fix dev | +tail fix | +blocked fix |
|
||||
|---|---|---|---|
|
||||
| agent-tui MB/s | 0.7 | 1.0 | **11.5** |
|
||||
| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** |
|
||||
| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** |
|
||||
| ascii-log MB/s | 6.4 | 4.7 | **9.6** |
|
||||
|
||||
The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The
|
||||
throttled blocked-check delivered the predicted ~85% cut. Dev mode carries
|
||||
~2× overhead vs prod, so the prod build should land near ~10ms DSR-under-
|
||||
load — from the 134ms baseline (~13×) — pending a packaged-build rerun.
|
||||
Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain
|
||||
ticks + xterm 12ms slices), which flow control (#6) does not target;
|
||||
re-evaluate the "within 10× of Terminal.app" goal line after a prod
|
||||
measurement. Next: term-speed-2 revival (#4), then flow control (#6).
|
||||
|
||||
### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate)
|
||||
|
||||
`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files,
|
||||
typecheck clean, ~2,776 targeted tests green, all three of our fixes
|
||||
verified present, chain features present and kill-switched (subagent's
|
||||
six review risks recorded in its report). Bench verdict on the revived
|
||||
build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35%
|
||||
unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round
|
||||
proved the kill switches are NOT the cost) and idle p50 doubled.
|
||||
|
||||
Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s
|
||||
≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon
|
||||
CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×)
|
||||
on the revive branch** (`session-ingest-throughput.bench.test.ts`,
|
||||
ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator
|
||||
restructure (scanner classes / query-reply forwarding / view-attribute
|
||||
responder) added per-byte cost to the daemon hot path. Chunks reaching
|
||||
main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger).
|
||||
|
||||
NEXT (fast inner loop — pure unit bench, no app restarts): on
|
||||
revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs
|
||||
7839fb9db, find the per-chunk scanner cost, restore our bounded-parser
|
||||
fast paths (the daemon emulator must never pay per-byte JS scanning for
|
||||
bytes that contain no ESC — same pre-filter pattern as the blocked-check
|
||||
keyword bypass), verify with the ingest bench back at ~100 MB/s, then
|
||||
full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to
|
||||
orca-performance. A residual renderer-side share is possible once the
|
||||
daemon is fixed — re-attribute after.
|
||||
|
||||
Merge gate: revive branch merges only at ≥ blockedfix numbers.
|
||||
|
||||
**RETRACTION (2026-07-03, later):** the daemon conviction above was a
|
||||
confounded measurement — the 39–48 MB/s ingest runs executed while a dev
|
||||
app was still running. On a quiet machine the revive branch ingests at
|
||||
**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s
|
||||
vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end
|
||||
revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs
|
||||
were load-controlled, and unit benches show up to 2.6× machine-load
|
||||
variance. Scanner pre-filters landed anyway on revive (71c89da9b;
|
||||
strictly positive, 641 daemon tests green).
|
||||
|
||||
**New measurement protocol (mandatory from here):** quiet machine (no dev
|
||||
apps or benches concurrent), paired A/B runs back-to-back alternating
|
||||
branches, n≥2 per side, report spread not just p50. The merge-gate
|
||||
comparison (blockedfix vs revive) must be redone under this protocol
|
||||
before any verdict. Next: run the controlled A/B; if the delta
|
||||
disappears, merge revive into orca-performance and proceed to flow
|
||||
control (#6); if it persists, resume attribution renderer-side (probe
|
||||
pty-connection dataCallback additions per chunk).
|
||||
|
||||
### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance
|
||||
|
||||
Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui +
|
||||
DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive
|
||||
6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50
|
||||
tied, p99 better on revive, throughput within overlapping noise (revive2's
|
||||
3.6 followed two runtime-busy create failures). The earlier "35%
|
||||
regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs
|
||||
11.5 yesterday — dev benches carry ~2x day-to-day machine variance;
|
||||
absolute dev numbers are only comparable within one A/B session.
|
||||
|
||||
Merged revive/term-speed-2 → orca-performance; typecheck clean, 288
|
||||
post-merge spot tests green. orca-performance now = main-ish base + #7153
|
||||
+ three perf fixes + full term-speed-2 chain (kill-switched, default ON)
|
||||
+ scanner pre-filters. Extended user testing now covers everything.
|
||||
Remaining from the revival agent's risk list: gate×drain e2e specs
|
||||
(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them.
|
||||
Next: producer flow control (#6) per design §5; prod packaged-build bench
|
||||
for the real headline numbers.
|
||||
|
||||
### 2026-07-03 — flow control merged; goal-state accounting
|
||||
|
||||
Producer flow control merged to orca-performance (348aeb325): protocol
|
||||
v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData,
|
||||
node-pty kernel backpressure, 5s daemon-side lost-resume failsafe +
|
||||
main-side pause re-assert, resume on every teardown path, version-gated
|
||||
(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED`
|
||||
(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests
|
||||
green.
|
||||
|
||||
**Definition-of-done accounting:**
|
||||
- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s
|
||||
and DSR-load p50 161→18.8 dev, results committed).
|
||||
- term-speed-2: REVIVED AND MERGED (A/B gate passed).
|
||||
- Flow control: IMPLEMENTED AND MERGED.
|
||||
- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged
|
||||
RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod
|
||||
projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The
|
||||
remaining gap is structural cadence (daemon 8ms batch, renderer drain
|
||||
ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the
|
||||
waste class this initiative eliminated. Prod verification path:
|
||||
electron-vite preview CANNOT host the bench (CLI-created panes are not
|
||||
adopted by the preview window's renderer → no ACKs → pending-cap drop;
|
||||
two attempts, documented) — measure on the next packaged RC cut from
|
||||
orca-performance using the committed rig + protocol instead.
|
||||
|
||||
**Deferred, ordered:** (1) sync orca-performance with main — conflicts
|
||||
incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era
|
||||
`Metadata=12`; renumber chain side, audit mobile/web stream consumers);
|
||||
(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) —
|
||||
gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig
|
||||
extensions + P90 telemetry (tasks #3/#8).
|
||||
|
||||
### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers)
|
||||
|
||||
Same rig, same protocol, same machine as the 1.4.91 baseline:
|
||||
|
||||
| metric | 1.4.91 baseline | v1.4.121-rc.0 | change |
|
||||
|---|---|---|---|
|
||||
| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) |
|
||||
| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x |
|
||||
| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x |
|
||||
| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x |
|
||||
| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x |
|
||||
| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x |
|
||||
| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x |
|
||||
|
||||
Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at
|
||||
a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse
|
||||
CPU (prod ≈ dev for both latency and throughput; the pipeline is
|
||||
cadence-bound, so faster prod code changes nothing). That uniform cap
|
||||
also explains plain-text dipping slightly below baseline: ascii/cjk used
|
||||
to run unpaced ahead of the old scheduler; now everything flows through
|
||||
the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app
|
||||
under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at
|
||||
parity. The remaining 4x is the named cadence stack (daemon 8 ms batch,
|
||||
scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) —
|
||||
next lever, tunable, tracked as follow-up. p99 tail (the freeze class)
|
||||
is 29.7 ms — users cannot perceive it.
|
||||
|
||||
Caveat: measured on the user's live app (this session active in it);
|
||||
idle p99 118 ms reflects that activity, not the terminal path.
|
||||
|
||||
### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig)
|
||||
|
||||
| metric | Orca v1.4.121-rc.0 | VS Code | verdict |
|
||||
|---|---|---|---|
|
||||
| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster |
|
||||
| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster |
|
||||
| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail |
|
||||
| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% |
|
||||
| cjk-emoji | 12.2 | 11.3 | tie |
|
||||
| agent-tui | 11.2 | 11.7 | tie |
|
||||
| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x |
|
||||
|
||||
Orca now beats or ties the best-known xterm.js terminal on 5 of 6
|
||||
metrics — including 16x at idle (what users feel all day) and 5x on
|
||||
SGR-heavy output — and holds a better p99 tail under load. Throughput
|
||||
sits at the shared engine ceiling (~9-12 MB/s), confirming the class
|
||||
limit.
|
||||
|
||||
The one loss (load p50) has a clean mechanism: VS Code's producer flow
|
||||
control caps unacked output at ~100KB, so its standing queue is
|
||||
~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue
|
||||
(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window
|
||||
(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested
|
||||
setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel
|
||||
drain scheduling (sub-ms re-arm; also raises the throughput ceiling);
|
||||
(2) tighter effective in-flight window on the renderer delivery path.
|
||||
Target: VS Code's ~7ms class or below without giving back throughput.
|
||||
|
||||
### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms
|
||||
|
||||
Lever results (dev, 3MB protocol, same session):
|
||||
- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the
|
||||
~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util)
|
||||
there is no standing queue. Kept (correct, removes a real clamp).
|
||||
- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon
|
||||
STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS):
|
||||
**p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1).
|
||||
Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239
|
||||
batcher+pty tests green after timing updates.
|
||||
|
||||
Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should
|
||||
land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining
|
||||
fixed-latency terms are renderer/xterm-internal (12ms parse slices).
|
||||
Note: main's interactive bypass (input-gated) means real keystroke echo
|
||||
skips batching entirely — the DSR metric understates real typing
|
||||
responsiveness; VS Code measured on the same freight path, comparison
|
||||
fair.
|
||||
|
||||
Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect
|
||||
~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in
|
||||
reach).
|
||||
|
||||
### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green
|
||||
|
||||
terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/
|
||||
title side effects live while parked), terminal-sleep-wake-restore
|
||||
(output restored + input accepted after wake), terminal-parked-memory
|
||||
(renderer memory released on park; views retained when kill-switched
|
||||
off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay —
|
||||
the revival's top flagged risk — now has e2e coverage on the exact
|
||||
branch the RC ships from. Remaining garble-hardening: differential
|
||||
hide/reveal fuzz harness (next build), reveal-time seq diagnostics.
|
||||
|
||||
## Success criteria (baseline-relative; finalize after task 1)
|
||||
|
||||
- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same
|
||||
box; zero DSR timeouts (today's freeze class).
|
||||
- Fenced agent-tui throughput ≥ VS Code on the same box.
|
||||
- Idle RSS with 0–1 agents materially down (target set after the memory
|
||||
harness lands; hidden-pane parking is the main lever).
|
||||
- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood.
|
||||
- Production P90 typing latency down and monitored continuously.
|
||||
@@ -1,306 +0,0 @@
|
||||
# Windows Performance Investigation — Progress Log
|
||||
|
||||
Goal: (1) significantly improve Windows startup time (~1 min cold start reported),
|
||||
(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance.
|
||||
All changes must be proven with before/after benchmark numbers.
|
||||
|
||||
## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency
|
||||
|
||||
Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes.
|
||||
Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase
|
||||
timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture).
|
||||
Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines
|
||||
(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn).
|
||||
|
||||
Findings (baseline, this machine):
|
||||
- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it —
|
||||
~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts
|
||||
admitted this). Premise (16-context budget) stale since #7064 raised budget to 128.
|
||||
- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon
|
||||
provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new
|
||||
pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first
|
||||
ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal.
|
||||
- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs
|
||||
scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear +
|
||||
refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts
|
||||
recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers
|
||||
risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) —
|
||||
left as follow-up.
|
||||
- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system
|
||||
ConPTY corruption + perf differences on degraded-mode/fresh-local spawns).
|
||||
|
||||
Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis):
|
||||
- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping
|
||||
were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the
|
||||
previous dispose-on-hide behavior.
|
||||
- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon.
|
||||
- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the
|
||||
first user terminal doesn't pay the ~2.7s first-ConPTY cost.
|
||||
|
||||
Follow-ups (documented, not in this branch):
|
||||
- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual
|
||||
tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback
|
||||
when column count changed while hidden.
|
||||
- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons
|
||||
+ synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF,
|
||||
pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes.
|
||||
- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is
|
||||
Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty
|
||||
→ rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at
|
||||
that renderer connect layer, not in reconnectPersistedTerminals.
|
||||
- First terminal opened immediately after launch also waits on the one-time daemon-init
|
||||
barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured
|
||||
preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays
|
||||
it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain
|
||||
existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it
|
||||
does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize
|
||||
on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing
|
||||
behind the in-flight warm-up, not unwarmed cost.
|
||||
- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn
|
||||
storms serializing the daemon loop.
|
||||
|
||||
Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim
|
||||
PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts).
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`)
|
||||
- [x] Startup bottleneck FIXED + verified: **19.31s → 1.80s median** (fixture);
|
||||
real-world profile was 62s of blocked main thread → now 0 icacls spawns steady-state
|
||||
- [x] OpenCode freeze ROOT CAUSE found + fixed: MessagePart hook flood (see F5/D2).
|
||||
Benchmark: 22.9 MB / 540 ms / 400 main-process fanouts per turn → 469 KB / 79 ms / 120
|
||||
(legacy vs throttled plugin behavior through the real hook HTTP pipeline)
|
||||
- [x] General Windows sync-work audit (results below); audit item #2 (readHooksJson per
|
||||
status IPC) investigated and found NOT hot — renderer barely calls those handlers.
|
||||
Fixed pre-existing Windows-only test failures (hydrate-shell-path delimiter).
|
||||
- [x] Windows ConPTY e2e perf validation (F7 below)
|
||||
|
||||
## Key facts / environment
|
||||
|
||||
- Branch: `Jinwoo-H/windows-launch-time`
|
||||
- Electron app, entry: `src/main/index.ts` (~1557 lines)
|
||||
- Existing startup diagnostics: `ORCA_STARTUP_DIAGNOSTICS=1` writes `[startup] <event>` lines to stderr
|
||||
(`src/main/startup/startup-diagnostics.ts`)
|
||||
- Prior art: PR #4618 "perf: speed up desktop startup", #5011 "stop main-thread PowerShell ACL storm
|
||||
on env-store reads", #4526 "Avoid OpenCode config cleanup freezes on Windows", b240d5eee
|
||||
"Measure startup hydration phases"
|
||||
|
||||
## Follow-ups / known issues (out of scope for this branch)
|
||||
|
||||
- Pre-existing Windows-only unit test failures: `daemon-pty-adapter.test.ts` (61) and
|
||||
`history-manager.test.ts` (3, chmod-based fs-error simulation is a no-op on Windows).
|
||||
Identical with/without this branch's changes. CI never sees them (ubuntu-only).
|
||||
- Consider a Windows CI lane for the terminal-perf e2e suite (F6/F7) and these unit suites.
|
||||
- Typing-latency load-sensitivity (F7): possible deeper work on daemon checkpoint
|
||||
scheduling/priority if user reports persist after D3.
|
||||
- Audit leftovers (F4): non-recursive `grantDirAcl` execFileSync on hook install
|
||||
(installer-utils.ts:210) could be async; readHooksJson caching unnecessary (not hot).
|
||||
|
||||
### D3 — Async checkpoint writes (implemented)
|
||||
|
||||
`HistoryManager.checkpoint` (every ~5s per dirty session, Electron main process) switched
|
||||
from writeFileSync+renameSync (~1MB snapshot JSON, inflated by Defender on Windows) to
|
||||
fs.promises with the same tmp+rename atomicity; ordering preserved by the adapter's
|
||||
checkpointInFlight guard.
|
||||
|
||||
## Suspects (startup)
|
||||
|
||||
1. **`grantDirAcl(userData, { recursive: true })`** — `src/main/index.ts:517-523`, win32 only,
|
||||
runs **synchronously on the main process inside `openMainWindow()` before window creation**.
|
||||
Spawns `icacls <userData> /grant:r <user>:(OI)(CI)(F) /T /C` with a **60s timeout**.
|
||||
The comment itself admits large userData dirs (tens of thousands of Chromium cache files)
|
||||
can take >10s. This blocks first paint for the whole walk. Matches "1 minute launch" and
|
||||
"Windows only".
|
||||
2. Windows Defender real-time scan of exe/asar/native modules on cold start (environmental,
|
||||
can't fix in code, but reducing file count / sync IO helps).
|
||||
3. TBD: store sync load, daemon init, i18n init, sherpa-onnx native module load.
|
||||
|
||||
### F3 — Baseline benchmark (2026-06-10)
|
||||
|
||||
Harness: `node tools/benchmarks/startup-time-bench.mjs --label baseline --iterations 3 --files 28000`
|
||||
(28k-file synthetic Chromium-cache-shaped userData fixture in %TEMP%, headless launch of
|
||||
the electron-vite build with `ORCA_STARTUP_DIAGNOSTICS=1`, milestones parsed from stderr).
|
||||
|
||||
| phase (median of 3) | baseline |
|
||||
|---|---|
|
||||
| spawnToAppReady | 857ms |
|
||||
| appReadyToServices | 178ms |
|
||||
| servicesToI18n | 2ms |
|
||||
| i18nToOpenWindow | 7ms |
|
||||
| **aclGrantMs** | **15.65s** |
|
||||
| windowCreatedToLoaded | 1.06s |
|
||||
| **totalToDidFinishLoad** | **19.31s** |
|
||||
|
||||
ACL walk = 81% of total. (Fixture is kinder than the real profile: same file count but
|
||||
freshly-written small files → real %APPDATA%\Orca measured 62s for the same command.)
|
||||
JSON: tools/benchmarks/results/startup-baseline-2026-06-10T19-36-01-305Z.json
|
||||
|
||||
### F4 — Sync main-thread audit (subagent, 2026-06-10)
|
||||
|
||||
Ranked offenders beyond the ACL grant (#1):
|
||||
2. `readHooksJson` + JSON.parse re-read per agent-status IPC call across ~10 hook services
|
||||
(`src/main/*/hook-service.ts` via `agent-hooks/installer-utils.ts:50`) — 10-100ms per
|
||||
status snapshot, all platforms. Remediation: in-memory cache.
|
||||
3. `whoami.exe` SID resolution (win32-utils.ts:92) — already cached, OK.
|
||||
4. macOS-only `defaults read` per browser probe — not Windows.
|
||||
5. `installer-utils.ts:210` non-recursive grantDirAcl on hook install (execFileSync,
|
||||
500ms-2s) — infrequent write path, low priority.
|
||||
6. `secure-file.ts` sync PowerShell on credential write path — by design (#5011), leave.
|
||||
|
||||
## Suspects (OpenCode freeze)
|
||||
|
||||
- User report: UI freezes ~5s after sending prompt; OpenCode session itself continues fine
|
||||
(visible from external terminal). So the agent process is healthy — the freeze is in Orca's
|
||||
main process or renderer. Spinner in left panel still animates (= renderer compositor alive?
|
||||
or just that one timer). Need to find sync main-process work triggered by OpenCode activity.
|
||||
- Prior fix #4526 "Avoid OpenCode config cleanup freezes on Windows" — re-check that path.
|
||||
|
||||
### Research results (subagent, 2026-06-10) — ranked candidates
|
||||
|
||||
1. **ConPTY output flood vs PTY batching/backpressure** (HIGH): Windows ConPTY re-renders
|
||||
full TUI frames → 10-100x output volume vs macOS. Batching in `src/main/ipc/pty.ts`
|
||||
(16KB chunks / 8ms flush, 512KB renderer in-flight window). If renderer xterm.write is
|
||||
slow, ACKs stall → in-flight fills → main stalls. Tests: terminal-foreground-redraw-freeze,
|
||||
artificial-opencode-terminal-load e2e.
|
||||
2. **Sync `runtime.onPtyData` per data event before batching** (MED-HIGH):
|
||||
`src/main/ipc/pty.ts:1376-1430` → `orca-runtime.ts:3256-3420`: normalizeTerminalChunk +
|
||||
tail-buffer append + agent-status OSC parsing run synchronously per chunk on main.
|
||||
Daemon PTY path. High event rate × per-event cost can saturate the main loop.
|
||||
3. **`mirrorUserConfig` recursive fs work in `buildPtyEnv` on PTY spawn** (MED):
|
||||
`src/main/opencode/hook-service.ts:359-524` + `pty/overlay-mirror.ts:63-110` —
|
||||
readdir/safeRemoveTree/symlinks on main thread at spawn; #4526 fixed only clearPty side.
|
||||
Timing mismatch with "5s after prompt" though.
|
||||
4. Agent-status event fan-out per OSC title (LOW-MED). 5. Tail-buffer O(n²) (LOW).
|
||||
|
||||
Gap in coverage: no test exercises rapid continuous ConPTY-scale data + sync onPtyData
|
||||
accumulation on Windows.
|
||||
|
||||
### F5 — ROOT CAUSE (2026-06-10): OpenCode MessagePart hook flood
|
||||
|
||||
Eliminated candidates first: ran `terminal-foreground-redraw-freeze.spec.ts` on THIS Windows
|
||||
machine (real ConPTY + daemon provider) — passes; renderer output scheduler protections hold.
|
||||
The raw TUI-output-flood theory doesn't explain an OpenCode-specific permanent freeze.
|
||||
|
||||
The actual mechanism (src/main/opencode/hook-service.ts plugin source):
|
||||
- OpenCode publishes `message.part.updated` with the FULL accumulated text of the part on
|
||||
every streamed append (architecture: parts are republished, not deltas).
|
||||
- Orca's plugin POSTed that full text to the agent-hook server on EVERY event →
|
||||
**O(n²) bytes per streaming turn**. A 120KB reply in 400 updates = ~23 MB through
|
||||
loopback HTTP + main-process JSON.parse; real turns are worse (per-token updates).
|
||||
- Main process spends its whole loop on HTTP receive + parse + normalize + fanout. UI symptom
|
||||
matches the user report exactly: everything dead (window close needs main + renderer
|
||||
round-trip), EXCEPT the sidebar agent indicator — which is the one thing fed by the very
|
||||
agentStatus:set flood that's starving everything else.
|
||||
- Why Windows-biased: same flood exists on macOS but combines on Windows with ConPTY
|
||||
full-frame redraw volume and generally slower process IO; also Windows daemon-PTY path
|
||||
adds main-process onPtyData work.
|
||||
- Why "5 seconds after sending the prompt": that's when the accumulated text gets big.
|
||||
- Why OpenCode keeps working: plugin POST failures are swallowed; the session is healthy.
|
||||
- Downstream payloads were already bounded (prompt 200 chars, lastAssistantMessage 8000
|
||||
chars via agent-status-types normalization) — the renderer wasn't the bottleneck; the
|
||||
main-process ingest was.
|
||||
|
||||
### F7 — Windows ConPTY e2e perf validation (2026-06-10)
|
||||
|
||||
Ran the terminal-perf budget specs on this Windows machine (real ConPTY + daemon PTY
|
||||
provider — a path CI never exercises):
|
||||
- `terminal-output-scheduler.spec.ts`: PASS (all tests)
|
||||
- `terminal-foreground-redraw-freeze.spec.ts`: PASS
|
||||
- `terminal-typing-latency.spec.ts`: PASSES in isolation, repeatedly — median 13.6-23.1ms,
|
||||
worst 34-42ms (budgets: 250ms median / 1000ms worst). Two earlier runs that exceeded the
|
||||
worst-key budget (1054.9ms, 2016.1ms outlier on a single key) occurred while other heavy
|
||||
tooling (vitest/tsgo/builds) ran concurrently on the machine → load-sensitivity, not a
|
||||
deterministic product defect. Note the product implication: under heavy host load
|
||||
(exactly what coding agents generate), a keystroke can stall >1s on Windows. Plausible
|
||||
contributors for follow-up: daemon checkpoint ticks (5s interval; snapshot serialize in
|
||||
daemon + sync writeFileSync of checkpoint JSON on main — daemon-pty-adapter.ts:592,
|
||||
history-manager.ts:109), Defender scanning fresh build artifacts.
|
||||
|
||||
### F6 — Windows e2e perf coverage gap
|
||||
|
||||
All terminal-perf e2e specs run on ubuntu-latest in CI. Verified they DO run on a Windows
|
||||
dev machine (`npx playwright test ... --project electron-headless` works locally). Consider
|
||||
a Windows CI lane for the terminal-perf suite.
|
||||
|
||||
## OpenCode fix (D2)
|
||||
|
||||
1. **Plugin throttle + cap (source fix)** — `src/main/opencode/hook-service.ts`:
|
||||
assistant MessagePart posts are trailing-edge coalesced to ≥250ms apart and text is
|
||||
capped at 4000 chars (leading edge posts immediately so previews stay snappy; pending
|
||||
snapshot flushed before SessionIdle so the done-row preview is the final message; user
|
||||
prompts bypass the throttle slot). Plugin file is rewritten on every Orca-launched
|
||||
OpenCode spawn, so the fix deploys to new sessions immediately.
|
||||
2. **Listener-side cap (stale-plugin defense)** — `src/shared/agent-hook-listener.ts`:
|
||||
OpenCode MessagePart text capped at 8000 chars at ingest (OPENCODE_HOOK_TEXT_MAX_CHARS)
|
||||
so pre-fix plugins in long-running OpenCode processes can't blow up state maps.
|
||||
3. **Benchmark/regression test** — `src/main/agent-hooks/opencode-message-part-flood-bench.test.ts`
|
||||
drives the real hook HTTP pipeline with both behaviors. Measured on this machine:
|
||||
| metric/turn | legacy plugin | throttled plugin |
|
||||
|---|---|---|
|
||||
| posts | 400 | 120 |
|
||||
| bytes through main | 22.9 MB | 469 KB (49x less) |
|
||||
| wall time | 540 ms | 79 ms |
|
||||
| listener fanouts | 400 | 120 |
|
||||
4. Behavioral plugin tests — `src/main/opencode/hook-plugin-message-part-throttle.test.ts`
|
||||
executes the generated plugin with fake timers + stubbed fetch.
|
||||
|
||||
## Findings
|
||||
|
||||
### F1 — Recursive icacls walk is the ~1 min startup (CONFIRMED, 2026-06-10)
|
||||
|
||||
- This machine's real packaged-Orca userData: `%APPDATA%\Orca` = **28,650 files / 2.06 GB**
|
||||
(mostly Chromium caches: Cache, Code Cache, GPUCache, blob_storage…).
|
||||
- Measured the exact command Orca runs in `openMainWindow()` (src/main/index.ts:517-523):
|
||||
- `icacls <userData> /grant:r <user>:(OI)(CI)(F) /T /C` → **62.0 s**
|
||||
- App runs it with `execFileSync` (main thread, BLOCKING, before BrowserWindow creation)
|
||||
with a **60s timeout** → every cold launch freezes ~60s, then the grant *times out and
|
||||
silently fails* (execFileSync throws, caught). Users pay the full minute and get nothing.
|
||||
- Non-recursive root-only grant: **4.8 s** (NTFS propagates inheritable ACE internally).
|
||||
- `icacls <userData>\* /grant:r …` (immediate children, 48 entries): **4.7 s**.
|
||||
- Why it exists (PR #1152): Chromium's BrowserWindow ctor resets userData DACL with
|
||||
Inherit-Only ACEs → EPERM on writes in existing subdirs (codex-runtime-home, agent-hooks…).
|
||||
Explicit child ACEs survive propagation. Per-write EPERM retries exist as backstop in
|
||||
`codex-accounts/fs-utils.ts` + `agent-hooks/installer-utils.ts`.
|
||||
- Windows ACL inheritance recalculates from the immediate parent during propagation, so
|
||||
explicit ACEs on userData + immediate children are sufficient; per-file ACEs on 28k
|
||||
Chromium cache files are useless work.
|
||||
|
||||
### F2 — Instrumentation prior art
|
||||
|
||||
- `ORCA_STARTUP_DIAGNOSTICS=1` → `[startup] <event>` lines on stderr (startup-diagnostics.ts).
|
||||
Only 2 events exist today (single-instance lock). Commit b240d5eee (branch
|
||||
perf/startup-first-window, NOT merged here) has a full StartupPhaseTimer framework —
|
||||
too large to cherry-pick; adding minimal milestone logs instead.
|
||||
- Hermetic benchmark launch path: `ORCA_E2E_USER_DATA_DIR=<dir>` redirects userData
|
||||
(works packaged + dev), `ORCA_E2E_HEADLESS=1` keeps window hidden. Dev/preview mode
|
||||
skips single-instance lock → safe alongside installed Orca.
|
||||
|
||||
## Decisions / fixes
|
||||
|
||||
### D0 — RESULTS: ACL fix benchmark (2026-06-10)
|
||||
|
||||
| phase (median) | baseline (3 it.) | after fix (4 it.) | steady state (3 it.) |
|
||||
|---|---|---|---|
|
||||
| aclGrantMs | **15.65s sync/blocking** | async (off critical path) | **0ms (marker hit)** |
|
||||
| totalToWindowCreated | 18.25s | 930ms | 814ms |
|
||||
| totalToDidFinishLoad | **19.31s** | **2.04s** | **1.80s** |
|
||||
|
||||
- First launch after fix: total 2.06s while the background grant ran 6.81s concurrently.
|
||||
- Marker verified written by real icacls run; subsequent launches log `acl-grant-done
|
||||
mode=marker-hit` with zero spawns.
|
||||
- JSON evidence: tools/benchmarks/results/startup-{baseline,acl-fix,acl-fix-steady}-*.json
|
||||
- Files: src/main/startup/windows-user-data-acl.ts (+tests), src/main/index.ts (wire-up +
|
||||
startup milestones), src/main/win32-utils.ts (export identity resolver),
|
||||
tools/benchmarks/startup-time-bench.mjs (harness).
|
||||
|
||||
### D1 — ACL grant fix (implemented as planned)
|
||||
|
||||
Replace the synchronous recursive walk with:
|
||||
1. A persisted marker (`windows-acl-grant.json` in userData, keyed on identity + scheme
|
||||
version): when present → skip everything (steady-state launches: 0 icacls spawns, 0 ms).
|
||||
2. When marker missing (first launch after install/profile import): grant root +
|
||||
immediate children via **async spawn** (never blocks window creation); write marker
|
||||
on success. Per-write EPERM retries remain the backstop during the async window —
|
||||
that's exactly what they're for (#1152 comment says so).
|
||||
3. Drop the /T full-tree walk entirely; it grants nothing the immediate-children
|
||||
ACEs + inheritance propagation don't already cover.
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.139",
|
||||
"version": "1.4.140-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -72,7 +72,7 @@
|
||||
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
|
||||
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless",
|
||||
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
|
||||
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
@@ -153,7 +153,7 @@
|
||||
"@tiptap/starter-kit": "^3.22.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -188,10 +188,10 @@
|
||||
"oxlint-plugin-react-doctor": "0.2.10",
|
||||
"oxlint-tsgolint": "0.23.0",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-grab": "^0.1.33",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
|
||||
Generated
+1239
-926
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer, PANE_KEY_ALIASES_MAX } from './server'
|
||||
|
||||
const SOURCE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111')
|
||||
const TARGET = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222')
|
||||
const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333')
|
||||
const SIBLING = makePaneKey('tab-target', '44444444-4444-4444-8444-444444444444')
|
||||
|
||||
describe('AgentHookServer pane authority', () => {
|
||||
it('keeps physical hooks routed after the source tab closes and suppresses them after owner retire', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: SOURCE,
|
||||
tabId: 'tab-source',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'source' }
|
||||
})
|
||||
|
||||
server.transferPaneAuthority(SOURCE, TARGET, 'pty-1')
|
||||
server.dropStatusEntriesByTabPrefix('tab-source')
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: SOURCE,
|
||||
tabId: 'tab-source',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'after source close' }
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: TARGET,
|
||||
tabId: 'tab-target',
|
||||
prompt: 'after source close'
|
||||
})
|
||||
])
|
||||
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: SIBLING,
|
||||
tabId: 'tab-target',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'sibling' }
|
||||
})
|
||||
server.retirePaneAuthority(TARGET)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: SOURCE,
|
||||
tabId: 'tab-source',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'too late' }
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: SIBLING, prompt: 'sibling' })
|
||||
])
|
||||
})
|
||||
|
||||
it('persists one physical alias while chained transfers advance its owner', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setPaneKeyAliasPersistenceListener(listener)
|
||||
|
||||
server.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10)
|
||||
server.transferPaneAuthority(TARGET, FINAL, 'pty-1', 20)
|
||||
|
||||
expect(listener).toHaveBeenLastCalledWith([
|
||||
{
|
||||
legacyPaneKey: SOURCE,
|
||||
stablePaneKey: FINAL,
|
||||
ptyId: 'pty-1',
|
||||
updatedAt: 20
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('requires live PTY ownership for a first transfer and trusts the chained alias afterward', () => {
|
||||
const server = new AgentHookServer()
|
||||
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: SOURCE,
|
||||
tabId: 'tab-source',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'unverified source' }
|
||||
})
|
||||
|
||||
expect(server.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(false)
|
||||
expect(server.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(false)
|
||||
expect(
|
||||
server.canTransferPaneAuthority(SOURCE, 'pty-1', (paneKey, ptyId) => {
|
||||
return paneKey === SOURCE && ptyId === 'pty-1'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
server.transferPaneAuthority(SOURCE, TARGET, 'pty-1')
|
||||
expect(server.canTransferPaneAuthority(TARGET, undefined, () => false)).toBe(true)
|
||||
expect(server.canTransferPaneAuthority(TARGET, 'pty-1', () => false)).toBe(true)
|
||||
expect(server.canTransferPaneAuthority(TARGET, 'other-pty', () => false)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat registered or restored aliases as verified authority', () => {
|
||||
const registered = new AgentHookServer()
|
||||
registered.registerPaneKeyAlias('tab-source:0', SOURCE, 'pty-1')
|
||||
|
||||
expect(registered.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(false)
|
||||
expect(registered.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(false)
|
||||
expect(
|
||||
registered.canTransferPaneAuthority(SOURCE, 'pty-1', (paneKey, ptyId) => {
|
||||
return paneKey === 'tab-source:0' && ptyId === 'pty-1'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
const restored = new AgentHookServer()
|
||||
restored.transferPaneAuthority(SOURCE, TARGET, 'pty-1', 10, { authorityVerified: false })
|
||||
expect(restored.canTransferPaneAuthority(TARGET, undefined, () => false)).toBe(false)
|
||||
expect(restored.canTransferPaneAuthority(TARGET, 'pty-1', () => false)).toBe(false)
|
||||
expect(
|
||||
restored.canTransferPaneAuthority(TARGET, 'pty-1', (paneKey, ptyId) => {
|
||||
return paneKey === TARGET && ptyId === 'pty-1'
|
||||
})
|
||||
).toBe(true)
|
||||
restored.transferPaneAuthority(TARGET, FINAL, 'pty-1', 20)
|
||||
expect(restored.canTransferPaneAuthority(FINAL, undefined, () => false)).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers a spawn-verified legacy alias over an earlier migration fallback', () => {
|
||||
const server = new AgentHookServer()
|
||||
server.registerPaneKeyAlias('tab-source:0', SOURCE, 'pty-1')
|
||||
server.registerPaneKeyAlias('tab-source:1', SOURCE, 'pty-1', 20, {
|
||||
authorityVerified: true
|
||||
})
|
||||
|
||||
expect(server.canTransferPaneAuthority(SOURCE, undefined, () => false)).toBe(true)
|
||||
expect(server.canTransferPaneAuthority(SOURCE, 'pty-1', () => false)).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds persisted aliases by evicting the oldest authority', () => {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setPaneKeyAliasPersistenceListener(listener)
|
||||
|
||||
for (let index = 0; index <= PANE_KEY_ALIASES_MAX; index += 1) {
|
||||
const suffix = index.toString(16).padStart(12, '0')
|
||||
server.transferPaneAuthority(
|
||||
makePaneKey(`source-${index}`, `00000000-0000-4000-8000-${suffix}`),
|
||||
makePaneKey(`target-${index}`, `10000000-0000-4000-8000-${suffix}`),
|
||||
`pty-${index}`,
|
||||
index + 1
|
||||
)
|
||||
}
|
||||
|
||||
const persisted = listener.mock.calls.at(-1)?.[0]
|
||||
expect(persisted).toHaveLength(PANE_KEY_ALIASES_MAX)
|
||||
expect(persisted).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ legacyPaneKey: expect.stringContaining('source-0:') })
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
+196
-20
@@ -28,6 +28,7 @@ import {
|
||||
HOOK_REQUEST_SLOWLORIS_MS,
|
||||
markClaudeLeadTurnInterrupted,
|
||||
MAX_PANE_KEY_LEN,
|
||||
movePaneCacheState,
|
||||
normalizeHookPayload,
|
||||
parseFormEncodedBody,
|
||||
readRequestBody,
|
||||
@@ -89,6 +90,7 @@ type PaneKeyAliasEntry = {
|
||||
stablePaneKey: string
|
||||
ptyId: string | null
|
||||
updatedAt: number
|
||||
authorityVerified: boolean
|
||||
}
|
||||
|
||||
// Why: name of the on-disk cache that survives Orca restart. Lives next to
|
||||
@@ -128,6 +130,8 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// gone. Bound the set so it can't grow one entry per tab close for the whole
|
||||
// session (it is otherwise only cleared at app quit).
|
||||
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
||||
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
|
||||
export const PANE_KEY_ALIASES_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
@@ -165,7 +169,9 @@ function equivalentInterruptAgentType(
|
||||
// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
|
||||
// at write/hydrate time so legacy numeric rows fail closed.
|
||||
export function isValidPaneKey(value: unknown): value is string {
|
||||
return typeof value === 'string' && parsePaneKey(value) !== null
|
||||
return (
|
||||
typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeHydratedEntry(
|
||||
@@ -473,6 +479,7 @@ export class AgentHookServer {
|
||||
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
|
||||
private promptSentHashSalt = randomBytes(16).toString('hex')
|
||||
private closedAgentStatusTabIds = new Set<string>()
|
||||
private closedAgentStatusPaneKeys = new Set<string>()
|
||||
// Why: identity check — skip writes when the JSON-stringified contents
|
||||
// exactly match the last successful disk write. Cheap protection against
|
||||
// re-firing trailing timers when nothing changed.
|
||||
@@ -641,13 +648,32 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
if (
|
||||
this.closedAgentStatusPaneKeys.has(paneKey) ||
|
||||
this.closedAgentStatusPaneKeys.has(ownerPaneKey)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const tabId = parsePaneKey(ownerPaneKey)?.tabId
|
||||
if (!tabId) {
|
||||
return false
|
||||
}
|
||||
return this.closedAgentStatusTabIds.has(tabId)
|
||||
}
|
||||
|
||||
private markPaneClosedForAgentStatus(paneKey: string): void {
|
||||
this.closedAgentStatusPaneKeys.delete(paneKey)
|
||||
this.closedAgentStatusPaneKeys.add(paneKey)
|
||||
while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) {
|
||||
const oldest = this.closedAgentStatusPaneKeys.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.closedAgentStatusPaneKeys.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
private attachStatusTiming(
|
||||
payload: AgentHookEventPayload,
|
||||
now = Date.now()
|
||||
@@ -937,15 +963,67 @@ export class AgentHookServer {
|
||||
this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases())
|
||||
}
|
||||
|
||||
private boundPaneKeyAliases(): void {
|
||||
while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) {
|
||||
// Why: renderer-originated aliases are untrusted process-lifetime state;
|
||||
// insertion-order eviction bounds both memory and per-message cleanup.
|
||||
const oldestKey = this.legacyPaneKeyAliases.keys().next().value
|
||||
if (!oldestKey) {
|
||||
break
|
||||
}
|
||||
this.legacyPaneKeyAliases.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
let fallbackPaneKey = paneKey
|
||||
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (
|
||||
entry.stablePaneKey === ownerPaneKey &&
|
||||
(!ptyId || !entry.ptyId || entry.ptyId === ptyId)
|
||||
) {
|
||||
if (entry.authorityVerified) {
|
||||
return physicalPaneKey
|
||||
}
|
||||
fallbackPaneKey = physicalPaneKey
|
||||
}
|
||||
}
|
||||
return fallbackPaneKey
|
||||
}
|
||||
|
||||
canTransferPaneAuthority(
|
||||
fromPaneKey: string,
|
||||
ptyId: string | undefined,
|
||||
ownsPty: (physicalPaneKey: string, ptyId: string) => boolean
|
||||
): boolean {
|
||||
if (!isValidPaneKey(fromPaneKey)) {
|
||||
return false
|
||||
}
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey)
|
||||
const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId)
|
||||
const alias = this.legacyPaneKeyAliases.get(physicalPaneKey)
|
||||
if (ptyId) {
|
||||
return Boolean(
|
||||
(alias?.authorityVerified && alias.ptyId === ptyId) ||
|
||||
ownsPty(physicalPaneKey, ptyId) ||
|
||||
(ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId))
|
||||
)
|
||||
}
|
||||
// Why: hook status is renderer-originated evidence, not PTY ownership.
|
||||
// ID-less moves are safe only after a prior verified transfer minted an alias.
|
||||
return alias?.authorityVerified === true
|
||||
}
|
||||
|
||||
registerPaneKeyAlias(
|
||||
legacyPaneKey: string,
|
||||
stablePaneKey: string,
|
||||
ptyId?: string,
|
||||
updatedAt = Date.now(),
|
||||
options?: { overwriteExisting?: boolean }
|
||||
options?: { overwriteExisting?: boolean; authorityVerified?: boolean }
|
||||
): void {
|
||||
const legacy = parseLegacyNumericPaneKey(legacyPaneKey)
|
||||
const stable = parsePaneKey(stablePaneKey)
|
||||
const stable = isValidPaneKey(stablePaneKey) ? parsePaneKey(stablePaneKey) : null
|
||||
if (!legacy || !stable || legacy.tabId !== stable.tabId) {
|
||||
return
|
||||
}
|
||||
@@ -957,24 +1035,110 @@ export class AgentHookServer {
|
||||
typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId
|
||||
const normalizedUpdatedAt =
|
||||
Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now())
|
||||
const authorityVerified = options?.authorityVerified ?? false
|
||||
if (
|
||||
existing &&
|
||||
existing.stablePaneKey === stablePaneKey &&
|
||||
existing.ptyId === (normalizedPtyId ?? null) &&
|
||||
existing.updatedAt === normalizedUpdatedAt
|
||||
existing.updatedAt === normalizedUpdatedAt &&
|
||||
existing.authorityVerified === authorityVerified
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.legacyPaneKeyAliases.set(legacy.paneKey, {
|
||||
stablePaneKey,
|
||||
ptyId: normalizedPtyId ?? null,
|
||||
updatedAt: normalizedUpdatedAt
|
||||
updatedAt: normalizedUpdatedAt,
|
||||
authorityVerified
|
||||
})
|
||||
this.boundPaneKeyAliases()
|
||||
if (normalizedPtyId) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
}
|
||||
|
||||
transferPaneAuthority(
|
||||
fromPaneKey: string,
|
||||
toPaneKey: string,
|
||||
ptyId?: string,
|
||||
updatedAt = Date.now(),
|
||||
options?: { authorityVerified?: boolean }
|
||||
): void {
|
||||
if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) {
|
||||
return
|
||||
}
|
||||
const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey)
|
||||
const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId)
|
||||
const existing = this.legacyPaneKeyAliases.get(physicalPaneKey)
|
||||
const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey)
|
||||
movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey)
|
||||
const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
if (movedStatus) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.state.lastStatusByPaneKey.set(toPaneKey, {
|
||||
...movedStatus,
|
||||
paneKey: toPaneKey,
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
}
|
||||
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
||||
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
||||
}
|
||||
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
|
||||
if (promptDedupe !== undefined) {
|
||||
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
|
||||
}
|
||||
this.clearAssistantMessageRetry(previousOwnerPaneKey)
|
||||
// Why: the live process keeps posting the physical source key after detach;
|
||||
// persist one chain-safe mapping to whichever surface currently owns it.
|
||||
this.legacyPaneKeyAliases.set(physicalPaneKey, {
|
||||
stablePaneKey: toPaneKey,
|
||||
ptyId: normalizedPtyId,
|
||||
updatedAt,
|
||||
authorityVerified: options?.authorityVerified ?? true
|
||||
})
|
||||
this.boundPaneKeyAliases()
|
||||
this.closedAgentStatusPaneKeys.delete(toPaneKey)
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
if (hadStatus) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
retirePaneAuthority(paneKey: string): void {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneKeys = new Set([paneKey, ownerPaneKey])
|
||||
let aliasChanged = false
|
||||
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) {
|
||||
this.legacyPaneKeyAliases.delete(physicalPaneKey)
|
||||
paneKeys.add(physicalPaneKey)
|
||||
paneKeys.add(entry.stablePaneKey)
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
||||
for (const key of paneKeys) {
|
||||
this.markPaneClosedForAgentStatus(key)
|
||||
this.clearAssistantMessageRetry(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
this.promptSentDedupeByPaneKey.delete(key)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
clearPaneKeyAliasesForPty(
|
||||
ptyId: string,
|
||||
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
|
||||
@@ -1030,10 +1194,9 @@ export class AgentHookServer {
|
||||
if (!stablePaneKey) {
|
||||
return body
|
||||
}
|
||||
// Why: pre-migration live shells keep posting their immutable numeric
|
||||
// ORCA_PANE_KEY. The reattach path proves the UUID leaf once, then this
|
||||
// bridge lets hook caches and renderer state use only the stable key.
|
||||
return { ...record, paneKey: stablePaneKey }
|
||||
// Why: migrated and detached shells keep posting an immutable physical
|
||||
// pane key; normalize both pane and tab identity to the current owner.
|
||||
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
|
||||
}
|
||||
|
||||
ingestTerminalStatus(event: {
|
||||
@@ -1043,7 +1206,8 @@ export class AgentHookServer {
|
||||
connectionId?: string | null
|
||||
payload: ParsedAgentStatusPayload
|
||||
}): void {
|
||||
const paneKey = this.resolvePaneKeyAlias(event.paneKey.trim())
|
||||
const physicalPaneKey = event.paneKey.trim()
|
||||
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
if (paneKey.length === 0) {
|
||||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
@@ -1052,11 +1216,16 @@ export class AgentHookServer {
|
||||
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
|
||||
return
|
||||
}
|
||||
const tabId =
|
||||
const reportedTabId =
|
||||
event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined
|
||||
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
|
||||
if (
|
||||
paneKey === physicalPaneKey &&
|
||||
reportedTabId !== undefined &&
|
||||
reportedTabId !== parsedPaneKey.tabId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
|
||||
if (this.shouldSuppressClosedTabStatus(paneKey)) {
|
||||
return
|
||||
}
|
||||
@@ -1135,7 +1304,8 @@ export class AgentHookServer {
|
||||
// Why: match the listener's HTTP path — `normalizeHookPayload` trims and
|
||||
// length-caps paneKey before caching, so the cache key here must follow
|
||||
// the same rule or remote-vs-local events for the same pane would diverge.
|
||||
const paneKey = this.resolvePaneKeyAlias(envelope.paneKey.trim())
|
||||
const physicalPaneKey = envelope.paneKey.trim()
|
||||
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
if (paneKey.length === 0) {
|
||||
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
||||
@@ -1156,13 +1326,18 @@ export class AgentHookServer {
|
||||
// Why: mirror the HTTP path's `readStringField` behavior — trim and treat
|
||||
// empty-after-trim as undefined rather than letting a literal "" leak
|
||||
// into the event.
|
||||
const tabId =
|
||||
const reportedTabId =
|
||||
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
|
||||
? envelope.tabId.trim()
|
||||
: undefined
|
||||
if (tabId !== undefined && tabId !== parsedPaneKey.tabId) {
|
||||
if (
|
||||
paneKey === physicalPaneKey &&
|
||||
reportedTabId !== undefined &&
|
||||
reportedTabId !== parsedPaneKey.tabId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
|
||||
if (this.shouldSuppressClosedTabStatus(paneKey)) {
|
||||
return
|
||||
}
|
||||
@@ -1363,6 +1538,7 @@ export class AgentHookServer {
|
||||
this.runtimeObservedStatusPaneKeys.clear()
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
this.notifyStatusChangeListeners()
|
||||
@@ -1432,13 +1608,13 @@ export class AgentHookServer {
|
||||
|
||||
let aliasChanged = false
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (
|
||||
paneCacheKeyMatchesTab(legacyPaneKey, tabId) ||
|
||||
paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)
|
||||
) {
|
||||
const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)
|
||||
if (ownerMatches) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
paneKeysToClear.add(legacyPaneKey)
|
||||
paneKeysToClear.add(entry.stablePaneKey)
|
||||
this.markPaneClosedForAgentStatus(legacyPaneKey)
|
||||
this.markPaneClosedForAgentStatus(entry.stablePaneKey)
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,9 @@ describe('listWorkItems', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--assignee',
|
||||
'@me'
|
||||
'@me',
|
||||
'--search',
|
||||
'sort:updated-desc'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -205,7 +207,9 @@ describe('listWorkItems', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--assignee',
|
||||
'@me'
|
||||
'@me',
|
||||
'--search',
|
||||
'sort:updated-desc'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -406,7 +410,9 @@ describe('listWorkItems', () => {
|
||||
'acme/widgets',
|
||||
'--state',
|
||||
'open',
|
||||
'--draft'
|
||||
'--draft',
|
||||
'--search',
|
||||
'sort:updated-desc'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -464,7 +470,9 @@ describe('listWorkItems', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--state',
|
||||
'merged'
|
||||
'merged',
|
||||
'--search',
|
||||
'sort:updated-desc'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -528,7 +536,7 @@ describe('listWorkItems', () => {
|
||||
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged']),
|
||||
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }])
|
||||
@@ -597,7 +605,7 @@ describe('listWorkItems', () => {
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--search', 'review-requested:@me']),
|
||||
expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
@@ -606,6 +614,34 @@ describe('listWorkItems', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10, 'is:issue is:open')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--search', 'sort:updated-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
||||
|
||||
await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z')
|
||||
|
||||
// Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's
|
||||
// exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']),
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
})
|
||||
|
||||
it('returns open issues and PRs for the all-open preset query', async () => {
|
||||
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
@@ -652,7 +688,9 @@ describe('listWorkItems', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--state',
|
||||
'open'
|
||||
'open',
|
||||
'--search',
|
||||
'sort:updated-desc'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
@@ -667,7 +705,9 @@ describe('listWorkItems', () => {
|
||||
'--repo',
|
||||
'acme/widgets',
|
||||
'--state',
|
||||
'open'
|
||||
'open',
|
||||
'--search',
|
||||
'sort:updated-desc'
|
||||
],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
|
||||
@@ -455,6 +455,12 @@ type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
|
||||
|
||||
const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees'
|
||||
|
||||
// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch
|
||||
// must return rows newest-updated-first for the cursor to advance correctly.
|
||||
// This is the search-qualifier spelling of the same contract the REST list
|
||||
// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649).
|
||||
const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc'
|
||||
|
||||
const WORK_ITEM_PR_LIST_JSON_FIELDS =
|
||||
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
|
||||
|
||||
@@ -996,11 +1002,13 @@ function buildWorkItemListArgs(args: {
|
||||
if (excludeMergedFromClosed) {
|
||||
searchParts.push('-is:merged')
|
||||
}
|
||||
// Why: cursor-based pagination. GitHub search supports updated:<DATE to
|
||||
// fetch items older than the cursor. We use the oldest item's updatedAt
|
||||
// from the previous page as the cursor.
|
||||
// Why: cursor-based pagination. GitHub search supports updated:<=DATE to
|
||||
// fetch items at or older than the cursor. We use the oldest item's updatedAt
|
||||
// from the previous page as the cursor. The bound is inclusive (`<=`) so items
|
||||
// sharing the boundary row's exact updatedAt aren't skipped between pages; the
|
||||
// renderer dedupes the re-fetched boundary rows by id (#8649).
|
||||
if (before) {
|
||||
searchParts.push(`updated:<${before}`)
|
||||
searchParts.push(`updated:<=${before}`)
|
||||
}
|
||||
if (kind === 'pr' && query.reviewRequested) {
|
||||
searchParts.push(`review-requested:${query.reviewRequested}`)
|
||||
@@ -1011,9 +1019,14 @@ function buildWorkItemListArgs(args: {
|
||||
if (query.freeText) {
|
||||
searchParts.push(query.freeText)
|
||||
}
|
||||
if (searchParts.length > 0) {
|
||||
out.push('--search', searchParts.join(' '))
|
||||
}
|
||||
// Why: pagination cursors slice on updatedAt, but `gh issue list` defaults
|
||||
// to created-desc and `--search` defaults to best-match. `gh issue/pr list`
|
||||
// has no --sort flag, so the only lever is the search query — which forces us
|
||||
// to always emit --search. Without pinning the sort, recently-updated old
|
||||
// items never appear on any page, so the pager advertises pages the fetch
|
||||
// chain can never reach (#8649).
|
||||
searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER)
|
||||
out.push('--search', searchParts.join(' '))
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
|
||||
const dropStatusEntry = vi.fn()
|
||||
const dropStatusEntriesByTabPrefix = vi.fn()
|
||||
const retirePaneAuthority = vi.fn()
|
||||
const transferPaneAuthority = vi.fn()
|
||||
const canTransferPaneAuthority = vi.fn(() => true)
|
||||
const getStatusSnapshot = vi.fn()
|
||||
const inferInterrupt = vi.fn()
|
||||
const clearMigrationUnsupportedPtysByTabPrefix = vi.fn()
|
||||
@@ -42,6 +45,9 @@ vi.mock('../agent-hooks/server', async () => {
|
||||
agentHookServer: {
|
||||
dropStatusEntry,
|
||||
dropStatusEntriesByTabPrefix,
|
||||
retirePaneAuthority,
|
||||
transferPaneAuthority,
|
||||
canTransferPaneAuthority,
|
||||
getStatusSnapshot,
|
||||
inferInterrupt
|
||||
}
|
||||
@@ -100,6 +106,10 @@ vi.mock('../kimi/hook-service', () => ({
|
||||
beforeEach(() => {
|
||||
dropStatusEntry.mockReset()
|
||||
dropStatusEntriesByTabPrefix.mockReset()
|
||||
retirePaneAuthority.mockReset()
|
||||
transferPaneAuthority.mockReset()
|
||||
canTransferPaneAuthority.mockReset()
|
||||
canTransferPaneAuthority.mockReturnValue(true)
|
||||
getStatusSnapshot.mockReset()
|
||||
inferInterrupt.mockReset()
|
||||
clearMigrationUnsupportedPtysByTabPrefix.mockReset()
|
||||
@@ -378,3 +388,73 @@ describe('agentStatus:dropByTabPrefix IPC', () => {
|
||||
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:dropByTabPrefix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent pane authority IPC', () => {
|
||||
it('retires one validated pane authority', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
onHandlers.get('agentStatus:retirePaneAuthority')!({}, PANE_KEY)
|
||||
|
||||
expect(retirePaneAuthority).toHaveBeenCalledWith(PANE_KEY)
|
||||
expect(clearMigrationUnsupportedPtysForPaneKey).toHaveBeenCalledWith(PANE_KEY)
|
||||
})
|
||||
|
||||
it('transfers validated pane authority with its provider PTY', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
onHandlers.get('agentStatus:transferPaneAuthority')!(
|
||||
{},
|
||||
{
|
||||
fromPaneKey: PANE_KEY,
|
||||
toPaneKey: CHILD_PANE_KEY,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
)
|
||||
|
||||
expect(transferPaneAuthority).toHaveBeenCalledWith(PANE_KEY, CHILD_PANE_KEY, 'pty-1')
|
||||
})
|
||||
|
||||
it('rejects malformed pane authority messages and replaces prior listeners', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
|
||||
onHandlers.get('agentStatus:retirePaneAuthority')!({}, 'tab-1:0')
|
||||
onHandlers.get('agentStatus:transferPaneAuthority')!(
|
||||
{},
|
||||
{
|
||||
fromPaneKey: PANE_KEY,
|
||||
toPaneKey: 'invalid',
|
||||
ptyId: ''
|
||||
}
|
||||
)
|
||||
|
||||
expect(retirePaneAuthority).not.toHaveBeenCalled()
|
||||
expect(transferPaneAuthority).not.toHaveBeenCalled()
|
||||
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:retirePaneAuthority')
|
||||
expect(removeAllListeners).toHaveBeenCalledWith('agentStatus:transferPaneAuthority')
|
||||
})
|
||||
|
||||
it('rejects unowned and oversized pane authority transfers', async () => {
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers()
|
||||
const transfer = onHandlers.get('agentStatus:transferPaneAuthority')!
|
||||
|
||||
canTransferPaneAuthority.mockReturnValue(false)
|
||||
transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY, ptyId: 'forged-pty' })
|
||||
transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY })
|
||||
canTransferPaneAuthority.mockReturnValue(true)
|
||||
transfer({}, { fromPaneKey: PANE_KEY, toPaneKey: CHILD_PANE_KEY, ptyId: 'x'.repeat(513) })
|
||||
transfer(
|
||||
{},
|
||||
{
|
||||
fromPaneKey: `${'x'.repeat(180)}:11111111-1111-4111-8111-111111111111`,
|
||||
toPaneKey: CHILD_PANE_KEY,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
)
|
||||
|
||||
expect(transferPaneAuthority).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+20
-33
@@ -5,9 +5,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import {
|
||||
clearMigrationUnsupportedPtysByTabPrefix,
|
||||
@@ -27,44 +25,26 @@ import { hermesHookService } from '../hermes/hook-service'
|
||||
import { devinHookService } from '../devin/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc'
|
||||
import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership'
|
||||
import {
|
||||
enrichAgentStatusIpcPayload,
|
||||
isValidAgentStatusDropTabId,
|
||||
type AgentStatusRuntimeEnrichment
|
||||
} from './agent-status-ipc-boundary'
|
||||
|
||||
type AgentStatusRuntimeEnrichment = Pick<
|
||||
OrcaRuntimeService,
|
||||
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
|
||||
>
|
||||
|
||||
const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160
|
||||
|
||||
function enrichAgentStatusIpcPayload(
|
||||
data: AgentStatusIpcPayload,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined
|
||||
): AgentStatusIpcPayload {
|
||||
if (!runtime) {
|
||||
return data
|
||||
}
|
||||
const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey)
|
||||
const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey)
|
||||
return {
|
||||
...data,
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(orchestration ? { orchestration } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isValidAgentStatusDropTabId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH &&
|
||||
value.trim() === value &&
|
||||
isValidTerminalTabId(value)
|
||||
)
|
||||
type AgentHookHandlerDependencies = {
|
||||
getPtyIdForPaneKey?: (paneKey: string) => string | undefined
|
||||
}
|
||||
|
||||
// Why: install/remove are intentionally not exposed to the renderer. Orca
|
||||
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
|
||||
// renderer-triggered remove would be silently reverted on the next launch
|
||||
// and mislead the user.
|
||||
export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment): void {
|
||||
export function registerAgentHookHandlers(
|
||||
runtime?: AgentStatusRuntimeEnrichment,
|
||||
dependencies: AgentHookHandlerDependencies = {}
|
||||
): void {
|
||||
// Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration
|
||||
// never throws "Attempted to register a second handler..." if this function is
|
||||
// ever invoked more than once (e.g. the macOS app re-activation path that
|
||||
@@ -120,6 +100,13 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
|
||||
console.warn('[agent-hooks] dropStatusEntriesByTabPrefix failed:', err)
|
||||
}
|
||||
})
|
||||
registerAgentPaneAuthorityIpcHandlers({
|
||||
ownsPty: createAgentPaneAuthorityOwnership({
|
||||
getPtyIdForPaneKey: dependencies.getPtyIdForPaneKey,
|
||||
getRuntimeTerminalHandleForPaneKey: (paneKey) =>
|
||||
runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
|
||||
})
|
||||
})
|
||||
ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => {
|
||||
// Why: the renderer pulls this after workspace hydration, so startup cannot
|
||||
// lose replayed statuses while its local store is still empty. Match the
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import { clearMigrationUnsupportedPtysForPaneKey } from '../agent-hooks/migration-unsupported-pty-state'
|
||||
|
||||
const MAX_PTY_ID_LENGTH = 512
|
||||
|
||||
export type AgentPaneAuthorityOwnership = {
|
||||
ownsPty: (paneKey: string, ptyId: string) => boolean
|
||||
}
|
||||
|
||||
export function registerAgentPaneAuthorityIpcHandlers(
|
||||
ownership: AgentPaneAuthorityOwnership
|
||||
): void {
|
||||
ipcMain.removeAllListeners('agentStatus:retirePaneAuthority')
|
||||
ipcMain.removeAllListeners('agentStatus:transferPaneAuthority')
|
||||
ipcMain.on('agentStatus:retirePaneAuthority', (_event, paneKey: unknown) => {
|
||||
if (typeof paneKey !== 'string' || !isValidPaneKey(paneKey)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
agentHookServer.retirePaneAuthority(paneKey)
|
||||
clearMigrationUnsupportedPtysForPaneKey(paneKey)
|
||||
} catch (err) {
|
||||
console.warn('[agent-hooks] retirePaneAuthority failed:', err)
|
||||
}
|
||||
})
|
||||
ipcMain.on('agentStatus:transferPaneAuthority', (_event, value: unknown) => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return
|
||||
}
|
||||
const args = value as Record<string, unknown>
|
||||
const ptyId = typeof args.ptyId === 'string' ? args.ptyId : undefined
|
||||
if (
|
||||
typeof args.fromPaneKey !== 'string' ||
|
||||
typeof args.toPaneKey !== 'string' ||
|
||||
!isValidPaneKey(args.fromPaneKey) ||
|
||||
!isValidPaneKey(args.toPaneKey) ||
|
||||
args.fromPaneKey === args.toPaneKey ||
|
||||
(args.ptyId !== undefined &&
|
||||
(typeof args.ptyId !== 'string' ||
|
||||
args.ptyId.length > MAX_PTY_ID_LENGTH ||
|
||||
args.ptyId.trim() !== args.ptyId ||
|
||||
args.ptyId.length === 0)) ||
|
||||
!agentHookServer.canTransferPaneAuthority(args.fromPaneKey, ptyId, ownership.ownsPty)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
agentHookServer.transferPaneAuthority(args.fromPaneKey, args.toPaneKey, ptyId)
|
||||
} catch (err) {
|
||||
console.warn('[agent-hooks] transferPaneAuthority failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership'
|
||||
|
||||
describe('agent pane authority ownership', () => {
|
||||
it('accepts only the PTY bound to the physical local pane', () => {
|
||||
const ownsPty = createAgentPaneAuthorityOwnership({
|
||||
getPtyIdForPaneKey: (paneKey) => (paneKey === 'source-pane' ? 'pty-1' : undefined)
|
||||
})
|
||||
|
||||
expect(ownsPty('source-pane', 'pty-1')).toBe(true)
|
||||
expect(ownsPty('source-pane', 'pty-forged')).toBe(false)
|
||||
expect(ownsPty('other-pane', 'pty-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches scoped and legacy runtime IDs to the authoritative terminal handle', () => {
|
||||
const getRuntimeTerminalHandleForPaneKey = vi.fn(() => 'terminal:one')
|
||||
const ownsPty = createAgentPaneAuthorityOwnership({
|
||||
getRuntimeTerminalHandleForPaneKey
|
||||
})
|
||||
|
||||
expect(ownsPty('source-pane', 'remote:terminal:one')).toBe(true)
|
||||
expect(ownsPty('source-pane', 'remote:env-1@@terminal%3Aone')).toBe(true)
|
||||
expect(ownsPty('source-pane', 'remote:env-1@@other')).toBe(false)
|
||||
expect(ownsPty('source-pane', 'remote:env-1@@%E0%A4%A')).toBe(false)
|
||||
expect(ownsPty('source-pane', 'remote:@@terminal%3Aone')).toBe(false)
|
||||
expect(ownsPty('source-pane', 'remote:%20@@terminal%3Aone')).toBe(false)
|
||||
expect(ownsPty('source-pane', 'remote:env-1@@')).toBe(false)
|
||||
expect(ownsPty('source-pane', 'remote:env-1@@terminal:one')).toBe(false)
|
||||
expect(ownsPty('source-pane', 'remote:terminal%3Aone')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
export type AgentPaneAuthorityOwnershipSources = {
|
||||
getPtyIdForPaneKey?: (paneKey: string) => string | undefined
|
||||
getRuntimeTerminalHandleForPaneKey?: (paneKey: string) => string | undefined
|
||||
}
|
||||
|
||||
function getRemoteRuntimeHandle(ptyId: string): string | null {
|
||||
if (!ptyId.startsWith('remote:')) {
|
||||
return null
|
||||
}
|
||||
const rest = ptyId.slice(7)
|
||||
const separatorIndex = rest.indexOf('@@')
|
||||
if (separatorIndex === -1) {
|
||||
return rest.length > 0 && rest.trim() === rest ? rest : null
|
||||
}
|
||||
const encodedOwner = rest.slice(0, separatorIndex)
|
||||
const encodedHandle = rest.slice(separatorIndex + 2)
|
||||
if (!encodedOwner || !encodedHandle) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const owner = decodeURIComponent(encodedOwner)
|
||||
const handle = decodeURIComponent(encodedHandle)
|
||||
if (!owner || owner.trim() !== owner || !handle || handle.trim() !== handle) {
|
||||
return null
|
||||
}
|
||||
return `remote:${encodeURIComponent(owner)}@@${encodeURIComponent(handle)}` === ptyId
|
||||
? handle
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentPaneAuthorityOwnership(
|
||||
sources: AgentPaneAuthorityOwnershipSources
|
||||
): (paneKey: string, ptyId: string) => boolean {
|
||||
return (paneKey, ptyId) => {
|
||||
if (sources.getPtyIdForPaneKey?.(paneKey) === ptyId) {
|
||||
return true
|
||||
}
|
||||
const runtimeHandle = sources.getRuntimeTerminalHandleForPaneKey?.(paneKey)
|
||||
return Boolean(runtimeHandle && getRemoteRuntimeHandle(ptyId) === runtimeHandle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
export type AgentStatusRuntimeEnrichment = Pick<
|
||||
OrcaRuntimeService,
|
||||
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
|
||||
>
|
||||
|
||||
const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160
|
||||
|
||||
export function enrichAgentStatusIpcPayload(
|
||||
data: AgentStatusIpcPayload,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined
|
||||
): AgentStatusIpcPayload {
|
||||
if (!runtime) {
|
||||
return data
|
||||
}
|
||||
const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey)
|
||||
const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey)
|
||||
return {
|
||||
...data,
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(orchestration ? { orchestration } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAgentStatusDropTabId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH &&
|
||||
value.trim() === value &&
|
||||
isValidTerminalTabId(value)
|
||||
)
|
||||
}
|
||||
@@ -226,10 +226,11 @@ describe('pty:management IPC handlers', () => {
|
||||
async function runKillAllWithPolls(
|
||||
handler: (event: unknown, args?: unknown) => unknown,
|
||||
pollCount: number = 65
|
||||
): Promise<{ killedCount: number; remainingCount: number }> {
|
||||
): Promise<{ killedCount: number; remainingCount: number; killedSessionIds: string[] }> {
|
||||
const resultPromise = handler({}) as Promise<{
|
||||
killedCount: number
|
||||
remainingCount: number
|
||||
killedSessionIds: string[]
|
||||
}>
|
||||
// Why: advance the loop's sleeps one at a time. Between each sleep the
|
||||
// handler awaits collectSessions (a microtask), so we need to flush
|
||||
@@ -275,7 +276,11 @@ describe('pty:management IPC handlers', () => {
|
||||
const handlers = buildHandlerMap()
|
||||
const result = await runKillAllWithPolls(handlers['pty:management:killAll'])
|
||||
|
||||
expect(result).toEqual({ killedCount: 3, remainingCount: 0 })
|
||||
expect(result).toEqual({
|
||||
killedCount: 3,
|
||||
remainingCount: 0,
|
||||
killedSessionIds: ['new-1', 'new-2', 'old-1']
|
||||
})
|
||||
// Each initial session receives exactly one shutdown — no retries.
|
||||
expect(current.shutdown).toHaveBeenCalledTimes(2)
|
||||
expect(current.shutdown).toHaveBeenCalledWith('new-1', { immediate: true })
|
||||
@@ -302,7 +307,7 @@ describe('pty:management IPC handlers', () => {
|
||||
const handlers = buildHandlerMap()
|
||||
const result = await runKillAllWithPolls(handlers['pty:management:killAll'])
|
||||
|
||||
expect(result).toEqual({ killedCount: 0, remainingCount: 1 })
|
||||
expect(result).toEqual({ killedCount: 0, remainingCount: 1, killedSessionIds: [] })
|
||||
// One shutdown fired — no per-session retry. Initial-snapshot
|
||||
// accounting means the stuck session is counted once.
|
||||
expect(current.shutdown).toHaveBeenCalledTimes(1)
|
||||
@@ -336,7 +341,11 @@ describe('pty:management IPC handlers', () => {
|
||||
const handlers = buildHandlerMap()
|
||||
const result = await runKillAllWithPolls(handlers['pty:management:killAll'])
|
||||
|
||||
expect(result).toEqual({ killedCount: 2, remainingCount: 0 })
|
||||
expect(result).toEqual({
|
||||
killedCount: 2,
|
||||
remainingCount: 0,
|
||||
killedSessionIds: ['a', 'b']
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows per-session shutdown rejections without stopping the batch', async () => {
|
||||
@@ -370,7 +379,11 @@ describe('pty:management IPC handlers', () => {
|
||||
expect(current.shutdown).toHaveBeenCalledWith('a', { immediate: true })
|
||||
expect(current.shutdown).toHaveBeenCalledWith('b', { immediate: true })
|
||||
// 'a' rejected and is still alive → counts as remaining; 'b' reaped.
|
||||
expect(result).toEqual({ killedCount: 1, remainingCount: 1 })
|
||||
expect(result).toEqual({
|
||||
killedCount: 1,
|
||||
remainingCount: 1,
|
||||
killedSessionIds: ['b']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -74,7 +74,11 @@ export function registerDaemonManagementHandlers(): void {
|
||||
// daemons aren't killed here.
|
||||
ipcMain.handle(
|
||||
'pty:management:killAll',
|
||||
async (): Promise<{ killedCount: number; remainingCount: number }> => {
|
||||
async (): Promise<{
|
||||
killedCount: number
|
||||
remainingCount: number
|
||||
killedSessionIds: string[]
|
||||
}> => {
|
||||
const adapters = getDaemonAdapters()
|
||||
// Why: snapshot the initial session set once, up front. All subsequent
|
||||
// accounting is relative to these IDs. If the renderer respawns panes
|
||||
@@ -87,7 +91,7 @@ export function registerDaemonManagementHandlers(): void {
|
||||
const initialCount = initial.length
|
||||
|
||||
if (initialCount === 0) {
|
||||
return { killedCount: 0, remainingCount: 0 }
|
||||
return { killedCount: 0, remainingCount: 0, killedSessionIds: [] }
|
||||
}
|
||||
|
||||
// Why: fire one shutdown per initial session, in parallel, once — no
|
||||
@@ -125,20 +129,29 @@ export function registerDaemonManagementHandlers(): void {
|
||||
// session count) is what keeps the math honest when the renderer
|
||||
// respawns panes with fresh IDs mid-kill.
|
||||
let remainingOriginalCount = initialCount
|
||||
let remainingOriginalIds = initialIds
|
||||
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
await sleep(POLL_INTERVAL_MS)
|
||||
const current = await collectSessions(adapters)
|
||||
remainingOriginalCount = current.reduce(
|
||||
(count, s) => (initialIds.has(s.sessionId) ? count + 1 : count),
|
||||
0
|
||||
remainingOriginalIds = new Set(
|
||||
current
|
||||
.filter((session) => initialIds.has(session.sessionId))
|
||||
.map((session) => session.sessionId)
|
||||
)
|
||||
remainingOriginalCount = remainingOriginalIds.size
|
||||
if (remainingOriginalCount === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const killedCount = initialCount - remainingOriginalCount
|
||||
return { killedCount, remainingCount: remainingOriginalCount }
|
||||
return {
|
||||
killedCount,
|
||||
remainingCount: remainingOriginalCount,
|
||||
killedSessionIds: [...initialIds].filter(
|
||||
(sessionId) => !remainingOriginalIds.has(sessionId)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1695,7 +1695,11 @@ describe('registerPtyHandlers', () => {
|
||||
value: 'linux'
|
||||
})
|
||||
try {
|
||||
const env = await daemonSpawnAndGetEnv({ PATH: '/usr/local/bin:/usr/bin' })
|
||||
// Why: overriding process.platform does not change the already-loaded
|
||||
// node:path dialect; keep this synthetic PATH internally consistent.
|
||||
const env = await daemonSpawnAndGetEnv({
|
||||
PATH: ['/usr/local/bin', '/usr/bin'].join(delimiter)
|
||||
})
|
||||
const entries = env.PATH.split(delimiter)
|
||||
const shimDir = join('/tmp/orca-user-data', 'linux-orca-cli-shim')
|
||||
// Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca
|
||||
@@ -3326,6 +3330,17 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects runtime terminal IDs before unowned local provider routing', async () => {
|
||||
const shutdown = vi.spyOn(getLocalPtyProvider(), 'shutdown')
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('pty:kill')!(null, { id: 'remote:env-1@@terminal-1' })
|
||||
).rejects.toThrow('Invalid PTY provider id')
|
||||
expect(shutdown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('synthesizes runtime exit after ordinary daemon-backed pty kill', async () => {
|
||||
const shutdown = vi.fn(async () => undefined)
|
||||
const runtime = {
|
||||
@@ -6724,6 +6739,8 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
it('spawns a plain POSIX login shell and queues startup commands for the live session', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalHome = process.env.HOME
|
||||
const originalOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR
|
||||
const originalShell = process.env.SHELL
|
||||
const originalZdotdir = process.env.ZDOTDIR
|
||||
|
||||
@@ -6731,6 +6748,9 @@ describe('registerPtyHandlers', () => {
|
||||
configurable: true,
|
||||
value: 'darwin'
|
||||
})
|
||||
// Why: this test simulates macOS even when Vitest runs on a Windows host.
|
||||
process.env.HOME = '/Users/test'
|
||||
delete process.env.ORCA_ORIG_ZDOTDIR
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
delete process.env.ZDOTDIR
|
||||
|
||||
@@ -6748,6 +6768,16 @@ describe('registerPtyHandlers', () => {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalOrcaOrigZdotdir === undefined) {
|
||||
delete process.env.ORCA_ORIG_ZDOTDIR
|
||||
} else {
|
||||
process.env.ORCA_ORIG_ZDOTDIR = originalOrcaOrigZdotdir
|
||||
}
|
||||
if (originalShell === undefined) {
|
||||
delete process.env.SHELL
|
||||
} else {
|
||||
@@ -10469,7 +10499,9 @@ describe('registerPtyHandlers', () => {
|
||||
expect(registerPaneKeyAliasMock).toHaveBeenCalledWith(
|
||||
'tab-1:0',
|
||||
stablePaneKey,
|
||||
expect.any(String)
|
||||
expect.any(String),
|
||||
expect.any(Number),
|
||||
{ authorityVerified: true }
|
||||
)
|
||||
expect(clearMigrationUnsupportedPtysForPaneKeyMock).toHaveBeenCalledWith(stablePaneKey)
|
||||
expect(setMigrationUnsupportedPtyMock).not.toHaveBeenCalled()
|
||||
|
||||
+8
-1
@@ -4322,7 +4322,9 @@ export function registerPtyHandlers(
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
legacySpawnPaneKey.paneKey,
|
||||
migrationUnsupportedPaneKey,
|
||||
result.id
|
||||
result.id,
|
||||
Date.now(),
|
||||
{ authorityVerified: true }
|
||||
)
|
||||
clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey)
|
||||
} else if (validatedPaneKey) {
|
||||
@@ -4951,6 +4953,11 @@ export function registerPtyHandlers(
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
|
||||
if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) {
|
||||
// Why: runtime terminal handles belong to terminal.close; allowing them
|
||||
// to fall through unowned PTY routing could target the local provider.
|
||||
throw new Error('Invalid PTY provider id')
|
||||
}
|
||||
const ownedConnectionId = ptyOwnership.get(args.id)
|
||||
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
|
||||
const connectionId = ownedConnectionId ?? parsedSshId?.connectionId
|
||||
|
||||
@@ -466,7 +466,9 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
|
||||
expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch })
|
||||
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime, {
|
||||
getPtyIdForPaneKey: expect.any(Function)
|
||||
})
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
|
||||
@@ -53,6 +53,7 @@ import { registerSpeechHandlers } from './speech'
|
||||
import { registerOrcaProfileHandlers } from './orca-profiles'
|
||||
import { registerCodexAccountHandlers } from './codex-accounts'
|
||||
import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { getPtyIdForPaneKey } from './pty'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
@@ -122,7 +123,7 @@ export function registerCoreHandlers(
|
||||
registerCodexUsageHandlers(codexUsage)
|
||||
registerOpenCodeUsageHandlers(openCodeUsage)
|
||||
registerCodexAccountHandlers(codexAccounts)
|
||||
registerAgentHookHandlers(runtime)
|
||||
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
|
||||
@@ -7355,6 +7355,44 @@ describe('Store', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('restores cross-tab pane authority aliases before hook ingestion', async () => {
|
||||
const physicalPaneKey = makePaneKey('tab-source', TEST_LEAF_1)
|
||||
const ownerPaneKey = makePaneKey('tab-target', TEST_LEAF_2)
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
legacyPaneKeyAliasEntries: [
|
||||
{
|
||||
ptyId: 'pty-detached',
|
||||
legacyPaneKey: physicalPaneKey,
|
||||
stablePaneKey: ownerPaneKey,
|
||||
updatedAt: 10
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await createStore()
|
||||
const { agentHookServer } = await import('./agent-hooks/server')
|
||||
agentHookServer.ingestTerminalStatus({
|
||||
paneKey: physicalPaneKey,
|
||||
tabId: 'tab-source',
|
||||
worktreeId: 'wt1',
|
||||
payload: { state: 'working', prompt: 'detached after restart' }
|
||||
})
|
||||
|
||||
expect(agentHookServer.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: ownerPaneKey,
|
||||
tabId: 'tab-target',
|
||||
prompt: 'detached after restart'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('persists fallback aliases when a legacy split layout has no PTY leaf bindings', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
||||
+26
-15
@@ -2264,11 +2264,34 @@ function normalizeLegacyPaneKeyAliasEntries(value: unknown): LegacyPaneKeyAliasE
|
||||
return false
|
||||
}
|
||||
const legacy = parseLegacyNumericPaneKey(candidate.legacyPaneKey)
|
||||
const relocatedSource = parsePaneKey(candidate.legacyPaneKey)
|
||||
const stable = parsePaneKey(candidate.stablePaneKey)
|
||||
return Boolean(legacy && stable && legacy.tabId === stable.tabId)
|
||||
return Boolean(stable && ((legacy && legacy.tabId === stable.tabId) || relocatedSource))
|
||||
})
|
||||
}
|
||||
|
||||
function registerPersistedPaneKeyAlias(entry: LegacyPaneKeyAliasEntry): void {
|
||||
if (parseLegacyNumericPaneKey(entry.legacyPaneKey)) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
entry.legacyPaneKey,
|
||||
entry.stablePaneKey,
|
||||
entry.ptyId,
|
||||
entry.updatedAt,
|
||||
{ overwriteExisting: false }
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: detached agents keep their original UUID pane key across restarts;
|
||||
// restore the physical-to-current-owner mapping before hook replay begins.
|
||||
agentHookServer.transferPaneAuthority(
|
||||
entry.legacyPaneKey,
|
||||
entry.stablePaneKey,
|
||||
entry.ptyId,
|
||||
entry.updatedAt,
|
||||
{ authorityVerified: false }
|
||||
)
|
||||
}
|
||||
|
||||
function mergeLegacyPaneKeyAliasEntries(
|
||||
entries: LegacyPaneKeyAliasEntry[]
|
||||
): LegacyPaneKeyAliasEntry[] {
|
||||
@@ -2648,13 +2671,7 @@ export class Store {
|
||||
setMigrationUnsupportedPty(entry)
|
||||
}
|
||||
for (const entry of normalized.legacyPaneKeyAliasEntries) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
entry.legacyPaneKey,
|
||||
entry.stablePaneKey,
|
||||
entry.ptyId,
|
||||
entry.updatedAt,
|
||||
{ overwriteExisting: false }
|
||||
)
|
||||
registerPersistedPaneKeyAlias(entry)
|
||||
}
|
||||
setMigrationUnsupportedPtyPersistenceListener((entries) => {
|
||||
this.state.migrationUnsupportedPtyEntries = entries
|
||||
@@ -5689,13 +5706,7 @@ export class Store {
|
||||
}
|
||||
}
|
||||
for (const entry of normalized.legacyPaneKeyAliasEntries) {
|
||||
agentHookServer.registerPaneKeyAlias(
|
||||
entry.legacyPaneKey,
|
||||
entry.stablePaneKey,
|
||||
entry.ptyId,
|
||||
entry.updatedAt,
|
||||
{ overwriteExisting: false }
|
||||
)
|
||||
registerPersistedPaneKeyAlias(entry)
|
||||
}
|
||||
session = normalized.session
|
||||
const remappedLeases = remapSshRemotePtyLeaseLeafIds(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MobileNotificationReplayBuffer,
|
||||
type ReplayableMobileNotification
|
||||
} from './mobile-notification-replay'
|
||||
import type { MobileNotificationDispatchEvent } from './orca-runtime'
|
||||
|
||||
function dispatch(
|
||||
buffer: MobileNotificationReplayBuffer,
|
||||
partial: Partial<MobileNotificationDispatchEvent> = {}
|
||||
): ReplayableMobileNotification {
|
||||
const event: MobileNotificationDispatchEvent = {
|
||||
type: 'notification',
|
||||
source: 'agent-task-complete',
|
||||
title: 'Done',
|
||||
body: 'Finished.',
|
||||
...partial
|
||||
}
|
||||
buffer.record(event)
|
||||
return buffer.getMissedSince(0).at(-1) as ReplayableMobileNotification
|
||||
}
|
||||
|
||||
describe('MobileNotificationReplayBuffer', () => {
|
||||
it('assigns a strictly increasing monotonic notificationSeq to every recorded event', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
const a = dispatch(buffer, { notificationId: 'agent:one' })
|
||||
const b = dispatch(buffer, { notificationId: 'agent:two' })
|
||||
const c = dispatch(buffer, { notificationId: 'agent:three' })
|
||||
expect(a.notificationSeq).toBe(1)
|
||||
expect(b.notificationSeq).toBe(2)
|
||||
expect(c.notificationSeq).toBe(3)
|
||||
expect(c.notificationSeq).toBeGreaterThan(b.notificationSeq)
|
||||
expect(b.notificationSeq).toBeGreaterThan(a.notificationSeq)
|
||||
})
|
||||
|
||||
// Why: the client watermarks + dedups on `notificationSeq` (the same field
|
||||
// the live fan-out tags). Replayed events must expose that exact field —
|
||||
// returning a bare `seq` here silently breaks watermark advance on a
|
||||
// replay-only delivery (regression guard for the #8129 field mismatch).
|
||||
it('exposes the client-facing `notificationSeq` field on replayed events', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
const a = dispatch(buffer, { notificationId: 'agent:one' })
|
||||
expect(a.notificationSeq).toBe(1)
|
||||
expect((a as Record<string, unknown>).seq).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns missed notifications that were dispatched after the watermark', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
dispatch(buffer, { notificationId: 'agent:one' })
|
||||
const b = dispatch(buffer, { notificationId: 'agent:two' })
|
||||
dispatch(buffer, { notificationId: 'agent:three' })
|
||||
|
||||
// Client reconnected having delivered up to seq 1.
|
||||
const missed = buffer.getMissedSince(b.notificationSeq - 1)
|
||||
expect(missed.map((e) => e.notificationId)).toEqual(['agent:two', 'agent:three'])
|
||||
})
|
||||
|
||||
it('does NOT return already-delivered notifications (dedupe regression)', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
const a = dispatch(buffer, { notificationId: 'agent:one' })
|
||||
dispatch(buffer, { notificationId: 'agent:two' })
|
||||
dispatch(buffer, { notificationId: 'agent:three' })
|
||||
|
||||
// Client already delivered everything up to seq 3, then reconnects again.
|
||||
const missed = buffer.getMissedSince(a.notificationSeq + 2)
|
||||
expect(missed).toEqual([])
|
||||
})
|
||||
|
||||
it('is idempotent: the same watermark always yields the same set', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
dispatch(buffer, { notificationId: 'agent:one' })
|
||||
dispatch(buffer, { notificationId: 'agent:two' })
|
||||
dispatch(buffer, { notificationId: 'agent:three' })
|
||||
|
||||
const first = buffer.getMissedSince(1)
|
||||
const second = buffer.getMissedSince(1)
|
||||
expect(second).toEqual(first)
|
||||
expect(second.map((e) => e.notificationId)).toEqual(['agent:two', 'agent:three'])
|
||||
})
|
||||
|
||||
it('does not duplicate an event that arrived on both live stream and replay', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
const a = dispatch(buffer, { notificationId: 'agent:one' })
|
||||
dispatch(buffer, { notificationId: 'agent:two' })
|
||||
|
||||
// Live stream delivered seq 1 (agent:one) during a brief liveness spell.
|
||||
// Reconnect asks for everything after 0 — must include agent:one exactly
|
||||
// once, never twice.
|
||||
const missed = buffer.getMissedSince(a.notificationSeq - 1)
|
||||
const ids = missed.map((e) => e.notificationId)
|
||||
expect(ids).toEqual(['agent:one', 'agent:two'])
|
||||
expect(ids.filter((id) => id === 'agent:one')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns the whole buffer when the watermark is 0 (cold open)', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer()
|
||||
dispatch(buffer, { notificationId: 'agent:one' })
|
||||
dispatch(buffer, { notificationId: 'agent:two' })
|
||||
expect(buffer.getMissedSince(0).map((e) => e.notificationId)).toEqual([
|
||||
'agent:one',
|
||||
'agent:two'
|
||||
])
|
||||
})
|
||||
|
||||
it('evicts oldest entries once the capacity is exceeded', () => {
|
||||
const buffer = new MobileNotificationReplayBuffer(2)
|
||||
dispatch(buffer, { notificationId: 'agent:one' })
|
||||
dispatch(buffer, { notificationId: 'agent:two' })
|
||||
dispatch(buffer, { notificationId: 'agent:three' })
|
||||
|
||||
// Capacity is 2, so the first entry is evicted; even from watermark 0 we
|
||||
// only get the two retained plus the newest.
|
||||
const retained = buffer.getMissedSince(0).map((e) => e.notificationId)
|
||||
expect(retained).toEqual(['agent:two', 'agent:three'])
|
||||
expect(buffer.size).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { MobileNotificationEvent } from './orca-runtime'
|
||||
|
||||
// Why: when a mobile client's socket is reaped (background/sleep, or a warm
|
||||
// proxy that delays the heartbeat reap), notifications dispatched in that
|
||||
// window are lost — there is no queue. This buffer is the catch-up source of
|
||||
// truth on the desktop: every notification that flows through
|
||||
// dispatchMobileNotification is recorded with a monotonic seq, and a reconnecting
|
||||
// client asks for everything after the seq it last acknowledged.
|
||||
//
|
||||
// Idempotency (the adversarial-review gate for #8129): replay is keyed by a
|
||||
// global monotonic seq, not by wall-clock. getMissedSince(lastSeenSeq) returns
|
||||
// exactly the events with seq > lastSeenSeq. Re-requesting with the same
|
||||
// watermark returns the same set; requesting with the client's true watermark
|
||||
// can never return an already-delivered event. The client additionally tracks
|
||||
// seen ids, so even a buffer-cap overlap cannot double-push.
|
||||
//
|
||||
// Why the field is `notificationSeq` (not `seq`): the live fan-out in
|
||||
// dispatchMobileNotification tags each event with `notificationSeq`, and the
|
||||
// mobile client watermarks + dedups on that exact field. Replayed events MUST
|
||||
// carry the same field name or the client can't advance its watermark from a
|
||||
// replay-only delivery (it would re-fetch/re-push across a restart). Keeping a
|
||||
// single field name end-to-end is what makes live and replay interchangeable.
|
||||
export type ReplayableMobileNotification = MobileNotificationEvent & {
|
||||
notificationSeq: number
|
||||
}
|
||||
|
||||
// Why: bound the buffer. 256 completes a few minutes of real agent activity and
|
||||
// tracks the client's existing MAX_SCHEDULED_NOTIFICATIONS (256) cap, far beyond
|
||||
// any background window a reconnect can still salvage. Beyond the cap we evict
|
||||
// oldest-first; a reconnect after that long is effectively a cold open and the
|
||||
// live stream resumes from current, so dropping older entries is acceptable.
|
||||
const DEFAULT_CAPACITY = 256
|
||||
|
||||
export class MobileNotificationReplayBuffer {
|
||||
private readonly capacity: number
|
||||
private seq = 0
|
||||
private readonly buffer: ReplayableMobileNotification[] = []
|
||||
|
||||
constructor(capacity: number = DEFAULT_CAPACITY) {
|
||||
this.capacity = capacity
|
||||
}
|
||||
|
||||
// Records a dispatched event and returns the monotonic seq assigned to it.
|
||||
// Callers surface the seq so clients can watermark their last-seen position
|
||||
// (both on the live fan-out and on explicit catch-up requests).
|
||||
record(event: MobileNotificationEvent): number {
|
||||
const seq = ++this.seq
|
||||
this.buffer.push({ ...event, notificationSeq: seq })
|
||||
if (this.buffer.length > this.capacity) {
|
||||
// Why: insertion-order array; oldest entries sit at the front.
|
||||
this.buffer.splice(0, this.buffer.length - this.capacity)
|
||||
}
|
||||
return seq
|
||||
}
|
||||
|
||||
// Returns every recorded event with seq strictly greater than lastSeenSeq.
|
||||
// Because seq is monotonic and global, this is an exact, idempotent cut:
|
||||
// the same lastSeenSeq always yields the same result.
|
||||
getMissedSince(lastSeenSeq: number): ReplayableMobileNotification[] {
|
||||
if (lastSeenSeq >= this.seq) {
|
||||
return []
|
||||
}
|
||||
return this.buffer.filter((entry) => entry.notificationSeq > lastSeenSeq)
|
||||
}
|
||||
|
||||
// Test/inspection helper: number of events currently retained.
|
||||
get size(): number {
|
||||
return this.buffer.length
|
||||
}
|
||||
}
|
||||
@@ -725,6 +725,10 @@ import {
|
||||
registerTerminalViewAttributesApplier
|
||||
} from './terminal-view-attribute-store'
|
||||
import { killAllProcessesForWorktree } from './worktree-teardown'
|
||||
import {
|
||||
MobileNotificationReplayBuffer,
|
||||
type ReplayableMobileNotification
|
||||
} from './mobile-notification-replay'
|
||||
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
|
||||
import {
|
||||
createMobileSessionTabsNotifyCoalescer,
|
||||
@@ -2040,6 +2044,10 @@ type ResolvedWorktreeInFlight = {
|
||||
promise: Promise<ResolvedWorktreeSnapshot>
|
||||
}
|
||||
|
||||
// Why: notificationSeq is the desktop-assigned monotonic sequence used for
|
||||
// mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a
|
||||
// client can watermark the last event it delivered and request exactly the
|
||||
// events after it — idempotent, no duplicate local pushes.
|
||||
export type MobileNotificationDispatchEvent = {
|
||||
type: 'notification'
|
||||
source: 'agent-task-complete' | 'terminal-bell' | 'test'
|
||||
@@ -2047,11 +2055,13 @@ export type MobileNotificationDispatchEvent = {
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
export type MobileNotificationDismissEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
notificationSeq?: number
|
||||
}
|
||||
|
||||
export type MobileNotificationEvent =
|
||||
@@ -7469,12 +7479,30 @@ export class OrcaRuntimeService {
|
||||
return this.notificationListeners.size
|
||||
}
|
||||
|
||||
// Why: bounded replay buffer for the mobile reconnect catch-up (#8129).
|
||||
// Every dispatched notification is recorded with a monotonic seq so a
|
||||
// reconnecting client can fetch exactly the events it missed. Kept on the
|
||||
// service instance (not per-client) because the buffer is a global,
|
||||
// idempotent-by-seq source of truth; clients watermark their own position.
|
||||
private readonly mobileNotificationReplay = new MobileNotificationReplayBuffer()
|
||||
|
||||
dispatchMobileNotification(event: MobileNotificationEvent): void {
|
||||
const seq = this.mobileNotificationReplay.record(event)
|
||||
for (const listener of this.notificationListeners) {
|
||||
listener(event)
|
||||
// Why: surface the desktop-assigned seq to live listeners so they can
|
||||
// watermark the last event delivered and feed it back to getMissedSince
|
||||
// on reconnect (idempotent catch-up, no duplicate local pushes).
|
||||
listener({ ...event, notificationSeq: seq })
|
||||
}
|
||||
}
|
||||
|
||||
// Returns notifications dispatched after lastSeenSeq. Idempotent: the same
|
||||
// watermark always yields the same set, so a client cannot be re-pushed an
|
||||
// already-delivered event (the adversarial-review gate for #8129).
|
||||
getMissedNotificationsSince(lastSeenSeq: number): ReplayableMobileNotification[] {
|
||||
return this.mobileNotificationReplay.getMissedSince(lastSeenSeq)
|
||||
}
|
||||
|
||||
dismissMobileNotification(notificationId: string): void {
|
||||
this.dispatchMobileNotification({ type: 'dismiss', notificationId })
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@ const NotificationUnsubscribeParams = z.object({
|
||||
.pipe(z.string().min(1, 'Missing subscriptionId'))
|
||||
})
|
||||
|
||||
// Why: notifications.getMissedSince is the catch-up RPC for mobile reconnect
|
||||
// (#8129). The client passes the highest seq it has already delivered; the
|
||||
// runtime returns only notifications dispatched after that seq. Because the
|
||||
// desktop assigns a monotonic seq to every dispatched notification, the cut is
|
||||
// exact and idempotent — re-requesting with the same watermark can never
|
||||
// return an already-delivered event, so reconnects never duplicate local
|
||||
// pushes (the adversarial-review gate for #8129).
|
||||
const NotificationGetMissedSinceParams = z.object({
|
||||
lastSeenSeq: z.number().int().min(0, 'lastSeenSeq must be a non-negative integer')
|
||||
})
|
||||
|
||||
// Why: notifications.subscribe streams desktop notification events to mobile
|
||||
// clients over WebSocket. The mobile client shows a local push notification
|
||||
// for each event. This avoids requiring Firebase/APNs — the existing
|
||||
@@ -52,5 +63,16 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
|
||||
runtime.cleanupSubscription(params.subscriptionId)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'notifications.getMissedSince',
|
||||
params: NotificationGetMissedSinceParams,
|
||||
// Why: returns only notifications with seq > lastSeenSeq. The runtime owns
|
||||
// the monotonic seq, so this is the single source of truth for what the
|
||||
// client missed while its socket was reaped.
|
||||
handler: async (params, { runtime }) => {
|
||||
const missed = runtime.getMissedNotificationsSince(params.lastSeenSeq)
|
||||
return { notifications: missed }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -285,6 +285,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'linear.updateIssue',
|
||||
'markdown.readTab',
|
||||
'markdown.saveTab',
|
||||
'notifications.getMissedSince',
|
||||
'notifications.subscribe',
|
||||
'notifications.unsubscribe',
|
||||
'preflight.check',
|
||||
|
||||
@@ -667,7 +667,11 @@ export type PtyManagementApi = {
|
||||
// `degraded` is true when the daemon is alive but cannot spawn fresh PTYs, so
|
||||
// new terminals run on the local provider without daemon persistence.
|
||||
listSessions: () => Promise<{ sessions: PtyManagementSession[]; degraded: boolean }>
|
||||
killAll: () => Promise<{ killedCount: number; remainingCount: number }>
|
||||
killAll: () => Promise<{
|
||||
killedCount: number
|
||||
remainingCount: number
|
||||
killedSessionIds?: string[]
|
||||
}>
|
||||
killOne: (args: { sessionId: string }) => Promise<{ success: boolean }>
|
||||
restart: () => Promise<{ success: boolean }>
|
||||
}
|
||||
@@ -3125,6 +3129,14 @@ export type PreloadApi = {
|
||||
/** Drop every cached hook status under one terminal tab prefix.
|
||||
* Fire-and-forget. */
|
||||
dropByTabPrefix: (tabId: string) => void
|
||||
/** Permanently retire one pane's hook authority while siblings stay live. */
|
||||
retirePaneAuthority: (paneKey: string) => void
|
||||
/** Move hook authority when a live pane is detached into another tab. */
|
||||
transferPaneAuthority: (args: {
|
||||
fromPaneKey: string
|
||||
toPaneKey: string
|
||||
ptyId?: string
|
||||
}) => void
|
||||
}
|
||||
mobile: {
|
||||
listNetworkInterfaces: () => Promise<{
|
||||
|
||||
@@ -4348,6 +4348,16 @@ const api = {
|
||||
* explicit tab close even when the renderer has no matching local row. */
|
||||
dropByTabPrefix: (tabId: string): void => {
|
||||
ipcRenderer.send('agentStatus:dropByTabPrefix', tabId)
|
||||
},
|
||||
retirePaneAuthority: (paneKey: string): void => {
|
||||
ipcRenderer.send('agentStatus:retirePaneAuthority', paneKey)
|
||||
},
|
||||
transferPaneAuthority: (args: {
|
||||
fromPaneKey: string
|
||||
toPaneKey: string
|
||||
ptyId?: string
|
||||
}): void => {
|
||||
ipcRenderer.send('agentStatus:transferPaneAuthority', args)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -204,6 +204,8 @@
|
||||
--git-graph-lane-4: #40b0a6;
|
||||
--git-graph-lane-5: #b66dff;
|
||||
--annotation-highlight: #f59e0b;
|
||||
--markdown-search-match: #facc15;
|
||||
--markdown-search-match-active: #fb923c;
|
||||
/* Why: tab-group splits use a dedicated divider — `--border` is too faint on
|
||||
card/editor surfaces when panes are side-by-side or stacked. Default must
|
||||
meet >=3:1 against `--card`, not only the hover/drag strong state. */
|
||||
@@ -292,6 +294,8 @@
|
||||
--git-graph-lane-4: #40b0a6;
|
||||
--git-graph-lane-5: #b66dff;
|
||||
--annotation-highlight: #fbbf24;
|
||||
--markdown-search-match: #facc15;
|
||||
--markdown-search-match-active: #fb923c;
|
||||
/* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */
|
||||
--tab-group-split-divider: #71717a;
|
||||
--tab-group-split-divider-strong: #a1a1aa;
|
||||
|
||||
@@ -237,15 +237,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.markdown-preview-search-match {
|
||||
padding: 0;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, #facc15 50%, transparent);
|
||||
/* Why: search hits are painted with the CSS Custom Highlight API (Ranges, no
|
||||
DOM mutation) instead of injected <mark> elements — see markdown-preview-search.ts.
|
||||
::highlight() supports color/background only, not border-radius/padding. */
|
||||
::highlight(markdown-preview-search-match) {
|
||||
background-color: color-mix(in srgb, var(--markdown-search-match) 50%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-preview-search-match[data-active] {
|
||||
background: color-mix(in srgb, #fb923c 60%, transparent);
|
||||
::highlight(markdown-preview-search-active-match) {
|
||||
background-color: color-mix(in srgb, var(--markdown-search-match-active) 60%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-review-toolbar {
|
||||
|
||||
@@ -205,6 +205,7 @@ import {
|
||||
type TaskPageRepoSourceState
|
||||
} from '@/components/task-page-cache-selectors'
|
||||
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
|
||||
import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination'
|
||||
import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns'
|
||||
import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team'
|
||||
import {
|
||||
@@ -6280,32 +6281,32 @@ export default function TaskPage(): React.JSX.Element {
|
||||
setPaginationLoading(true)
|
||||
setLoadingTargetPage(target)
|
||||
try {
|
||||
let cursor = oldestItem.updatedAt
|
||||
let loadedPages = pages.length
|
||||
const newPages: GitHubWorkItem[][] = []
|
||||
|
||||
while (loadedPages <= target) {
|
||||
const { items } = await fetchWorkItemsNextPage(
|
||||
repoArgs,
|
||||
PER_REPO_FETCH_LIMIT,
|
||||
CROSS_REPO_DISPLAY_LIMIT,
|
||||
q,
|
||||
cursor
|
||||
)
|
||||
if (paginationGenerationRef.current !== requestGeneration) {
|
||||
return
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: pages,
|
||||
initialCursor: oldestItem.updatedAt,
|
||||
targetPage: target,
|
||||
// Why: uniform page size keeps deduped pages from shrinking below the
|
||||
// size `totalPages` (count ÷ effectivePageSize) assumes — otherwise
|
||||
// the per-page overlap loss would strand the tail items.
|
||||
pageSize: effectivePageSize,
|
||||
isCancelled: () => paginationGenerationRef.current !== requestGeneration,
|
||||
fetchPage: async (cursor) => {
|
||||
const { items } = await fetchWorkItemsNextPage(
|
||||
repoArgs,
|
||||
PER_REPO_FETCH_LIMIT,
|
||||
CROSS_REPO_DISPLAY_LIMIT,
|
||||
q,
|
||||
cursor
|
||||
)
|
||||
return { items }
|
||||
}
|
||||
if (items.length === 0) {
|
||||
break
|
||||
}
|
||||
newPages.push(items)
|
||||
cursor = items.at(-1)!.updatedAt
|
||||
loadedPages += 1
|
||||
})
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (newPages.length > 0) {
|
||||
setPages((prev) => [...prev, ...newPages])
|
||||
setCurrentPage(target < loadedPages ? target : loadedPages - 1)
|
||||
if (result.newPages.length > 0) {
|
||||
setPages((prev) => [...prev, ...result.newPages])
|
||||
setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load next page:', err)
|
||||
@@ -6316,7 +6317,14 @@ export default function TaskPage(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
},
|
||||
[paginationLoading, selectedRepos, pages, appliedTaskSearch, fetchWorkItemsNextPage]
|
||||
[
|
||||
paginationLoading,
|
||||
selectedRepos,
|
||||
pages,
|
||||
appliedTaskSearch,
|
||||
fetchWorkItemsNextPage,
|
||||
effectivePageSize
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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(
|
||||
@@ -1364,11 +1364,17 @@ function Terminal(): React.JSX.Element | null {
|
||||
(unifiedTab?.contentType === 'browser' &&
|
||||
browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId)))
|
||||
) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
if (unifiedTab.contentType === 'terminal') {
|
||||
// Why: paired-host bulk close must revoke renderer resume and hook
|
||||
// authority as well as removing the host-owned session tab.
|
||||
closeTerminalTab(unifiedTab.entityId)
|
||||
} else {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) {
|
||||
@@ -1423,11 +1429,17 @@ function Terminal(): React.JSX.Element | null {
|
||||
(unifiedTab?.contentType === 'browser' &&
|
||||
browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId)))
|
||||
) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.contentType === 'browser' ? unifiedTab.id : unifiedTab.entityId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
if (unifiedTab.contentType === 'terminal') {
|
||||
// Why: route every terminal close through the destructive local
|
||||
// lifecycle boundary before the paired host RPC.
|
||||
closeTerminalTab(unifiedTab.entityId)
|
||||
} else {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) {
|
||||
|
||||
@@ -484,12 +484,18 @@ export default function MarkdownPreview({
|
||||
input.focus()
|
||||
input.select()
|
||||
}, [])
|
||||
const matchesRef = useRef<HTMLElement[]>([])
|
||||
const matchesRef = useRef<Range[]>([])
|
||||
// Stable token identifying this preview in the document-global highlight
|
||||
// registry, so split/floating previews don't clobber each other's Find paint.
|
||||
const searchInstanceRef = useRef<object>({})
|
||||
const lastAppliedInitialAnchorRef = useRef<string | null>(null)
|
||||
const pendingEditorRevealFrameIdsRef = useRef<number[]>([])
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [matchCount, setMatchCount] = useState(0)
|
||||
// Bumps whenever the match ranges are recomputed, so the active-highlight
|
||||
// effect re-runs even when a streamed rerender yields the same count/index.
|
||||
const [searchRevision, setSearchRevision] = useState(0)
|
||||
const [activeMatchIndex, setActiveMatchIndex] = useState(-1)
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
@@ -835,29 +841,36 @@ export default function MarkdownPreview({
|
||||
return
|
||||
}
|
||||
|
||||
const instanceId = searchInstanceRef.current
|
||||
|
||||
if (!isSearchOpen) {
|
||||
matchesRef.current = []
|
||||
setMatchCount(0)
|
||||
clearMarkdownPreviewSearchHighlights(body)
|
||||
clearMarkdownPreviewSearchHighlights(instanceId)
|
||||
return
|
||||
}
|
||||
|
||||
// Search decorations are applied imperatively because the rendered preview is
|
||||
// already owned by react-markdown. Rewriting the markdown AST for transient
|
||||
// find state would make navigation and link rendering much harder to reason about.
|
||||
const matches = applyMarkdownPreviewSearchHighlights(body, query)
|
||||
// Search decorations are painted via the CSS Custom Highlight API (Ranges,
|
||||
// no DOM mutation) because the rendered preview is owned by react-markdown;
|
||||
// splitting its nodes to inject <mark> corrupted react's tree (crash 237acef1).
|
||||
const matches = applyMarkdownPreviewSearchHighlights(instanceId, body, query)
|
||||
matchesRef.current = matches
|
||||
setMatchCount(matches.length)
|
||||
setSearchRevision((v) => v + 1)
|
||||
setActiveMatchIndex((cur) =>
|
||||
matches.length === 0 ? -1 : cur >= 0 && cur < matches.length ? cur : 0
|
||||
)
|
||||
|
||||
return () => clearMarkdownPreviewSearchHighlights(body)
|
||||
return () => clearMarkdownPreviewSearchHighlights(instanceId)
|
||||
}, [renderedContent, isSearchOpen, query])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveMarkdownPreviewSearchMatch(matchesRef.current, activeMatchIndex)
|
||||
}, [activeMatchIndex, matchCount])
|
||||
setActiveMarkdownPreviewSearchMatch(
|
||||
searchInstanceRef.current,
|
||||
matchesRef.current,
|
||||
activeMatchIndex
|
||||
)
|
||||
}, [activeMatchIndex, matchCount, searchRevision])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!initialAnchor || initialAnchor === lastAppliedInitialAnchorRef.current) {
|
||||
@@ -1919,7 +1932,10 @@ export default function MarkdownPreview({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={bodyRef} className="markdown-body">
|
||||
{/* Why: translate="no" keeps browser/OS page-translation from swapping
|
||||
text nodes react owns, which otherwise triggers the same
|
||||
insertBefore/removeChild reconciliation crash (237acef1). */}
|
||||
<div ref={bodyRef} className="markdown-body" translate="no">
|
||||
{/* Why: remarkFrontmatter strips front matter from normal markdown
|
||||
output. When the user opts in from the preview actions menu, render the
|
||||
raw metadata as a compact read-only block above the document body. */}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// @vitest-environment happy-dom
|
||||
//
|
||||
// Regression guards for crash 237acef1: search highlighting must not mutate the
|
||||
// DOM react-markdown owns. Injecting <mark> by splitting react's text nodes (and
|
||||
// normalize()-merging them on clear) left react with stale child pointers, so
|
||||
// the next streamed-content commit threw
|
||||
// NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before
|
||||
// which the new node is to be inserted is not a child of this node.
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
applyMarkdownPreviewSearchHighlights,
|
||||
clearMarkdownPreviewSearchHighlights,
|
||||
setActiveMarkdownPreviewSearchMatch
|
||||
} from './markdown-preview-search'
|
||||
|
||||
const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
|
||||
const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
|
||||
|
||||
function MarkdownBody({ parts }: { parts: readonly string[] }): React.JSX.Element {
|
||||
// Mirror react-markdown: a <p> whose children are text nodes react owns.
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
<p>{parts.map((part) => part)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('markdown preview search highlighting keeps react-owned DOM intact (crash 237acef1)', () => {
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
const instance = {}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
clearMarkdownPreviewSearchHighlights(instance)
|
||||
})
|
||||
|
||||
function render(parts: readonly string[]): HTMLElement {
|
||||
act(() => root.render(<MarkdownBody parts={parts} />))
|
||||
const body = container.querySelector<HTMLElement>('.markdown-body')
|
||||
if (!body) {
|
||||
throw new Error('missing markdown body')
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
it('applies highlights without mutating the react-owned subtree', () => {
|
||||
const body = render(['hello world'])
|
||||
const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'lo')
|
||||
expect(matches.length).toBe(1)
|
||||
// No <mark> is injected; the text react owns is byte-for-byte intact.
|
||||
expect(body.querySelector('mark')).toBeNull()
|
||||
expect(body.textContent).toBe('hello world')
|
||||
})
|
||||
|
||||
it('survives a streamed re-render while highlights are active', () => {
|
||||
const body = render(['alpha ', 'beta'])
|
||||
applyMarkdownPreviewSearchHighlights(instance, body, 'beta')
|
||||
// Highlights stay registered while streamed content rewrites the paragraph.
|
||||
// When they mutated react's text nodes this commit threw NotFoundError; with
|
||||
// no DOM mutation it is clean. (afterEach clears the instance.)
|
||||
expect(() => render(['alpha ', 'gamma', ' delta'])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// The paint path only runs where the CSS Custom Highlight API exists (prod
|
||||
// Electron), which jsdom/happy-dom lack — so stub a faithful, Electron-shaped
|
||||
// registry + Highlight to exercise it. The stub Highlight throws if handed any
|
||||
// constructor argument, catching any accidental `new Highlight(...ranges)`
|
||||
// spread (which overflows V8's arg stack on large docs).
|
||||
class StubHighlight {
|
||||
readonly ranges = new Set<Range>()
|
||||
constructor(...args: Range[]) {
|
||||
if (args.length > 0) {
|
||||
throw new RangeError('Maximum call stack size exceeded')
|
||||
}
|
||||
}
|
||||
add(range: Range): void {
|
||||
this.ranges.add(range)
|
||||
}
|
||||
}
|
||||
|
||||
class StubHighlightRegistry {
|
||||
readonly entries = new Map<string, StubHighlight>()
|
||||
set(name: string, highlight: StubHighlight): void {
|
||||
this.entries.set(name, highlight)
|
||||
}
|
||||
delete(name: string): void {
|
||||
this.entries.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
describe('markdown preview search painting with the CSS Custom Highlight API', () => {
|
||||
let registry: StubHighlightRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new StubHighlightRegistry()
|
||||
vi.stubGlobal('Highlight', StubHighlight)
|
||||
vi.stubGlobal('CSS', { highlights: registry })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function bodyWithText(text: string): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.className = 'markdown-body'
|
||||
div.textContent = text
|
||||
document.body.append(div)
|
||||
return div
|
||||
}
|
||||
|
||||
it('paints many matches without a constructor-arg spread', () => {
|
||||
const instance = {}
|
||||
const body = bodyWithText('a a a a a a a a')
|
||||
// A spread paint (new Highlight(...ranges)) would throw via the stub.
|
||||
expect(() => applyMarkdownPreviewSearchHighlights(instance, body, 'a')).not.toThrow()
|
||||
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(8)
|
||||
clearMarkdownPreviewSearchHighlights(instance)
|
||||
body.remove()
|
||||
})
|
||||
|
||||
it('unions highlights across previews so a second Find does not clobber the first', () => {
|
||||
const a = {}
|
||||
const b = {}
|
||||
const bodyA = bodyWithText('alpha alpha')
|
||||
const bodyB = bodyWithText('alpha')
|
||||
|
||||
const matchesA = applyMarkdownPreviewSearchHighlights(a, bodyA, 'alpha')
|
||||
const matchesB = applyMarkdownPreviewSearchHighlights(b, bodyB, 'alpha')
|
||||
expect(matchesA.length).toBe(2)
|
||||
expect(matchesB.length).toBe(1)
|
||||
// The registry paints the union (A's 2 + B's 1), not just the last writer's.
|
||||
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(3)
|
||||
|
||||
// Closing B keeps A's highlights.
|
||||
clearMarkdownPreviewSearchHighlights(b)
|
||||
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)?.ranges.size).toBe(2)
|
||||
|
||||
clearMarkdownPreviewSearchHighlights(a)
|
||||
expect(registry.entries.has(SEARCH_HIGHLIGHT_NAME)).toBe(false)
|
||||
setActiveMarkdownPreviewSearchMatch(a, matchesA, -1)
|
||||
bodyA.remove()
|
||||
bodyB.remove()
|
||||
})
|
||||
|
||||
it('navigation repaints only the active highlight, not the full match set', () => {
|
||||
const instance = {}
|
||||
const body = bodyWithText('a a a a')
|
||||
const matches = applyMarkdownPreviewSearchHighlights(instance, body, 'a')
|
||||
const paintedAfterApply = registry.entries.get(SEARCH_HIGHLIGHT_NAME)
|
||||
setActiveMarkdownPreviewSearchMatch(instance, matches, 0)
|
||||
setActiveMarkdownPreviewSearchMatch(instance, matches, 1)
|
||||
// The SEARCH highlight object is untouched by navigation (not rebuilt each Next/Prev)...
|
||||
expect(registry.entries.get(SEARCH_HIGHLIGHT_NAME)).toBe(paintedAfterApply)
|
||||
// ...while the ACTIVE highlight tracks the current match.
|
||||
expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(matches[1])).toBe(true)
|
||||
clearMarkdownPreviewSearchHighlights(instance)
|
||||
body.remove()
|
||||
})
|
||||
|
||||
it('re-apply drops the active highlight until the caller repaints it (same-count rerender)', () => {
|
||||
const instance = {}
|
||||
const body = bodyWithText('one two one two')
|
||||
const first = applyMarkdownPreviewSearchHighlights(instance, body, 'one')
|
||||
expect(first.length).toBe(2)
|
||||
setActiveMarkdownPreviewSearchMatch(instance, first, 0)
|
||||
expect(registry.entries.has(ACTIVE_SEARCH_HIGHLIGHT_NAME)).toBe(true)
|
||||
|
||||
// A streamed rerender / new query re-applies and clears the active range even
|
||||
// when the match count is unchanged (regression for the vanishing active mark).
|
||||
const second = applyMarkdownPreviewSearchHighlights(instance, body, 'two')
|
||||
expect(second.length).toBe(first.length)
|
||||
expect(registry.entries.has(ACTIVE_SEARCH_HIGHLIGHT_NAME)).toBe(false)
|
||||
|
||||
// MarkdownPreview repaints via its searchRevision effect; the module restores it.
|
||||
setActiveMarkdownPreviewSearchMatch(instance, second, 0)
|
||||
expect(registry.entries.get(ACTIVE_SEARCH_HIGHLIGHT_NAME)?.ranges.has(second[0])).toBe(true)
|
||||
clearMarkdownPreviewSearchHighlights(instance)
|
||||
body.remove()
|
||||
})
|
||||
})
|
||||
@@ -166,92 +166,170 @@ function buildLocaleLowercaseIndex(text: string): {
|
||||
return { text: normalized, originalStartByNormalizedOffset, originalEndByNormalizedOffset }
|
||||
}
|
||||
|
||||
export function clearMarkdownPreviewSearchHighlights(root: HTMLElement): void {
|
||||
const highlights = root.querySelectorAll<HTMLElement>('[data-markdown-preview-search-match]')
|
||||
for (const highlight of highlights) {
|
||||
const textNode = document.createTextNode(highlight.textContent ?? '')
|
||||
highlight.replaceWith(textNode)
|
||||
// Why: react-markdown owns the preview DOM. Injecting <mark> by splitting its
|
||||
// text nodes (and normalize()-merging them on clear) left react holding stale
|
||||
// child pointers, so the next streamed-content commit threw NotFoundError
|
||||
// ("insertBefore ... not a child of this node"; crash 237acef1). Paint matches
|
||||
// with the CSS Custom Highlight API instead — it highlights Ranges without
|
||||
// mutating the DOM react manages. The static names below must match the
|
||||
// ::highlight() selectors in markdown-preview.css.
|
||||
const SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-match'
|
||||
const ACTIVE_SEARCH_HIGHLIGHT_NAME = 'markdown-preview-search-active-match'
|
||||
|
||||
type HighlightLike = { add(range: Range): void }
|
||||
type HighlightRegistryLike = {
|
||||
set(name: string, highlight: HighlightLike): void
|
||||
delete(name: string): void
|
||||
}
|
||||
|
||||
// Accessed via globalThis so the code degrades to a no-op where the API is
|
||||
// absent (older Chromium, jsdom/happy-dom in tests) — match counting and
|
||||
// navigation still work off the returned Ranges; only the paint is skipped.
|
||||
function getHighlightApi(): {
|
||||
registry: HighlightRegistryLike
|
||||
create: (ranges: readonly Range[]) => HighlightLike
|
||||
} | null {
|
||||
const scope = globalThis as {
|
||||
CSS?: { highlights?: HighlightRegistryLike }
|
||||
Highlight?: new () => HighlightLike
|
||||
}
|
||||
const registry = scope.CSS?.highlights
|
||||
const HighlightCtor = scope.Highlight
|
||||
if (!registry || typeof HighlightCtor !== 'function') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
registry,
|
||||
// Why: build with .add() rather than new Highlight(...ranges). A big doc +
|
||||
// short query yields 100k+ ranges, and spreading that many constructor
|
||||
// args overflows V8's argument stack (RangeError) — the same large-content
|
||||
// regime as the bug this file fixes.
|
||||
create: (ranges) => {
|
||||
const highlight = new HighlightCtor()
|
||||
for (const range of ranges) {
|
||||
highlight.add(range)
|
||||
}
|
||||
return highlight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: CSS.highlights is a document-global registry keyed by a static name, but
|
||||
// several MarkdownPreview instances can be open at once (split panes, floating
|
||||
// window). Track each instance's ranges by its own token and paint the UNION,
|
||||
// so a second preview's Find does not clobber the first's highlights. Ranges
|
||||
// live in each instance's own subtree, so the union paints every pane correctly.
|
||||
const searchRangesByInstance = new Map<object, readonly Range[]>()
|
||||
const activeRangeByInstance = new Map<object, Range>()
|
||||
|
||||
// Avoid array spread when collecting union ranges — a large doc can produce
|
||||
// 100k+ ranges and create()/registry writes must not build variadic arg lists.
|
||||
function paintMatchHighlight(api: NonNullable<ReturnType<typeof getHighlightApi>>): void {
|
||||
const matchRanges: Range[] = []
|
||||
for (const ranges of searchRangesByInstance.values()) {
|
||||
for (const range of ranges) {
|
||||
matchRanges.push(range)
|
||||
}
|
||||
}
|
||||
if (matchRanges.length > 0) {
|
||||
api.registry.set(SEARCH_HIGHLIGHT_NAME, api.create(matchRanges))
|
||||
} else {
|
||||
api.registry.delete(SEARCH_HIGHLIGHT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
function paintActiveHighlight(api: NonNullable<ReturnType<typeof getHighlightApi>>): void {
|
||||
const activeRanges: Range[] = []
|
||||
for (const range of activeRangeByInstance.values()) {
|
||||
activeRanges.push(range)
|
||||
}
|
||||
if (activeRanges.length > 0) {
|
||||
api.registry.set(ACTIVE_SEARCH_HIGHLIGHT_NAME, api.create(activeRanges))
|
||||
} else {
|
||||
api.registry.delete(ACTIVE_SEARCH_HIGHLIGHT_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMarkdownPreviewSearchHighlights(instanceId: object): void {
|
||||
searchRangesByInstance.delete(instanceId)
|
||||
activeRangeByInstance.delete(instanceId)
|
||||
const api = getHighlightApi()
|
||||
if (api) {
|
||||
paintMatchHighlight(api)
|
||||
paintActiveHighlight(api)
|
||||
}
|
||||
root.normalize()
|
||||
}
|
||||
|
||||
export function applyMarkdownPreviewSearchHighlights(
|
||||
instanceId: object,
|
||||
root: HTMLElement,
|
||||
query: string
|
||||
): HTMLElement[] {
|
||||
clearMarkdownPreviewSearchHighlights(root)
|
||||
): Range[] {
|
||||
const ranges: Range[] = []
|
||||
|
||||
if (!query || isMarkdownPreviewSearchQueryTooLarge(query)) {
|
||||
return []
|
||||
if (query && !isMarkdownPreviewSearchQueryTooLarge(query)) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (!(node.parentElement instanceof HTMLElement)) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
if (!node.textContent?.trim()) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
})
|
||||
|
||||
let currentNode = walker.nextNode()
|
||||
while (currentNode) {
|
||||
if (currentNode instanceof Text) {
|
||||
const text = currentNode.textContent ?? ''
|
||||
// findTextMatchRanges returns offsets into the original text, so they
|
||||
// map straight onto this Text node without any DOM rewrite.
|
||||
for (const { start, end } of findTextMatchRanges(text, query)) {
|
||||
const range = document.createRange()
|
||||
range.setStart(currentNode, start)
|
||||
range.setEnd(currentNode, end)
|
||||
ranges.push(range)
|
||||
}
|
||||
}
|
||||
currentNode = walker.nextNode()
|
||||
}
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
if (!(node.parentElement instanceof HTMLElement)) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
if (node.parentElement.closest('[data-markdown-preview-search-match]')) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
if (!node.textContent?.trim()) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
})
|
||||
|
||||
const textNodes: Text[] = []
|
||||
let currentNode = walker.nextNode()
|
||||
while (currentNode) {
|
||||
if (currentNode instanceof Text) {
|
||||
textNodes.push(currentNode)
|
||||
}
|
||||
currentNode = walker.nextNode()
|
||||
searchRangesByInstance.set(instanceId, ranges)
|
||||
activeRangeByInstance.delete(instanceId)
|
||||
const api = getHighlightApi()
|
||||
if (api) {
|
||||
paintMatchHighlight(api)
|
||||
paintActiveHighlight(api)
|
||||
}
|
||||
|
||||
const matches: HTMLElement[] = []
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent ?? ''
|
||||
const ranges = findTextMatchRanges(text, query)
|
||||
if (ranges.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment()
|
||||
let cursor = 0
|
||||
for (const range of ranges) {
|
||||
if (range.start > cursor) {
|
||||
fragment.append(document.createTextNode(text.slice(cursor, range.start)))
|
||||
}
|
||||
|
||||
const highlight = document.createElement('mark')
|
||||
highlight.dataset.markdownPreviewSearchMatch = 'true'
|
||||
highlight.className = 'markdown-preview-search-match'
|
||||
highlight.textContent = text.slice(range.start, range.end)
|
||||
fragment.append(highlight)
|
||||
matches.push(highlight)
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
fragment.append(document.createTextNode(text.slice(cursor)))
|
||||
}
|
||||
|
||||
textNode.replaceWith(fragment)
|
||||
}
|
||||
|
||||
return matches
|
||||
return ranges
|
||||
}
|
||||
|
||||
export function setActiveMarkdownPreviewSearchMatch(
|
||||
matches: readonly HTMLElement[],
|
||||
instanceId: object,
|
||||
matches: readonly Range[],
|
||||
activeIndex: number
|
||||
): void {
|
||||
for (const [index, match] of matches.entries()) {
|
||||
const isActive = index === activeIndex
|
||||
match.toggleAttribute('data-active', isActive)
|
||||
if (isActive) {
|
||||
match.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
}
|
||||
const active = activeIndex >= 0 ? matches[activeIndex] : undefined
|
||||
|
||||
if (active) {
|
||||
activeRangeByInstance.set(instanceId, active)
|
||||
} else {
|
||||
activeRangeByInstance.delete(instanceId)
|
||||
}
|
||||
|
||||
const api = getHighlightApi()
|
||||
if (api) {
|
||||
// Only the active range changed — don't rebuild the (potentially 100k-range)
|
||||
// match highlight on every Next/Prev navigation.
|
||||
paintActiveHighlight(api)
|
||||
}
|
||||
|
||||
if (active) {
|
||||
// The Range's start container is a Text node; scroll its element into view.
|
||||
active.startContainer.parentElement?.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
runKillAllTerminalSurfaces,
|
||||
snapshotKillAllTerminalSurfaceIds,
|
||||
@@ -29,6 +30,10 @@ function state(overrides: Partial<KillAllTerminalSurfaceState> = {}): KillAllTer
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
deferredSshSessionIdsByTabId: {},
|
||||
pendingReconnectPtyIdByTabId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -120,7 +125,7 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
const targetIds = snapshotKillAllTerminalSurfaceIds(current)
|
||||
const calls: string[] = []
|
||||
const killDaemonSessions = vi.fn(() => management.promise)
|
||||
const closeSurface = vi.fn((targetId: string) => {
|
||||
const closeSurface = vi.fn((targetId: string, _options: unknown) => {
|
||||
calls.push(`close:${targetId}`)
|
||||
removeSurface(current, targetId)
|
||||
})
|
||||
@@ -166,10 +171,10 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
'wt-new-active': [unified('later-visible', 'later-tab', 'wt-new-active')]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'background-target': ['pty-shared', 'remote:runtime-only'],
|
||||
'background-target': ['pty-shared', 'pty-external', 'remote:runtime-only'],
|
||||
'unified-only-target': ['pty-unified', 'pty-shared'],
|
||||
'moved-target': ['ssh:host@@pty-last', 'pty-shared'],
|
||||
'later-tab': ['pty-later']
|
||||
'later-tab': ['pty-later', 'pty-external']
|
||||
}
|
||||
})
|
||||
;(
|
||||
@@ -177,29 +182,40 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId: Record<string, string> }>
|
||||
}
|
||||
).terminalLayoutsByTabId = {
|
||||
'moved-target': { ptyIdsByLeafId: { leaf: 'layout-restore-hint' } }
|
||||
'moved-target': {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { leaf: 'layout-restore-hint' }
|
||||
}
|
||||
}
|
||||
management.resolve({ killedCount: 2, remainingCount: 1 })
|
||||
await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(3))
|
||||
await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(5))
|
||||
|
||||
expect(closeSurface.mock.calls).toEqual([
|
||||
['background-target', { force: true }],
|
||||
['unified-only-target', { force: true }],
|
||||
['moved-target', { force: true }]
|
||||
])
|
||||
expect(calls.slice(0, 3)).toEqual([
|
||||
'close:background-target',
|
||||
'close:unified-only-target',
|
||||
'close:moved-target'
|
||||
expect(closeSurface.mock.calls.map(([targetId]) => targetId)).toEqual([
|
||||
'background-target',
|
||||
'unified-only-target',
|
||||
'moved-target'
|
||||
])
|
||||
for (const [, options] of closeSurface.mock.calls) {
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
force: true,
|
||||
localPtyTeardownOwnedExternally: true,
|
||||
precomputedRetirementPlan: expect.any(Object)
|
||||
})
|
||||
)
|
||||
}
|
||||
expect(calls.indexOf('kill:pty-shared')).toBeLessThan(calls.indexOf('close:moved-target'))
|
||||
expect(killPty).toHaveBeenCalledWith('pty-shared')
|
||||
expect(killPty).toHaveBeenCalledWith('pty-unified')
|
||||
expect(killPty).toHaveBeenCalledWith('ssh:host@@pty-last')
|
||||
expect(killPty).not.toHaveBeenCalledWith('remote:runtime-only')
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-later')
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-external')
|
||||
expect(killPty).not.toHaveBeenCalledWith('stale-pty')
|
||||
expect(killPty).not.toHaveBeenCalledWith('tab-restore-hint')
|
||||
expect(killPty).not.toHaveBeenCalledWith('layout-restore-hint')
|
||||
expect(killPty).toHaveBeenCalledWith('tab-restore-hint')
|
||||
expect(killPty).toHaveBeenCalledWith('layout-restore-hint')
|
||||
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later-tab'])
|
||||
|
||||
let settled = false
|
||||
@@ -215,7 +231,7 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
closeAttemptCount: 3,
|
||||
absentTargetCount: 4,
|
||||
failedCloseAttemptCount: 0,
|
||||
exactKillAcceptedCount: 2,
|
||||
exactKillAcceptedCount: 4,
|
||||
exactKillRejectedCount: 1,
|
||||
daemon: { status: 'fulfilled', killedCount: 2, remainingCount: 1 }
|
||||
})
|
||||
@@ -240,11 +256,42 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
|
||||
expect(summary.daemon).toEqual({ status: 'rejected' })
|
||||
expect(summary.absentTargetCount).toBe(1)
|
||||
expect(closeSurface).toHaveBeenCalledWith('target', { force: true })
|
||||
expect(closeSurface).toHaveBeenCalledWith(
|
||||
'target',
|
||||
expect.objectContaining({
|
||||
force: true,
|
||||
localPtyTeardownOwnedExternally: true,
|
||||
precomputedRetirementPlan: expect.any(Object),
|
||||
precomputedCloseState: expect.any(Object)
|
||||
})
|
||||
)
|
||||
expect(killPty).toHaveBeenCalledWith('local-pty')
|
||||
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later'])
|
||||
})
|
||||
|
||||
it('does not exact-kill daemon sessions already settled by management', async () => {
|
||||
const current = state({
|
||||
tabsByWorktree: { wt: [terminal('target', 'wt')] },
|
||||
ptyIdsByTabId: { target: ['daemon-pty', 'ssh:host@@relay-pty'] }
|
||||
})
|
||||
const killPty = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await runKillAllTerminalSurfaces(['target'], {
|
||||
getState: () => current,
|
||||
killDaemonSessions: vi.fn().mockResolvedValue({
|
||||
killedCount: 1,
|
||||
remainingCount: 0,
|
||||
killedSessionIds: ['daemon-pty']
|
||||
}),
|
||||
closeSurface: (targetId) => removeSurface(current, targetId),
|
||||
killPty,
|
||||
reportSummary: vi.fn()
|
||||
})
|
||||
|
||||
expect(killPty).toHaveBeenCalledOnce()
|
||||
expect(killPty).toHaveBeenCalledWith('ssh:host@@relay-pty')
|
||||
})
|
||||
|
||||
it('reports the informational zero state without provider fanout', async () => {
|
||||
const killDaemonSessions = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 })
|
||||
const closeSurface = vi.fn()
|
||||
@@ -326,7 +373,135 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('records bounded count and latency evidence for 100 terminal tabs', async () => {
|
||||
it('replans after a pre-mutation close failure and keeps the failed tab as a survivor', async () => {
|
||||
const current = state({
|
||||
activeWorktreeId: 'wt',
|
||||
tabsByWorktree: { wt: [terminal('fails', 'wt'), terminal('closes', 'wt')] },
|
||||
ptyIdsByTabId: { fails: ['pty-fails'], closes: ['pty-closes'] }
|
||||
})
|
||||
const closeSurface = vi.fn(
|
||||
(
|
||||
targetId: string,
|
||||
_options: { precomputedCloseState: { terminalCountBeforeClose: number } }
|
||||
) => {
|
||||
if (targetId === 'fails') {
|
||||
throw new Error('pre-mutation failure')
|
||||
}
|
||||
removeSurface(current, targetId)
|
||||
}
|
||||
)
|
||||
const killPty = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const summary = await runKillAllTerminalSurfaces(['fails', 'closes'], {
|
||||
getState: () => current,
|
||||
killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
|
||||
closeSurface,
|
||||
killPty,
|
||||
yieldToRenderer: vi.fn().mockResolvedValue(undefined),
|
||||
reportSummary: vi.fn()
|
||||
})
|
||||
|
||||
expect(closeSurface.mock.calls[1]?.[1].precomputedCloseState).toEqual({
|
||||
owningWorktreeId: 'wt',
|
||||
terminalCountBeforeClose: 2,
|
||||
nextTerminalTabId: 'fails'
|
||||
})
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-fails')
|
||||
expect(killPty).toHaveBeenCalledWith('pty-closes')
|
||||
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['fails'])
|
||||
expect(summary).toMatchObject({
|
||||
closeAttemptCount: 2,
|
||||
absentTargetCount: 1,
|
||||
failedCloseAttemptCount: 1,
|
||||
closeYieldCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('revalidates remaining ownership after a yield before killing an exact PTY', async () => {
|
||||
let current = state({
|
||||
activeWorktreeId: 'wt',
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
tabsByWorktree: {
|
||||
wt: [terminal('first', 'wt'), terminal('second', 'wt'), terminal('third', 'wt')]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
first: ['pty-first', 'remote:terminal-1'],
|
||||
second: ['pty-second'],
|
||||
third: ['pty-shared-after-yield', 'remote:env-1@@terminal-1']
|
||||
}
|
||||
})
|
||||
const closeSurface = vi.fn(
|
||||
(
|
||||
targetId: string,
|
||||
options: {
|
||||
precomputedRetirementPlan: {
|
||||
runtimeTerminals: unknown[]
|
||||
cleanupOnlyPtyIds: string[]
|
||||
}
|
||||
precomputedCloseState: {
|
||||
terminalCountBeforeClose: number
|
||||
nextTerminalTabId: string | null
|
||||
}
|
||||
}
|
||||
) => {
|
||||
removeSurface(current, targetId)
|
||||
if (targetId === 'second') {
|
||||
current = { ...current }
|
||||
}
|
||||
expect(options.precomputedCloseState.terminalCountBeforeClose).toBeGreaterThan(0)
|
||||
}
|
||||
)
|
||||
const killPty = vi.fn().mockResolvedValue(undefined)
|
||||
const yieldToRenderer = vi.fn(async () => {
|
||||
current = state({
|
||||
activeWorktreeId: 'wt',
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
tabsByWorktree: {
|
||||
wt: [terminal('third', 'wt'), terminal('survivor', 'wt')]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
third: ['pty-shared-after-yield', 'remote:env-1@@terminal-1'],
|
||||
survivor: ['pty-shared-after-yield']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const summary = await runKillAllTerminalSurfaces(['first', 'second', 'third'], {
|
||||
getState: () => current,
|
||||
killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
|
||||
closeSurface,
|
||||
killPty,
|
||||
yieldToRenderer,
|
||||
reportSummary: vi.fn()
|
||||
})
|
||||
|
||||
expect(closeSurface.mock.calls.map(([targetId]) => targetId)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
'third'
|
||||
])
|
||||
expect(closeSurface.mock.calls[2]?.[1].precomputedCloseState).toEqual({
|
||||
owningWorktreeId: 'wt',
|
||||
terminalCountBeforeClose: 2,
|
||||
nextTerminalTabId: 'survivor'
|
||||
})
|
||||
expect(closeSurface.mock.calls[2]?.[1].precomputedRetirementPlan).toMatchObject({
|
||||
runtimeTerminals: [],
|
||||
cleanupOnlyPtyIds: ['remote:env-1@@terminal-1']
|
||||
})
|
||||
expect(killPty).toHaveBeenCalledWith('pty-first')
|
||||
expect(killPty).toHaveBeenCalledWith('pty-second')
|
||||
expect(killPty).not.toHaveBeenCalledWith('pty-shared-after-yield')
|
||||
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['survivor'])
|
||||
expect(summary).toMatchObject({
|
||||
closeAttemptCount: 3,
|
||||
absentTargetCount: 3,
|
||||
exactKillAcceptedCount: 2,
|
||||
closeYieldCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('records bounded fanout and two-close batches for 100 terminal tabs', async () => {
|
||||
const tabs = Array.from({ length: 100 }, (_, index) => terminal(`tab-${index}`, 'wt'))
|
||||
const ptyIdsByTabId = Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]]))
|
||||
const previousState = useAppStore.getState()
|
||||
@@ -340,11 +515,25 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
const unsubscribe = useAppStore.subscribe(() => {
|
||||
zustandWrites += 1
|
||||
})
|
||||
const killPty = vi.fn().mockResolvedValue(undefined)
|
||||
const providerKill = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('window', { api: { pty: { kill: providerKill } } })
|
||||
let nowMs = 0
|
||||
try {
|
||||
const summary = await runKillAllTerminalSurfaces(snapshotKillAllTerminalSurfaceIds(), {
|
||||
killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
|
||||
killPty,
|
||||
killDaemonSessions: vi.fn().mockResolvedValue({
|
||||
killedCount: 0,
|
||||
remainingCount: 0,
|
||||
killedSessionIds: []
|
||||
}),
|
||||
closeSurface: (tabId, options) => {
|
||||
closeTerminalTab(tabId, options)
|
||||
nowMs += 1
|
||||
},
|
||||
killPty: providerKill,
|
||||
now: () => nowMs,
|
||||
yieldToRenderer: async () => {
|
||||
nowMs += 0.1
|
||||
},
|
||||
reportSummary: vi.fn()
|
||||
})
|
||||
|
||||
@@ -352,12 +541,13 @@ describe('runKillAllTerminalSurfaces', () => {
|
||||
expect(summary.absentTargetCount).toBe(100)
|
||||
expect(summary.exactKillAcceptedCount).toBe(100)
|
||||
expect(summary.closeDurationMs).toBeGreaterThanOrEqual(0)
|
||||
expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(50)
|
||||
expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(2.1)
|
||||
expect(summary.closeYieldCount).toBe(49)
|
||||
expect(summary.closePhaseExceededLongTaskBudget).toBe(false)
|
||||
expect(zustandWrites).toBeGreaterThanOrEqual(100)
|
||||
expect(killPty).toHaveBeenCalledTimes(100)
|
||||
expect(providerKill).toHaveBeenCalledTimes(100)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
unsubscribe()
|
||||
useAppStore.setState(previousState, true)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
closeTerminalTab,
|
||||
type PrecomputedTerminalCloseState
|
||||
} from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
buildTerminalTabRetirementPlans,
|
||||
type TerminalTabRetirementPlan,
|
||||
type TerminalTabRetirementState
|
||||
} from '@/store/slices/terminal-tab-retirement'
|
||||
import { reserveTerminalRetirementTeardowns } from '@/store/slices/terminal-retirement-teardown-reservation'
|
||||
|
||||
const CLOSE_BATCH_SIZE = 2
|
||||
|
||||
type DaemonKillAllResult = {
|
||||
killedCount: number
|
||||
remainingCount: number
|
||||
killedSessionIds?: string[]
|
||||
}
|
||||
|
||||
export type KillAllTerminalSurfaceState = Pick<
|
||||
AppState,
|
||||
'activeWorktreeId' | 'ptyIdsByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree'
|
||||
>
|
||||
export type KillAllTerminalSurfaceState = TerminalTabRetirementState &
|
||||
Pick<AppState, 'activeWorktreeId'>
|
||||
|
||||
export type KillAllTerminalSurfacesSummary = {
|
||||
targetCount: number
|
||||
@@ -34,7 +42,15 @@ export type KillAllTerminalSurfacesSummary = {
|
||||
type KillAllTerminalSurfaceDependencies = {
|
||||
getState: () => KillAllTerminalSurfaceState
|
||||
killDaemonSessions: () => Promise<DaemonKillAllResult>
|
||||
closeSurface: (tabId: string, options: { force: true }) => void
|
||||
closeSurface: (
|
||||
tabId: string,
|
||||
options: {
|
||||
force: true
|
||||
localPtyTeardownOwnedExternally: true
|
||||
precomputedRetirementPlan: TerminalTabRetirementPlan
|
||||
precomputedCloseState: PrecomputedTerminalCloseState
|
||||
}
|
||||
) => void
|
||||
killPty: (ptyId: string) => Promise<void>
|
||||
now: () => number
|
||||
yieldToRenderer: () => Promise<void>
|
||||
@@ -60,20 +76,31 @@ export function snapshotKillAllTerminalSurfaceIds(
|
||||
return [...targetIds]
|
||||
}
|
||||
|
||||
function getTargetOwners(
|
||||
function getTargetIndex(
|
||||
state: KillAllTerminalSurfaceState,
|
||||
targetIds: ReadonlySet<string>
|
||||
): Map<string, string> {
|
||||
): {
|
||||
ownerByTargetId: Map<string, string>
|
||||
terminalIdsByWorktree: Map<string, Set<string>>
|
||||
} {
|
||||
const ownerByTargetId = new Map<string, string>()
|
||||
const terminalIdsByWorktree = new Map<string, Set<string>>()
|
||||
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
|
||||
const ids = terminalIdsByWorktree.get(worktreeId) ?? new Set<string>()
|
||||
for (const tab of tabs) {
|
||||
ids.add(tab.id)
|
||||
if (targetIds.has(tab.id) && !ownerByTargetId.has(tab.id)) {
|
||||
ownerByTargetId.set(tab.id, worktreeId)
|
||||
}
|
||||
}
|
||||
terminalIdsByWorktree.set(worktreeId, ids)
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
|
||||
const ids = terminalIdsByWorktree.get(worktreeId) ?? new Set<string>()
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'terminal') {
|
||||
ids.add(tab.entityId)
|
||||
}
|
||||
if (
|
||||
tab.contentType === 'terminal' &&
|
||||
targetIds.has(tab.entityId) &&
|
||||
@@ -82,12 +109,18 @@ function getTargetOwners(
|
||||
ownerByTargetId.set(tab.entityId, worktreeId)
|
||||
}
|
||||
}
|
||||
terminalIdsByWorktree.set(worktreeId, ids)
|
||||
}
|
||||
return ownerByTargetId
|
||||
return { ownerByTargetId, terminalIdsByWorktree }
|
||||
}
|
||||
|
||||
function isTargetPresent(state: KillAllTerminalSurfaceState, targetId: string): boolean {
|
||||
return snapshotKillAllTerminalSurfaceIds(state).includes(targetId)
|
||||
function getNextTerminalId(ids: ReadonlySet<string>, closingId: string): string | null {
|
||||
for (const id of ids) {
|
||||
if (id !== closingId) {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function createDefaultDependencies(): KillAllTerminalSurfaceDependencies {
|
||||
@@ -117,7 +150,6 @@ export async function runKillAllTerminalSurfaces(
|
||||
): Promise<KillAllTerminalSurfacesSummary> {
|
||||
const deps = { ...createDefaultDependencies(), ...dependencies }
|
||||
const targetIds = [...new Set(snapshotTargetIds)]
|
||||
const targetIdSet = new Set(targetIds)
|
||||
|
||||
let daemon: KillAllTerminalSurfacesSummary['daemon']
|
||||
try {
|
||||
@@ -127,53 +159,105 @@ export async function runKillAllTerminalSurfaces(
|
||||
}
|
||||
|
||||
const cleanupState = deps.getState()
|
||||
const ownerByTargetId = getTargetOwners(cleanupState, targetIdSet)
|
||||
const presentTargetIds = targetIds.filter((targetId) => ownerByTargetId.has(targetId))
|
||||
const activeWorktreeId = cleanupState.activeWorktreeId
|
||||
// Why: keeping the active worktree until its targets close lets the existing
|
||||
// tab action choose editor/browser/deactivation without spawning a replacement.
|
||||
const closeOrder = [
|
||||
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
|
||||
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
|
||||
]
|
||||
|
||||
const exactPtyIds = new Set<string>()
|
||||
for (const targetId of presentTargetIds) {
|
||||
for (const ptyId of cleanupState.ptyIdsByTabId[targetId] ?? []) {
|
||||
if (ptyId.length > 0 && !ptyId.startsWith('remote:')) {
|
||||
exactPtyIds.add(ptyId)
|
||||
const remainingTargetIds = new Set(targetIds)
|
||||
const createCloseWave = (state: KillAllTerminalSurfaceState) => {
|
||||
const remainingIdSet = new Set(remainingTargetIds)
|
||||
const { ownerByTargetId, terminalIdsByWorktree } = getTargetIndex(state, remainingIdSet)
|
||||
const presentTargetIds = [...remainingTargetIds].filter((targetId) =>
|
||||
ownerByTargetId.has(targetId)
|
||||
)
|
||||
for (const targetId of remainingTargetIds) {
|
||||
if (!ownerByTargetId.has(targetId)) {
|
||||
remainingTargetIds.delete(targetId)
|
||||
}
|
||||
}
|
||||
const activeWorktreeId = state.activeWorktreeId
|
||||
// Why: keeping the active worktree until its targets close lets the existing
|
||||
// tab action choose editor/browser/deactivation without spawning a replacement.
|
||||
const closeOrder = [
|
||||
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
|
||||
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
|
||||
]
|
||||
return {
|
||||
state,
|
||||
ownerByTargetId,
|
||||
terminalIdsByWorktree,
|
||||
closeOrder,
|
||||
retirementPlans: buildTerminalTabRetirementPlans(state, closeOrder)
|
||||
}
|
||||
}
|
||||
|
||||
let failedCloseAttemptCount = 0
|
||||
let closeWave = createCloseWave(cleanupState)
|
||||
const daemonKilledSessionIds = new Set(
|
||||
daemon.status === 'fulfilled' ? (daemon.killedSessionIds ?? []) : []
|
||||
)
|
||||
const scheduledPtyOwners = new Set<string>()
|
||||
const exactKillTasks: Promise<void>[] = []
|
||||
const attemptedTargetIds: string[] = []
|
||||
const failedCloseTargetIds = new Set<string>()
|
||||
const closeStartedAt = deps.now()
|
||||
let closeBatchStartedAt = closeStartedAt
|
||||
let maxCloseBatchDurationMs = 0
|
||||
let closeYieldCount = 0
|
||||
for (let index = 0; index < closeOrder.length; index += 1) {
|
||||
const targetId = closeOrder[index]
|
||||
let failed = false
|
||||
try {
|
||||
deps.closeSurface(targetId, { force: true })
|
||||
} catch {
|
||||
failed = true
|
||||
while (closeWave.closeOrder.length > 0) {
|
||||
const batchTargetIds = closeWave.closeOrder.splice(0, CLOSE_BATCH_SIZE)
|
||||
let mustReplanAfterYield = false
|
||||
for (const targetId of batchTargetIds) {
|
||||
remainingTargetIds.delete(targetId)
|
||||
attemptedTargetIds.push(targetId)
|
||||
const owningWorktreeId = closeWave.ownerByTargetId.get(targetId)!
|
||||
const remainingTerminalIds =
|
||||
closeWave.terminalIdsByWorktree.get(owningWorktreeId) ?? new Set<string>()
|
||||
const nextTerminalTabId = getNextTerminalId(remainingTerminalIds, targetId)
|
||||
const { plan: retirementPlan, newlyScheduledPtyOwners } = reserveTerminalRetirementTeardowns(
|
||||
closeWave.state,
|
||||
closeWave.retirementPlans.get(targetId)!,
|
||||
scheduledPtyOwners
|
||||
)
|
||||
let closeFailed = false
|
||||
try {
|
||||
deps.closeSurface(targetId, {
|
||||
force: true,
|
||||
localPtyTeardownOwnedExternally: true,
|
||||
precomputedRetirementPlan: retirementPlan,
|
||||
precomputedCloseState: {
|
||||
owningWorktreeId,
|
||||
terminalCountBeforeClose: remainingTerminalIds.size,
|
||||
nextTerminalTabId
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
closeFailed = true
|
||||
failedCloseTargetIds.add(targetId)
|
||||
}
|
||||
const failedTargetSurvived =
|
||||
closeFailed && snapshotKillAllTerminalSurfaceIds(deps.getState()).includes(targetId)
|
||||
if (failedTargetSurvived) {
|
||||
// Why: a pre-mutation failure leaves the tab as a live non-target owner.
|
||||
// Replan before touching siblings so it still protects counts and PTYs.
|
||||
for (const owner of newlyScheduledPtyOwners) {
|
||||
scheduledPtyOwners.delete(owner)
|
||||
}
|
||||
mustReplanAfterYield = true
|
||||
break
|
||||
}
|
||||
remainingTerminalIds.delete(targetId)
|
||||
for (const ptyId of retirementPlan.localOrSshPtyIds) {
|
||||
if (daemonKilledSessionIds.has(ptyId)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
exactKillTasks.push(deps.killPty(ptyId))
|
||||
} catch (error) {
|
||||
exactKillTasks.push(Promise.reject(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
failed ||= isTargetPresent(deps.getState(), targetId)
|
||||
} catch {
|
||||
failed = true
|
||||
}
|
||||
if (failed) {
|
||||
failedCloseAttemptCount += 1
|
||||
}
|
||||
const isBatchEnd = (index + 1) % CLOSE_BATCH_SIZE === 0 || index + 1 === closeOrder.length
|
||||
if (isBatchEnd) {
|
||||
maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
|
||||
}
|
||||
if (isBatchEnd && index + 1 < closeOrder.length) {
|
||||
maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
|
||||
if (remainingTargetIds.size > 0) {
|
||||
// Why: closeTab cascades clone several store maps, so large confirmed
|
||||
// snapshots yield between bounded batches instead of monopolizing a frame.
|
||||
const stateAfterBatch = deps.getState()
|
||||
try {
|
||||
await deps.yieldToRenderer()
|
||||
} catch {
|
||||
@@ -181,21 +265,30 @@ export async function runKillAllTerminalSurfaces(
|
||||
}
|
||||
closeYieldCount += 1
|
||||
closeBatchStartedAt = deps.now()
|
||||
const stateAfterYield = deps.getState()
|
||||
if (mustReplanAfterYield || stateAfterYield !== stateAfterBatch) {
|
||||
// Why: a yield lets tabs move, detach, or appear. Replanning the
|
||||
// remaining snapshot prevents stale ownership from killing a survivor.
|
||||
closeWave = createCloseWave(stateAfterYield)
|
||||
}
|
||||
}
|
||||
}
|
||||
const closeDurationMs = Math.max(0, deps.now() - closeStartedAt)
|
||||
|
||||
const exactKillResults = await Promise.allSettled(
|
||||
[...exactPtyIds].map((ptyId) => Promise.resolve().then(() => deps.killPty(ptyId)))
|
||||
)
|
||||
// Why: the management sweep already settled daemon-owned IDs; awaiting only
|
||||
// reserved exact kills keeps each provider at one request per ownership identity.
|
||||
const exactKillResults = await Promise.allSettled(exactKillTasks)
|
||||
const exactKillAcceptedCount = exactKillResults.filter(
|
||||
(result) => result.status === 'fulfilled'
|
||||
).length
|
||||
const finalTargetIds = new Set(snapshotKillAllTerminalSurfaceIds(deps.getState()))
|
||||
const absentTargetCount = targetIds.filter((targetId) => !finalTargetIds.has(targetId)).length
|
||||
const failedCloseAttemptCount = attemptedTargetIds.filter(
|
||||
(targetId) => failedCloseTargetIds.has(targetId) || finalTargetIds.has(targetId)
|
||||
).length
|
||||
const summary: KillAllTerminalSurfacesSummary = {
|
||||
targetCount: targetIds.length,
|
||||
closeAttemptCount: closeOrder.length,
|
||||
closeAttemptCount: attemptedTargetIds.length,
|
||||
absentTargetCount,
|
||||
failedCloseAttemptCount,
|
||||
exactKillAcceptedCount,
|
||||
|
||||
@@ -68,6 +68,57 @@ describe('groupWorkspaceKanbanWorktrees', () => {
|
||||
expect(grouped.get('doing')?.map((item) => item.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('does not crash when a worktree is missing its displayName under Manual sort', () => {
|
||||
// Repro for crash 99657ab1: a worktree reached the sidebar with an
|
||||
// undefined displayName, so `a.displayName.localeCompare(...)` threw
|
||||
// `Cannot read properties of undefined (reading 'localeCompare')`.
|
||||
expect(() =>
|
||||
groupWorkspaceKanbanWorktrees({
|
||||
worktrees: [
|
||||
worktree({
|
||||
id: 'a',
|
||||
displayName: undefined as unknown as string,
|
||||
workspaceStatus: 'doing',
|
||||
manualOrder: 100
|
||||
}),
|
||||
worktree({
|
||||
id: 'b',
|
||||
displayName: undefined as unknown as string,
|
||||
workspaceStatus: 'doing',
|
||||
manualOrder: 100
|
||||
})
|
||||
],
|
||||
visibleWorktreeIds: new Set(['a', 'b']),
|
||||
workspaceStatuses: statuses,
|
||||
sortBy: 'manual'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not crash when a worktree is missing its displayName outside Manual sort', () => {
|
||||
expect(() =>
|
||||
groupWorkspaceKanbanWorktrees({
|
||||
worktrees: [
|
||||
worktree({
|
||||
id: 'a',
|
||||
displayName: undefined as unknown as string,
|
||||
workspaceStatus: 'doing',
|
||||
lastActivityAt: 10
|
||||
}),
|
||||
worktree({
|
||||
id: 'b',
|
||||
displayName: undefined as unknown as string,
|
||||
workspaceStatus: 'doing',
|
||||
lastActivityAt: 10
|
||||
})
|
||||
],
|
||||
visibleWorktreeIds: new Set(['a', 'b']),
|
||||
workspaceStatuses: statuses,
|
||||
sortBy: 'recent'
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('keeps pinned then recent ordering outside Manual sort', () => {
|
||||
const grouped = groupWorkspaceKanbanWorktrees({
|
||||
worktrees: [
|
||||
|
||||
@@ -2,15 +2,20 @@ import type { WorkspaceStatus, WorkspaceStatusDefinition, Worktree } from '../..
|
||||
import type { SortBy } from './smart-sort'
|
||||
import { getWorkspaceStatus } from './workspace-status'
|
||||
|
||||
// Why: displayName is typed non-optional, but persisted/discovered worktrees
|
||||
// have reached the sidebar with an undefined name (crash 99657ab1), which made
|
||||
// `displayName.localeCompare(...)` throw and took down the sidebar. Compare on a
|
||||
// safe string so a missing name only affects tie-break order, never crashes.
|
||||
function compareDisplayName(a: Worktree, b: Worktree): number {
|
||||
return (a.displayName ?? '').localeCompare(b.displayName ?? '')
|
||||
}
|
||||
|
||||
function sortBoardWorktrees(a: Worktree, b: Worktree): number {
|
||||
return b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName)
|
||||
return b.lastActivityAt - a.lastActivityAt || compareDisplayName(a, b)
|
||||
}
|
||||
|
||||
function sortManualBoardWorktrees(a: Worktree, b: Worktree): number {
|
||||
return (
|
||||
(b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) ||
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
return (b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) || compareDisplayName(a, b)
|
||||
}
|
||||
|
||||
export function groupWorkspaceKanbanWorktrees(params: {
|
||||
|
||||
@@ -72,7 +72,8 @@ vi.mock('../../runtime/web-runtime-session', () => ({
|
||||
closeWebRuntimeSessionTab: mocks.closeWebRuntimeSessionTab,
|
||||
createWebRuntimeSessionBrowserTab: vi.fn(),
|
||||
createWebRuntimeSessionTerminal: vi.fn(),
|
||||
isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive
|
||||
isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive,
|
||||
toHostSessionTabId: (tabId: string) => tabId
|
||||
}))
|
||||
|
||||
vi.mock('../../store/slices/browser-webview-cleanup', () => ({
|
||||
@@ -233,6 +234,72 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
|
||||
expect(event.detail).toEqual({ tabId: 'terminal-1' })
|
||||
})
|
||||
|
||||
it('revokes local terminal state before paired-host bulk close', async () => {
|
||||
const secondTerminal = {
|
||||
id: 'terminal-2',
|
||||
ptyId: 'remote:env-1@@pty-2',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal 2',
|
||||
defaultTitle: 'Terminal 2',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
const secondUnified = {
|
||||
id: 'unified-terminal-2',
|
||||
entityId: secondTerminal.id,
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal 2',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
const currentState = storeBox.state as {
|
||||
tabsByWorktree: Record<string, unknown[]>
|
||||
unifiedTabsByWorktree: Record<string, { id: string }[]>
|
||||
}
|
||||
const firstUnified = currentState.unifiedTabsByWorktree['wt-1'][0]
|
||||
storeBox.state = {
|
||||
...storeBox.state,
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [...currentState.tabsByWorktree['wt-1'], secondTerminal]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [firstUnified, secondUnified]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: firstUnified.id,
|
||||
tabOrder: [firstUnified.id, secondUnified.id]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mocks.isWebRuntimeSessionActive.mockReturnValue(true)
|
||||
const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel')
|
||||
const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' })
|
||||
|
||||
model.commands.closeOthers(firstUnified.id)
|
||||
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith(
|
||||
'terminal-2',
|
||||
expect.objectContaining({ remoteCloseOwnedByHost: true })
|
||||
)
|
||||
expect(mocks.closeWebRuntimeSessionTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'terminal-2',
|
||||
environmentId: 'env-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('records terminal split completion when splitting a single terminal tab group', async () => {
|
||||
mocks.createEmptySplitGroup.mockReturnValue('group-2')
|
||||
mocks.createTab.mockReturnValue({ id: 'terminal-2' })
|
||||
|
||||
@@ -316,11 +316,9 @@ export function useTabGroupWorkspaceModel({
|
||||
worktreeId
|
||||
)
|
||||
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: item.entityId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
// Why: paired-host bulk close must revoke local resume and hook
|
||||
// authority before asking the host to remove its canonical tab.
|
||||
closeTerminalTab(item.entityId)
|
||||
continue
|
||||
}
|
||||
if (item.contentType === 'browser') {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination'
|
||||
|
||||
function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem {
|
||||
return {
|
||||
id,
|
||||
type: 'issue',
|
||||
number: Number.parseInt(id.split(':')[1] ?? '0', 10),
|
||||
title: id,
|
||||
state: 'open',
|
||||
url: `https://example.test/${id}`,
|
||||
labels: [],
|
||||
updatedAt,
|
||||
author: null,
|
||||
repoId
|
||||
}
|
||||
}
|
||||
|
||||
// Build a run of issues numbered `from`..`to` (inclusive) with strictly
|
||||
// decreasing updatedAt, on repo `r`, so `at(-1)` is the oldest.
|
||||
function run(r: string, from: number, to: number): GitHubWorkItem[] {
|
||||
const out: GitHubWorkItem[] = []
|
||||
for (let n = from; n <= to; n += 1) {
|
||||
// Larger number => newer; encode as a descending timestamp so sort order is
|
||||
// unambiguous and distinct.
|
||||
const ts = `2026-01-01T00:00:${String(1000 - n).padStart(4, '0')}Z`
|
||||
out.push(item(r, `issue:${n}`, ts))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('workItemIdentity', () => {
|
||||
it('qualifies the bare id with the repo so cross-repo collisions are distinct', () => {
|
||||
expect(workItemIdentity(item('repo-a', 'issue:9', 't'))).toBe('repo-a:issue:9')
|
||||
expect(workItemIdentity(item('repo-b', 'issue:9', 't'))).not.toBe(
|
||||
workItemIdentity(item('repo-a', 'issue:9', 't'))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('accumulateWorkItemPages', () => {
|
||||
it('drops the re-fetched boundary row that shares the previous page cursor', async () => {
|
||||
const boundary = item('r', 'issue:2', '2026-07-02')
|
||||
const existing = [[item('r', 'issue:1', '2026-07-03'), boundary]]
|
||||
// Inclusive cursor re-returns issue:2 (boundary) then genuinely older rows,
|
||||
// enough to fill a full page of size 2 after dedup.
|
||||
const fetchPage = vi.fn().mockResolvedValue({
|
||||
items: [boundary, item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
|
||||
})
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: '2026-07-02',
|
||||
targetPage: 1,
|
||||
pageSize: 2,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result.cancelled).toBe(false)
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
expect(result.newPages).toEqual([
|
||||
[item('r', 'issue:3', '2026-07-01'), item('r', 'issue:4', '2026-07-00')]
|
||||
])
|
||||
expect(result.loadedPages).toBe(2)
|
||||
expect(fetchPage).toHaveBeenCalledWith('2026-07-02')
|
||||
})
|
||||
|
||||
it('backfills across fetches so a deduped page stays full and the tail stays reachable', async () => {
|
||||
// Regression: pageSize 3. Page 0 = issues 1..3. Inclusive cursor re-fetches
|
||||
// the boundary (issue:3), so a naive "one fetch = one page" scheme would emit
|
||||
// a 2-item page and the count-derived totalPages would strand the tail.
|
||||
const existing = [run('r', 1, 3)]
|
||||
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
// <=cursor0 re-returns issue:3 (boundary) + 4,5 => 2 fresh, not yet a page.
|
||||
.mockResolvedValueOnce({ items: run('r', 3, 5) })
|
||||
// Backfill fetches again from issue:5's ts and gets 5 (boundary) + 6 => 1 fresh.
|
||||
.mockResolvedValueOnce({ items: run('r', 5, 6) })
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: cursor0,
|
||||
targetPage: 1,
|
||||
pageSize: 3,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result.cancelled).toBe(false)
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
// One uniform full page of the 3 genuinely-new items 4,5,6 — nothing stranded.
|
||||
expect(result.newPages).toEqual([run('r', 4, 6)])
|
||||
expect(result.loadedPages).toBe(2)
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2)
|
||||
expect(fetchPage).toHaveBeenNthCalledWith(2, run('r', 3, 5).at(-1)!.updatedAt)
|
||||
})
|
||||
|
||||
it('does not confuse same-numbered items from different repos', async () => {
|
||||
const existing = [[item('repo-a', 'issue:9', '2026-07-02')]]
|
||||
// repo-b's issue:9 is a different item and must survive dedup.
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [item('repo-b', 'issue:9', '2026-07-01')] })
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: '2026-07-02',
|
||||
targetPage: 1,
|
||||
pageSize: 1,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result.cancelled).toBe(false)
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
expect(result.newPages).toEqual([[item('repo-b', 'issue:9', '2026-07-01')]])
|
||||
expect(result.loadedPages).toBe(2)
|
||||
})
|
||||
|
||||
it('flushes a short final page when the source is exhausted mid-page', async () => {
|
||||
const existing = [run('r', 1, 3)]
|
||||
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
// 2 fresh rows, short of pageSize 3...
|
||||
.mockResolvedValueOnce({ items: run('r', 3, 5) })
|
||||
// ...then the source is exhausted.
|
||||
.mockResolvedValueOnce({ items: [] })
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: cursor0,
|
||||
targetPage: 1,
|
||||
pageSize: 3,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result.cancelled).toBe(false)
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
// Short final page rather than dropping issues 4,5.
|
||||
expect(result.newPages).toEqual([run('r', 4, 5)])
|
||||
expect(result.loadedPages).toBe(2)
|
||||
})
|
||||
|
||||
it('stops (flushing progress) when a full page yields nothing new', async () => {
|
||||
const existing = [run('r', 1, 3)]
|
||||
const cursor0 = run('r', 1, 3).at(-1)!.updatedAt
|
||||
// First fetch adds issues 4,5 (2 fresh); second fetch re-returns only
|
||||
// already-seen rows (a >pageSize same-timestamp run) — no forward progress.
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: run('r', 3, 5) })
|
||||
.mockResolvedValueOnce({ items: run('r', 4, 5) })
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: cursor0,
|
||||
targetPage: 5,
|
||||
pageSize: 3,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result.cancelled).toBe(false)
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
// The 2 buffered fresh rows are flushed rather than lost.
|
||||
expect(result.newPages).toEqual([run('r', 4, 5)])
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('chains full pages until the target page is reached', async () => {
|
||||
const existing = [run('r', 1, 2)]
|
||||
const cursor0 = run('r', 1, 2).at(-1)!.updatedAt
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: run('r', 2, 4) }) // boundary 2 + 3,4
|
||||
.mockResolvedValueOnce({ items: run('r', 4, 6) }) // boundary 4 + 5,6
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: cursor0,
|
||||
targetPage: 2,
|
||||
pageSize: 2,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result.cancelled).toBe(false)
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
expect(result.newPages).toEqual([run('r', 3, 4), run('r', 5, 6)])
|
||||
expect(result.loadedPages).toBe(3)
|
||||
})
|
||||
|
||||
it('stops at an empty page (source exhausted) with no buffered rows', async () => {
|
||||
const existing = [[item('r', 'issue:1', '2026-07-02')]]
|
||||
const fetchPage = vi.fn().mockResolvedValue({ items: [] })
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: '2026-07-02',
|
||||
targetPage: 3,
|
||||
pageSize: 3,
|
||||
fetchPage,
|
||||
isCancelled: () => false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ cancelled: false, newPages: [], loadedPages: 1 })
|
||||
})
|
||||
|
||||
it('recovers leftover rows discarded at the target boundary on the next call', async () => {
|
||||
const pageSize = 3
|
||||
// Call 1: page0 = 1..3, target page 1. The fetch returns more than one
|
||||
// page of fresh rows; issue 7 is past the emitted page and gets discarded
|
||||
// (it belongs to page 2, which the user hasn't requested yet).
|
||||
const existing1 = [run('r', 1, 3)]
|
||||
const fetch1 = vi.fn().mockResolvedValueOnce({ items: run('r', 3, 7) }) // boundary 3 + 4,5,6,7
|
||||
const call1 = await accumulateWorkItemPages({
|
||||
existingPages: existing1,
|
||||
initialCursor: run('r', 1, 3).at(-1)!.updatedAt,
|
||||
targetPage: 1,
|
||||
pageSize,
|
||||
fetchPage: fetch1,
|
||||
isCancelled: () => false
|
||||
})
|
||||
expect(call1.cancelled).toBe(false)
|
||||
if (call1.cancelled) {
|
||||
return
|
||||
}
|
||||
expect(call1.newPages).toEqual([run('r', 4, 6)]) // 7 discarded
|
||||
|
||||
// Call 2: page1 is now on screen. The inclusive cursor re-fetches the
|
||||
// boundary (6, deduped) plus the discarded 7 and beyond — 7 must reappear
|
||||
// exactly once, in order, with no duplicate of 4..6.
|
||||
const call2 = await accumulateWorkItemPages({
|
||||
existingPages: [...existing1, ...call1.newPages],
|
||||
initialCursor: call1.newPages.at(-1)!.at(-1)!.updatedAt, // ts(6)
|
||||
targetPage: 2,
|
||||
pageSize,
|
||||
fetchPage: vi.fn().mockResolvedValueOnce({ items: run('r', 6, 9) }), // boundary 6 + 7,8,9
|
||||
isCancelled: () => false
|
||||
})
|
||||
expect(call2.cancelled).toBe(false)
|
||||
if (call2.cancelled) {
|
||||
return
|
||||
}
|
||||
expect(call2.newPages).toEqual([run('r', 7, 9)]) // 7 recovered, deduped, in order
|
||||
})
|
||||
|
||||
it('reports cancellation and discards fetched pages when superseded', async () => {
|
||||
const existing = [[item('r', 'issue:1', '2026-07-02')]]
|
||||
const fetchPage = vi.fn().mockResolvedValue({ items: [item('r', 'issue:2', '2026-07-01')] })
|
||||
|
||||
const result = await accumulateWorkItemPages({
|
||||
existingPages: existing,
|
||||
initialCursor: '2026-07-02',
|
||||
targetPage: 1,
|
||||
pageSize: 1,
|
||||
fetchPage,
|
||||
isCancelled: () => true
|
||||
})
|
||||
|
||||
expect(result).toEqual({ cancelled: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
|
||||
/**
|
||||
* Cross-repo Tasks pagination is cursor-based on `updatedAt`: each page's oldest
|
||||
* row seeds the next fetch as `updated:<=<cursor>`. The bound is inclusive so
|
||||
* items sharing the boundary row's exact timestamp aren't skipped between pages
|
||||
* (#8649) — which means the boundary rows come back on the next fetch and must
|
||||
* be deduped by identity here.
|
||||
*
|
||||
* Because dedup removes the re-fetched overlap, a single fetch no longer yields
|
||||
* a full page. If we emitted each deduped fetch as its own page, pages 1+ would
|
||||
* be one row short of `pageSize` while `totalPages` (count ÷ pageSize) still
|
||||
* assumed full pages — stranding the tail items. So we backfill: accumulate
|
||||
* fresh rows across as many fetches as needed and emit uniform `pageSize` pages,
|
||||
* flushing a short final page only when the source is exhausted.
|
||||
*
|
||||
* Kept as a pure function (out of the TaskPage component) so the dedup +
|
||||
* cursor-advance + fixed-page contract is unit-testable without a DOM.
|
||||
*
|
||||
* Tradeoff: when the per-repo fetch size equals `pageSize` the boundary dedup
|
||||
* costs one extra fetch per page (and re-fetches the sub-page leftover on the
|
||||
* next advance). Acceptable for interactive pagination and bounded by the gh
|
||||
* rate-limit guard; persisting the cursor/buffer across calls to avoid the
|
||||
* re-fetch is a possible follow-up.
|
||||
*/
|
||||
|
||||
// Why: `item.id` (e.g. "issue:9") is only unique within a repo — two selected
|
||||
// repos can carry the same bare id. Key dedup on repo + id, matching the row
|
||||
// key the table renders with.
|
||||
export function workItemIdentity(item: Pick<GitHubWorkItem, 'id' | 'repoId'>): string {
|
||||
return `${item.repoId}:${item.id}`
|
||||
}
|
||||
|
||||
export type WorkItemPageFetchResult = { items: GitHubWorkItem[] }
|
||||
|
||||
export type AccumulateWorkItemPagesArgs = {
|
||||
/** Pages already on screen; their items seed the dedup set. */
|
||||
existingPages: readonly GitHubWorkItem[][]
|
||||
/** updatedAt of the oldest currently-loaded row — the first fetch cursor. */
|
||||
initialCursor: string
|
||||
/** 0-indexed page the user is trying to reach. */
|
||||
targetPage: number
|
||||
/** Uniform display page size (the component's effectivePageSize). */
|
||||
pageSize: number
|
||||
/** Fetch one page for the given inclusive cursor. */
|
||||
fetchPage: (cursor: string) => Promise<WorkItemPageFetchResult>
|
||||
/** Returns true if the request was superseded and results should be dropped. */
|
||||
isCancelled: () => boolean
|
||||
}
|
||||
|
||||
export type AccumulateWorkItemPagesResult =
|
||||
| { cancelled: true }
|
||||
| {
|
||||
cancelled: false
|
||||
/** Freshly fetched, deduped, uniform-sized pages to append. */
|
||||
newPages: GitHubWorkItem[][]
|
||||
/** Total loaded page count after appending (existing + new). */
|
||||
loadedPages: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pages until `targetPage` is reached (or the source is exhausted),
|
||||
* deduping re-fetched boundary rows and emitting uniform `pageSize` pages so
|
||||
* every item lands on exactly one page and page sizes stay consistent with the
|
||||
* count-derived `totalPages`.
|
||||
*/
|
||||
export async function accumulateWorkItemPages(
|
||||
args: AccumulateWorkItemPagesArgs
|
||||
): Promise<AccumulateWorkItemPagesResult> {
|
||||
const { existingPages, initialCursor, targetPage, pageSize, fetchPage, isCancelled } = args
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const page of existingPages) {
|
||||
for (const item of page) {
|
||||
seen.add(workItemIdentity(item))
|
||||
}
|
||||
}
|
||||
|
||||
let cursor = initialCursor
|
||||
let loadedPages = existingPages.length
|
||||
const newPages: GitHubWorkItem[][] = []
|
||||
// Fresh rows not yet emitted as a page, held until they fill `pageSize`.
|
||||
let buffer: GitHubWorkItem[] = []
|
||||
|
||||
const emitFullPages = (): void => {
|
||||
while (buffer.length >= pageSize && loadedPages <= targetPage) {
|
||||
newPages.push(buffer.slice(0, pageSize))
|
||||
buffer = buffer.slice(pageSize)
|
||||
loadedPages += 1
|
||||
}
|
||||
}
|
||||
|
||||
while (loadedPages <= targetPage) {
|
||||
const { items } = await fetchPage(cursor)
|
||||
if (isCancelled()) {
|
||||
return { cancelled: true }
|
||||
}
|
||||
if (items.length === 0) {
|
||||
break
|
||||
}
|
||||
const fresh = items.filter((item) => !seen.has(workItemIdentity(item)))
|
||||
// Advance from the raw fetch, not the deduped rows, so the cursor tracks
|
||||
// real data. If a full page yields nothing new the cursor can't move past
|
||||
// this timestamp (a rare >pageSize same-timestamp run), so stop rather than
|
||||
// re-fetch the same window forever.
|
||||
cursor = items.at(-1)!.updatedAt
|
||||
if (fresh.length === 0) {
|
||||
break
|
||||
}
|
||||
for (const item of fresh) {
|
||||
seen.add(workItemIdentity(item))
|
||||
}
|
||||
buffer.push(...fresh)
|
||||
emitFullPages()
|
||||
}
|
||||
|
||||
// Source exhausted (or stalled) with a partial page still buffered: emit it as
|
||||
// a short final page so those items remain reachable.
|
||||
if (buffer.length > 0 && loadedPages <= targetPage) {
|
||||
newPages.push(buffer)
|
||||
loadedPages += 1
|
||||
}
|
||||
|
||||
return { cancelled: false, newPages, loadedPages }
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -200,6 +200,7 @@ type StoreState = {
|
||||
setAgentStatus: ReturnType<typeof vi.fn>
|
||||
removeAgentStatus: ReturnType<typeof vi.fn>
|
||||
dropAgentStatus: ReturnType<typeof vi.fn>
|
||||
retireAgentPaneAuthority: ReturnType<typeof vi.fn>
|
||||
setPaneForegroundAgent: ReturnType<typeof vi.fn>
|
||||
clearPaneForegroundAgent: ReturnType<typeof vi.fn>
|
||||
markTerminalTabUnread: ReturnType<typeof vi.fn>
|
||||
@@ -828,6 +829,7 @@ describe('connectPanePty', () => {
|
||||
),
|
||||
removeAgentStatus: vi.fn(),
|
||||
dropAgentStatus: vi.fn(),
|
||||
retireAgentPaneAuthority: vi.fn(),
|
||||
setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => {
|
||||
mockStoreState.paneForegroundAgentByPaneKey[paneKey] = entry
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('destroys when the tab is gone and no replacement owns the PTY', () => {
|
||||
it('detaches when closeTab already owns provider shutdown for the removed tab', () => {
|
||||
expect(
|
||||
shouldDetachPaneTransportOnUnmount({
|
||||
tabStillExists: false,
|
||||
@@ -159,6 +159,17 @@ describe('shouldDetachPaneTransportOnUnmount', () => {
|
||||
ptyId: 'remote:env@@term-1',
|
||||
worktreeTabs: []
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('destroys an ID-less transport so a pending spawn cannot outlive unmount', () => {
|
||||
expect(
|
||||
shouldDetachPaneTransportOnUnmount({
|
||||
tabStillExists: false,
|
||||
tabId: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeTabs: []
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -459,15 +459,9 @@ export function shouldDetachPaneTransportOnUnmount(args: {
|
||||
ptyId: string | null
|
||||
worktreeTabs: readonly TerminalTab[] | undefined
|
||||
}): boolean {
|
||||
if (!args.ptyId) {
|
||||
return false
|
||||
}
|
||||
if (args.tabStillExists) {
|
||||
return true
|
||||
}
|
||||
return Boolean(
|
||||
args.worktreeTabs?.some((tab) => tab.id !== args.tabId && tab.ptyId === args.ptyId)
|
||||
)
|
||||
// Why: mounted transport teardown is renderer-only; closeTab or the pane-close
|
||||
// action already owns provider shutdown. Destroy only pending, ID-less spawns.
|
||||
return Boolean(args.ptyId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1296,16 +1290,10 @@ export function useTerminalPaneLifecycle({
|
||||
}
|
||||
const leafId = closedPane?.leafId
|
||||
if (leafId && !isDetachedToTab) {
|
||||
// Why: closing a pane is user-initiated teardown of this row — drop
|
||||
// (not remove) so any retained `done` snapshot for this pane is also
|
||||
// cleared and a same-frame live→gone transition cannot re-snapshot
|
||||
// it via the retention sync. This is pane-keyed state, so it must
|
||||
// clear even if the PTY transport was already removed.
|
||||
// Why: pane close permanently revokes only this pane's authority;
|
||||
// an exact tombstone blocks queued hooks without suppressing siblings.
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
clearTerminalPaneUnread(paneKey)
|
||||
useAppStore.getState().dropAgentStatus(paneKey)
|
||||
useAppStore.getState().clearPaneForegroundAgent(paneKey)
|
||||
useAppStore.getState().retireAgentPaneAuthority(paneKey)
|
||||
}
|
||||
if (transport) {
|
||||
if (isDetachedToTab) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type {
|
||||
TerminalTabCloseReason,
|
||||
TerminalTabRetirementPlan
|
||||
} from '@/store/slices/terminal-tab-retirement'
|
||||
|
||||
export function closeLocalTerminalTabState(
|
||||
terminalTabId: string,
|
||||
options?: {
|
||||
reason?: TerminalTabCloseReason
|
||||
remoteCloseOwnedByHost?: boolean
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
precomputedRetirementPlan?: TerminalTabRetirementPlan
|
||||
}
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
if (
|
||||
options?.precomputedRetirementPlan?.tabId === terminalTabId ||
|
||||
Object.values(state.tabsByWorktree).some((tabs) => tabs.some((tab) => tab.id === terminalTabId))
|
||||
) {
|
||||
if (
|
||||
options?.reason ||
|
||||
options?.remoteCloseOwnedByHost ||
|
||||
options?.localPtyTeardownOwnedExternally ||
|
||||
options?.precomputedRetirementPlan
|
||||
) {
|
||||
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.closeTab(unified.entityId, options)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { AppState } from '@/store'
|
||||
import type { TerminalTabRetirementPlan } from '@/store/slices/terminal-tab-retirement'
|
||||
|
||||
export type PrecomputedTerminalCloseState = {
|
||||
owningWorktreeId: string
|
||||
terminalCountBeforeClose: number
|
||||
nextTerminalTabId: string | null
|
||||
}
|
||||
|
||||
export type TerminalCloseTarget = {
|
||||
worktreeId: string
|
||||
terminalTabId: string
|
||||
}
|
||||
|
||||
export function validatePrecomputedTerminalCloseState(
|
||||
tabId: string,
|
||||
retirementPlan: TerminalTabRetirementPlan | undefined,
|
||||
closeState: PrecomputedTerminalCloseState | undefined
|
||||
): PrecomputedTerminalCloseState | undefined {
|
||||
return retirementPlan?.tabId === tabId &&
|
||||
retirementPlan.worktreeId === closeState?.owningWorktreeId
|
||||
? closeState
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function resolveTerminalCloseTarget(
|
||||
state: Pick<AppState, 'tabsByWorktree' | 'unifiedTabsByWorktree'>,
|
||||
tabId: string,
|
||||
precomputed: PrecomputedTerminalCloseState | undefined
|
||||
): TerminalCloseTarget | null {
|
||||
if (precomputed) {
|
||||
return { worktreeId: precomputed.owningWorktreeId, terminalTabId: tabId }
|
||||
}
|
||||
for (const [worktreeId, worktreeTabs] of Object.entries(state.tabsByWorktree)) {
|
||||
if (worktreeTabs.some((tab) => tab.id === tabId)) {
|
||||
return { worktreeId, terminalTabId: tabId }
|
||||
}
|
||||
}
|
||||
for (const [worktreeId, unifiedTabs] of Object.entries(state.unifiedTabsByWorktree ?? {})) {
|
||||
const unified = unifiedTabs.find(
|
||||
(tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId)
|
||||
)
|
||||
if (unified) {
|
||||
return { worktreeId, terminalTabId: unified.entityId }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: host-backed terminals may exist only in unified state, so sibling
|
||||
// selection must merge both representations into one terminal entity set.
|
||||
export function getWorktreeTerminalTabIds(
|
||||
state: Pick<AppState, 'tabsByWorktree' | 'unifiedTabsByWorktree'>,
|
||||
worktreeId: string
|
||||
): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
|
||||
ids.add(tab.id)
|
||||
}
|
||||
for (const tab of state.unifiedTabsByWorktree?.[worktreeId] ?? []) {
|
||||
if (tab.contentType === 'terminal') {
|
||||
ids.add(tab.entityId)
|
||||
}
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
@@ -59,6 +59,7 @@ describe('closeTerminalTab kill-all routing', () => {
|
||||
})
|
||||
|
||||
it('force-closes a pinned terminal without opening a second confirmation', () => {
|
||||
const closeTab = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
@@ -74,6 +75,7 @@ describe('closeTerminalTab kill-all routing', () => {
|
||||
}
|
||||
]
|
||||
},
|
||||
closeTab,
|
||||
closeUnifiedTab,
|
||||
requestPinnedTabCloseConfirm
|
||||
})
|
||||
@@ -82,7 +84,8 @@ describe('closeTerminalTab kill-all routing', () => {
|
||||
closeTerminalTab('terminal-1', { force: true })
|
||||
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('visible-pinned')
|
||||
expect(closeTab).toHaveBeenCalledWith('terminal-1', { reason: undefined })
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes the last active terminal to an existing editor without closing it', () => {
|
||||
|
||||
@@ -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',
|
||||
@@ -206,6 +209,7 @@ describe('closeTerminalTab', () => {
|
||||
})
|
||||
|
||||
it('closes unified-only terminal tabs when tabsByWorktree is missing the row', () => {
|
||||
const closeTab = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
@@ -232,7 +236,7 @@ describe('closeTerminalTab', () => {
|
||||
activeTabId: 'terminal-entity-1',
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
closeTab: vi.fn(),
|
||||
closeTab,
|
||||
closeUnifiedTab,
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveWorktree: vi.fn()
|
||||
@@ -240,10 +244,12 @@ describe('closeTerminalTab', () => {
|
||||
|
||||
closeTerminalTab('terminal-entity-1')
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-tab-1')
|
||||
expect(closeTab).toHaveBeenCalledWith('terminal-entity-1', { reason: undefined })
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates the next unified terminal tab when closing the active unified-only tab', () => {
|
||||
const closeTab = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
const setActiveTab = vi.fn()
|
||||
getStateMock.mockReturnValue({
|
||||
@@ -285,7 +291,7 @@ describe('closeTerminalTab', () => {
|
||||
activeTabId: 'terminal-entity-1',
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
closeTab: vi.fn(),
|
||||
closeTab,
|
||||
closeUnifiedTab,
|
||||
setActiveTab,
|
||||
setActiveWorktree: vi.fn()
|
||||
@@ -294,7 +300,8 @@ describe('closeTerminalTab', () => {
|
||||
closeTerminalTab('terminal-entity-1')
|
||||
|
||||
expect(setActiveTab).toHaveBeenCalledWith('terminal-entity-2')
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-tab-1')
|
||||
expect(closeTab).toHaveBeenCalledWith('terminal-entity-1', { reason: undefined })
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes closes on a remote worktree to the host even when the local→host map has no entry', () => {
|
||||
@@ -319,7 +326,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',
|
||||
@@ -388,11 +398,13 @@ describe('closeTerminalTab', () => {
|
||||
|
||||
it('closes the pinned tab when the confirmation callback runs', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeTab = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeTab,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
@@ -401,7 +413,8 @@ describe('closeTerminalTab', () => {
|
||||
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
|
||||
onConfirm()
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
|
||||
expect(closeTab).toHaveBeenCalledWith('pinned-entity-1', { reason: undefined })
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('guards a pinned tab closed by its unified id (workspace overlay path)', () => {
|
||||
@@ -425,11 +438,13 @@ describe('closeTerminalTab', () => {
|
||||
|
||||
it('closes a pinned tab immediately when the confirmation setting is off', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeTab = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: false,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeTab,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
@@ -437,7 +452,8 @@ describe('closeTerminalTab', () => {
|
||||
closeTerminalTab('pinned-entity-1')
|
||||
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
|
||||
expect(closeTab).toHaveBeenCalledWith('pinned-entity-1', { reason: undefined })
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,18 @@ 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,
|
||||
TerminalTabRetirementPlan
|
||||
} from '@/store/slices/terminal-tab-retirement'
|
||||
import { closeLocalTerminalTabState } from './close-local-terminal-tab-state'
|
||||
import {
|
||||
getWorktreeTerminalTabIds,
|
||||
resolveTerminalCloseTarget,
|
||||
validatePrecomputedTerminalCloseState,
|
||||
type PrecomputedTerminalCloseState
|
||||
} from './terminal-close-target'
|
||||
export type { PrecomputedTerminalCloseState } from './terminal-close-target'
|
||||
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
'editor',
|
||||
@@ -22,71 +34,6 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
|
||||
type TerminalTabActionState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
type CloseTerminalTabTarget = {
|
||||
worktreeId: string
|
||||
terminalTabId: string
|
||||
}
|
||||
|
||||
function resolveCloseTerminalTabTarget(
|
||||
state: TerminalTabActionState,
|
||||
tabId: string
|
||||
): CloseTerminalTabTarget | null {
|
||||
for (const [worktreeId, worktreeTabs] of Object.entries(state.tabsByWorktree)) {
|
||||
if (worktreeTabs.some((tab) => tab.id === tabId)) {
|
||||
return { worktreeId, terminalTabId: tabId }
|
||||
}
|
||||
}
|
||||
|
||||
for (const [worktreeId, unifiedTabs] of Object.entries(state.unifiedTabsByWorktree ?? {})) {
|
||||
const unified = unifiedTabs.find(
|
||||
(tab) => tab.contentType === 'terminal' && (tab.entityId === tabId || tab.id === tabId)
|
||||
)
|
||||
if (unified) {
|
||||
return { worktreeId, terminalTabId: unified.entityId }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: host-backed terminals may only exist in unifiedTabsByWorktree as
|
||||
// terminal entities, so close/sibling selection must merge tabsByWorktree and
|
||||
// unified terminal entityIds into one deduped list per worktree.
|
||||
function getWorktreeTerminalTabIds(state: TerminalTabActionState, worktreeId: string): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const tab of state.tabsByWorktree[worktreeId] ?? []) {
|
||||
ids.add(tab.id)
|
||||
}
|
||||
for (const tab of state.unifiedTabsByWorktree?.[worktreeId] ?? []) {
|
||||
if (tab.contentType === 'terminal') {
|
||||
ids.add(tab.entityId)
|
||||
}
|
||||
}
|
||||
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,9 +96,23 @@ 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
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
precomputedRetirementPlan?: TerminalTabRetirementPlan
|
||||
precomputedCloseState?: PrecomputedTerminalCloseState
|
||||
}
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
const target = resolveCloseTerminalTabTarget(state, tabId)
|
||||
const precomputedCloseState = validatePrecomputedTerminalCloseState(
|
||||
tabId,
|
||||
options?.precomputedRetirementPlan,
|
||||
options?.precomputedCloseState
|
||||
)
|
||||
const target = resolveTerminalCloseTarget(state, tabId, precomputedCloseState)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
@@ -159,7 +120,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 +152,16 @@ 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,
|
||||
...(options?.localPtyTeardownOwnedExternally
|
||||
? { localPtyTeardownOwnedExternally: true }
|
||||
: {}),
|
||||
...(options?.precomputedRetirementPlan
|
||||
? { precomputedRetirementPlan: options.precomputedRetirementPlan }
|
||||
: {})
|
||||
})
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: owningWorktreeId,
|
||||
tabId: hostBackedTabId,
|
||||
@@ -196,9 +170,21 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
|
||||
return
|
||||
}
|
||||
|
||||
const currentTerminalTabIds = getWorktreeTerminalTabIds(state, owningWorktreeId)
|
||||
if (currentTerminalTabIds.length <= 1) {
|
||||
closeLocalTerminalTabState(terminalTabId)
|
||||
const currentTerminalTabIds = precomputedCloseState
|
||||
? null
|
||||
: getWorktreeTerminalTabIds(state, owningWorktreeId)
|
||||
const terminalCountBeforeClose =
|
||||
precomputedCloseState?.terminalCountBeforeClose ?? currentTerminalTabIds!.length
|
||||
if (terminalCountBeforeClose <= 1) {
|
||||
closeLocalTerminalTabState(terminalTabId, {
|
||||
reason: options?.reason,
|
||||
...(options?.localPtyTeardownOwnedExternally
|
||||
? { localPtyTeardownOwnedExternally: true }
|
||||
: {}),
|
||||
...(options?.precomputedRetirementPlan
|
||||
? { precomputedRetirementPlan: options.precomputedRetirementPlan }
|
||||
: {})
|
||||
})
|
||||
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
|
||||
@@ -221,15 +207,22 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
|
||||
}
|
||||
|
||||
if (state.activeWorktreeId === owningWorktreeId && terminalTabId === state.activeTabId) {
|
||||
const currentIndex = currentTerminalTabIds.indexOf(terminalTabId)
|
||||
const nextTabId =
|
||||
currentTerminalTabIds[currentIndex + 1] ?? currentTerminalTabIds[currentIndex - 1]
|
||||
const currentIndex = currentTerminalTabIds?.indexOf(terminalTabId) ?? -1
|
||||
const nextTabId = precomputedCloseState
|
||||
? precomputedCloseState.nextTerminalTabId
|
||||
: (currentTerminalTabIds![currentIndex + 1] ?? currentTerminalTabIds![currentIndex - 1])
|
||||
if (nextTabId) {
|
||||
state.setActiveTab(nextTabId)
|
||||
}
|
||||
}
|
||||
|
||||
closeLocalTerminalTabState(terminalTabId)
|
||||
closeLocalTerminalTabState(terminalTabId, {
|
||||
reason: options?.reason,
|
||||
...(options?.localPtyTeardownOwnedExternally ? { localPtyTeardownOwnedExternally: true } : {}),
|
||||
...(options?.precomputedRetirementPlan
|
||||
? { precomputedRetirementPlan: options.precomputedRetirementPlan }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
|
||||
export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void {
|
||||
|
||||
@@ -4873,6 +4873,104 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not recurse when flushing a pending status re-enters via the store subscriber', async () => {
|
||||
// Repro for crash 9fc89529 (RangeError: Maximum call stack size exceeded):
|
||||
// the store subscriber calls flushPendingAgentStatuses() on every update.
|
||||
// flush -> applyAgentStatus -> store.setAgentStatus notifies subscribers
|
||||
// synchronously (like Zustand) -> subscriber -> flush again while the same
|
||||
// event is still queued -> infinite recursion. Model setAgentStatus with a
|
||||
// real synchronous notify so the re-entrancy is exercised end to end.
|
||||
const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null }
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
current: null
|
||||
}
|
||||
let setAgentStatusCalls = 0
|
||||
const notify = (): void => {
|
||||
const listener = subscribeListenerRef.current
|
||||
if (listener) {
|
||||
listener(storeState, storeState)
|
||||
}
|
||||
}
|
||||
const storeState: StoreLike = buildStoreState({
|
||||
// Why: mirror Zustand — a state mutation notifies subscribers synchronously.
|
||||
setAgentStatus: (paneKey: string, entry: unknown) => {
|
||||
setAgentStatusCalls += 1
|
||||
storeState.agentStatusByPaneKey = {
|
||||
...(storeState.agentStatusByPaneKey as Record<string, unknown>),
|
||||
[paneKey]: entry
|
||||
}
|
||||
notify()
|
||||
},
|
||||
workspaceSessionReady: true,
|
||||
settings: { terminalFontSize: 13, notifications: { enabled: false } },
|
||||
// Pane does not exist yet -> the incoming event is buffered as pending.
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
vi.doMock('../store', () => ({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn((listener: StoreSubscribeListener) => {
|
||||
subscribeListenerRef.current = listener
|
||||
return () => {
|
||||
subscribeListenerRef.current = null
|
||||
}
|
||||
}),
|
||||
getState: () => storeState
|
||||
}
|
||||
}))
|
||||
stubAuxiliaryModules()
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
buildWindowApi({
|
||||
onSet: (cb) => {
|
||||
onSetListenerRef.current = cb
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const { useIpcEvents } = await import('./useIpcEvents')
|
||||
useIpcEvents()
|
||||
await Promise.resolve()
|
||||
|
||||
if (typeof onSetListenerRef.current !== 'function') {
|
||||
throw new Error('Expected agentStatus.onSet listener to be registered')
|
||||
}
|
||||
if (typeof subscribeListenerRef.current !== 'function') {
|
||||
throw new Error('Expected useAppStore.subscribe listener to be registered')
|
||||
}
|
||||
|
||||
// Event lands before the tab exists -> buffered as a pending retry.
|
||||
onSetListenerRef.current({
|
||||
paneKey: FUTURE_PANE_KEY,
|
||||
state: 'working',
|
||||
prompt: 'p',
|
||||
agentType: 'claude',
|
||||
receivedAt: 1_700_000_000_100,
|
||||
stateStartedAt: 1_699_999_999_100
|
||||
})
|
||||
expect(setAgentStatusCalls).toBe(0)
|
||||
|
||||
// Tab hydrates; the next store update flushes the pending event. Without the
|
||||
// re-entrancy guard this overflows the stack instead of applying once.
|
||||
storeState.tabsByWorktree = {
|
||||
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }]
|
||||
}
|
||||
storeState.terminalLayoutsByTabId = {
|
||||
'tab-future': {
|
||||
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
|
||||
activeLeafId: FUTURE_LEAF_ID,
|
||||
expandedLeafId: null
|
||||
}
|
||||
}
|
||||
|
||||
expect(() => notify()).not.toThrow()
|
||||
// Applied exactly once — the re-entrant flush is a no-op, not a loop.
|
||||
expect(setAgentStatusCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('applies ready push events for an unmounted inactive terminal tab', async () => {
|
||||
const setAgentStatus = vi.fn()
|
||||
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
|
||||
|
||||
@@ -124,6 +124,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut
|
||||
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
|
||||
import { titleHasAgentName } from '../../../shared/agent-detection'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
|
||||
import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode'
|
||||
@@ -251,10 +252,14 @@ let remoteWorkspaceSnapshotWriteSuppressUntil = 0
|
||||
const REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS = 1000
|
||||
|
||||
function isAgentStatusForRecentlyClosedTab(
|
||||
store: Pick<AppState, 'recentlyClosedAgentStatusTabIds'>,
|
||||
store: Pick<AppState, 'recentlyClosedAgentStatusTabIds' | 'recentlyRetiredAgentStatusPaneKeys'>,
|
||||
paneKey: string
|
||||
): boolean {
|
||||
const tabId = parsePaneKey(paneKey)?.tabId
|
||||
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
|
||||
if (store.recentlyRetiredAgentStatusPaneKeys?.[ownerPaneKey] === true) {
|
||||
return true
|
||||
}
|
||||
const tabId = parsePaneKey(ownerPaneKey)?.tabId
|
||||
if (!tabId) {
|
||||
return false
|
||||
}
|
||||
@@ -842,6 +847,11 @@ export function useIpcEvents(): void {
|
||||
type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped'
|
||||
const pendingAgentStatusEvents: PendingAgentStatusEvent[] = []
|
||||
let pendingAgentStatusRetryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: applyAgentStatus -> store.setAgentStatus notifies the store
|
||||
// subscriber synchronously, which re-enters flushPendingAgentStatuses while
|
||||
// the queue is still mid-drain. Guard against that re-entrancy so the same
|
||||
// event is not reprocessed forever (crash 9fc89529: stack overflow).
|
||||
let isFlushingAgentStatuses = false
|
||||
|
||||
unsubs.push(attachMobileMarkdownBridge())
|
||||
|
||||
@@ -2917,25 +2927,37 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
|
||||
function flushPendingAgentStatuses(): void {
|
||||
// Why: a re-entrant call (store subscriber firing during a setAgentStatus
|
||||
// inside the loop below) must not reprocess the still-queued events — the
|
||||
// outer flush already owns them. Bailing here breaks the infinite
|
||||
// recursion without dropping work; the outer loop finishes the drain.
|
||||
if (isFlushingAgentStatuses) {
|
||||
return
|
||||
}
|
||||
if (pendingAgentStatusEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const remaining: PendingAgentStatusEvent[] = []
|
||||
for (const event of pendingAgentStatusEvents) {
|
||||
if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
|
||||
continue
|
||||
isFlushingAgentStatuses = true
|
||||
try {
|
||||
const now = Date.now()
|
||||
const remaining: PendingAgentStatusEvent[] = []
|
||||
for (const event of pendingAgentStatusEvents) {
|
||||
if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) {
|
||||
continue
|
||||
}
|
||||
const result = applyAgentStatus(event.data, { retry: true })
|
||||
if (result === 'pending') {
|
||||
remaining.push(event)
|
||||
}
|
||||
}
|
||||
const result = applyAgentStatus(event.data, { retry: true })
|
||||
if (result === 'pending') {
|
||||
remaining.push(event)
|
||||
pendingAgentStatusEvents.length = 0
|
||||
pendingAgentStatusEvents.push(...remaining)
|
||||
if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
|
||||
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
|
||||
pendingAgentStatusRetryTimer = null
|
||||
}
|
||||
}
|
||||
pendingAgentStatusEvents.length = 0
|
||||
pendingAgentStatusEvents.push(...remaining)
|
||||
if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) {
|
||||
globalThis.clearTimeout(pendingAgentStatusRetryTimer)
|
||||
pendingAgentStatusRetryTimer = null
|
||||
} finally {
|
||||
isFlushingAgentStatuses = false
|
||||
}
|
||||
schedulePendingAgentStatusFlush()
|
||||
}
|
||||
@@ -2951,6 +2973,8 @@ export function useIpcEvents(): void {
|
||||
if (isAgentStatusForRecentlyClosedTab(store, data.paneKey)) {
|
||||
return 'dropped'
|
||||
}
|
||||
const paneKey = resolveAgentPaneAuthorityKey(data.paneKey)
|
||||
const ownerTabId = parsePaneKey(paneKey)?.tabId ?? data.tabId
|
||||
const payload = normalizeAgentStatusPayload({
|
||||
state: data.state,
|
||||
prompt: data.prompt,
|
||||
@@ -2976,7 +3000,7 @@ export function useIpcEvents(): void {
|
||||
repoConnectionId,
|
||||
repoConnectionResolved,
|
||||
owningWorktreeId
|
||||
} = resolvePaneKey(store, data.paneKey)
|
||||
} = resolvePaneKey(store, paneKey)
|
||||
if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
|
||||
// Why: orchestration worker hooks can carry main-side worktree
|
||||
// attribution before this renderer has a terminal tab for the pane.
|
||||
@@ -3058,7 +3082,7 @@ export function useIpcEvents(): void {
|
||||
const statusPayloadWithTurnBoundary = data.promptInteractionKey
|
||||
? { ...statusPayload, promptInteractionKey: data.promptInteractionKey }
|
||||
: statusPayload
|
||||
const existingStatus = store.agentStatusByPaneKey[data.paneKey]
|
||||
const existingStatus = store.agentStatusByPaneKey[paneKey]
|
||||
if (existingStatus && data.receivedAt < existingStatus.updatedAt) {
|
||||
// Why: the store rejects out-of-order status rows; keep notification and
|
||||
// terminal lifecycle effects on the same accepted event boundary.
|
||||
@@ -3088,8 +3112,8 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
if (
|
||||
shouldSuppressCodexAutoApprovalStatus(statusPayload, {
|
||||
paneKey: data.paneKey,
|
||||
tabId: data.tabId,
|
||||
paneKey,
|
||||
tabId: ownerTabId,
|
||||
terminalHandle: data.terminalHandle,
|
||||
launchToken: data.launchToken,
|
||||
providerSession: data.providerSession,
|
||||
@@ -3103,7 +3127,7 @@ export function useIpcEvents(): void {
|
||||
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
|
||||
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
|
||||
store.setAgentStatus(
|
||||
data.paneKey,
|
||||
paneKey,
|
||||
statusPayloadWithTurnBoundary,
|
||||
terminalTitle,
|
||||
{
|
||||
@@ -3111,7 +3135,7 @@ export function useIpcEvents(): void {
|
||||
stateStartedAt: data.stateStartedAt
|
||||
},
|
||||
{
|
||||
tabId: data.tabId,
|
||||
tabId: ownerTabId,
|
||||
worktreeId: statusWorktreeId,
|
||||
terminalHandle: data.terminalHandle
|
||||
},
|
||||
@@ -3122,7 +3146,7 @@ export function useIpcEvents(): void {
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
applyResolvedAgentTerminalTitleToTab(store, data.paneKey, title, terminalTitle)
|
||||
applyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle)
|
||||
if (options?.replay !== true && statusWorktreeId) {
|
||||
// Why: local Codex/Claude hooks arrive through this main-process IPC
|
||||
// path, not the PTY OSC fallback, so task-complete notifications must
|
||||
@@ -3132,7 +3156,7 @@ export function useIpcEvents(): void {
|
||||
? { ...resolvedPayload, stateStartedAt: data.stateStartedAt }
|
||||
: resolvedPayload
|
||||
observeAgentHookCompletionForNotification({
|
||||
paneKey: data.paneKey,
|
||||
paneKey,
|
||||
worktreeId: statusWorktreeId,
|
||||
payload: notificationPayload
|
||||
})
|
||||
|
||||
@@ -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,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import {
|
||||
resetAgentPaneAuthorityAliasesForTests,
|
||||
resolveAgentPaneAuthorityKey
|
||||
} from './agent-pane-authority'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
const SOURCE = makePaneKey('tab-source', '11111111-1111-4111-8111-111111111111')
|
||||
const TARGET = makePaneKey('tab-target', '22222222-2222-4222-8222-222222222222')
|
||||
const FINAL = makePaneKey('tab-final', '33333333-3333-4333-8333-333333333333')
|
||||
const SIBLING = makePaneKey('tab-target', '44444444-4444-4444-8444-444444444444')
|
||||
|
||||
const retirePaneAuthority = vi.fn()
|
||||
const transferPaneAuthority = vi.fn()
|
||||
const dropByTabPrefix = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
resetAgentPaneAuthorityAliasesForTests()
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
agentStatus: {
|
||||
retirePaneAuthority,
|
||||
transferPaneAuthority,
|
||||
dropByTabPrefix,
|
||||
drop: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetAgentPaneAuthorityAliasesForTests()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('agent pane authority', () => {
|
||||
it('retires one pane, clears resume authority, and rejects late status without harming siblings', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' })
|
||||
store.getState().setAgentStatus(SIBLING, { state: 'working', prompt: 'sibling' })
|
||||
store.getState().registerAgentLaunchConfig(TARGET, { agentArgs: '', agentEnv: {} })
|
||||
store.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[TARGET]: {
|
||||
paneKey: TARGET,
|
||||
tabId: 'tab-target',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().retireAgentPaneAuthority(TARGET)
|
||||
store.getState().setAgentStatus(TARGET, { state: 'done', prompt: 'late' })
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.agentStatusByPaneKey[TARGET]).toBeUndefined()
|
||||
expect(state.agentLaunchConfigByPaneKey[TARGET]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[TARGET]).toBeUndefined()
|
||||
expect(state.agentStatusByPaneKey[SIBLING]).toBeDefined()
|
||||
expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBe(true)
|
||||
expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET)
|
||||
})
|
||||
|
||||
it('keeps a physical pane routed through chained detaches until its current owner closes', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setAgentStatus(SOURCE, { state: 'working', prompt: 'source' })
|
||||
store.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[SOURCE]: {
|
||||
paneKey: SOURCE,
|
||||
tabId: 'tab-source',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store
|
||||
.getState()
|
||||
.transferAgentPaneAuthority({ fromPaneKey: SOURCE, toPaneKey: TARGET, ptyId: 'pty-1' })
|
||||
store
|
||||
.getState()
|
||||
.transferAgentPaneAuthority({ fromPaneKey: TARGET, toPaneKey: FINAL, ptyId: 'pty-1' })
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-source')
|
||||
|
||||
expect(resolveAgentPaneAuthorityKey(SOURCE)).toBe(FINAL)
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey[FINAL]).toMatchObject({
|
||||
paneKey: FINAL,
|
||||
tabId: 'tab-final',
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
})
|
||||
store.getState().setAgentStatus(SOURCE, { state: 'working', prompt: 'after source close' })
|
||||
expect(store.getState().agentStatusByPaneKey[FINAL]?.prompt).toBe('after source close')
|
||||
expect(transferPaneAuthority).toHaveBeenNthCalledWith(1, {
|
||||
fromPaneKey: SOURCE,
|
||||
toPaneKey: TARGET,
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
expect(transferPaneAuthority).toHaveBeenNthCalledWith(2, {
|
||||
fromPaneKey: TARGET,
|
||||
toPaneKey: FINAL,
|
||||
ptyId: 'pty-1'
|
||||
})
|
||||
|
||||
store.getState().dropAgentStatusByTabPrefix('tab-final')
|
||||
store.getState().setAgentStatus(SOURCE, { state: 'done', prompt: 'too late' })
|
||||
|
||||
expect(store.getState().agentStatusByPaneKey[SOURCE]).toBeUndefined()
|
||||
expect(store.getState().agentStatusByPaneKey[FINAL]).toBeUndefined()
|
||||
expect(store.getState().recentlyRetiredAgentStatusPaneKeys[SOURCE]).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
|
||||
type AgentPaneAuthorityAlias = {
|
||||
ownerPaneKey: string
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
const aliasesByPhysicalPaneKey = new Map<string, AgentPaneAuthorityAlias>()
|
||||
|
||||
export type AgentPaneAuthorityTransfer = {
|
||||
physicalPaneKey: string
|
||||
previousOwnerPaneKey: string
|
||||
ownerPaneKey: string
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
export function resolveAgentPaneAuthorityKey(paneKey: string): string {
|
||||
return aliasesByPhysicalPaneKey.get(paneKey)?.ownerPaneKey ?? paneKey
|
||||
}
|
||||
|
||||
export function transferAgentPaneAuthorityAlias(args: {
|
||||
fromPaneKey: string
|
||||
toPaneKey: string
|
||||
ptyId?: string | null
|
||||
}): AgentPaneAuthorityTransfer | null {
|
||||
if (!parsePaneKey(args.fromPaneKey) || !parsePaneKey(args.toPaneKey)) {
|
||||
return null
|
||||
}
|
||||
const previousOwnerPaneKey = resolveAgentPaneAuthorityKey(args.fromPaneKey)
|
||||
let physicalPaneKey = args.fromPaneKey
|
||||
for (const [candidatePhysicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
|
||||
if (
|
||||
alias.ownerPaneKey === previousOwnerPaneKey &&
|
||||
(!args.ptyId || !alias.ptyId || alias.ptyId === args.ptyId)
|
||||
) {
|
||||
physicalPaneKey = candidatePhysicalPaneKey
|
||||
break
|
||||
}
|
||||
}
|
||||
const ptyId = args.ptyId?.trim() || aliasesByPhysicalPaneKey.get(physicalPaneKey)?.ptyId || null
|
||||
if (physicalPaneKey !== args.toPaneKey) {
|
||||
// Why: the process keeps posting its original ORCA_PANE_KEY after detach;
|
||||
// one physical-to-owner alias keeps chained moves on the current surface.
|
||||
aliasesByPhysicalPaneKey.set(physicalPaneKey, {
|
||||
ownerPaneKey: args.toPaneKey,
|
||||
ptyId
|
||||
})
|
||||
}
|
||||
return {
|
||||
physicalPaneKey,
|
||||
previousOwnerPaneKey,
|
||||
ownerPaneKey: args.toPaneKey,
|
||||
ptyId
|
||||
}
|
||||
}
|
||||
|
||||
export function retireAgentPaneAuthorityAliases(paneKey: string): string[] {
|
||||
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
|
||||
const retiredPaneKeys = new Set([paneKey, ownerPaneKey])
|
||||
for (const [physicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
|
||||
if (physicalPaneKey === paneKey || alias.ownerPaneKey === ownerPaneKey) {
|
||||
aliasesByPhysicalPaneKey.delete(physicalPaneKey)
|
||||
retiredPaneKeys.add(physicalPaneKey)
|
||||
retiredPaneKeys.add(alias.ownerPaneKey)
|
||||
}
|
||||
}
|
||||
return [...retiredPaneKeys]
|
||||
}
|
||||
|
||||
export function retireAgentPaneAuthorityAliasesByOwnerTab(tabId: string): string[] {
|
||||
const ownerPrefix = `${tabId}:`
|
||||
const retiredPaneKeys = new Set<string>()
|
||||
for (const [physicalPaneKey, alias] of aliasesByPhysicalPaneKey) {
|
||||
if (!alias.ownerPaneKey.startsWith(ownerPrefix)) {
|
||||
continue
|
||||
}
|
||||
aliasesByPhysicalPaneKey.delete(physicalPaneKey)
|
||||
retiredPaneKeys.add(physicalPaneKey)
|
||||
retiredPaneKeys.add(alias.ownerPaneKey)
|
||||
}
|
||||
return [...retiredPaneKeys]
|
||||
}
|
||||
|
||||
export function resetAgentPaneAuthorityAliasesForTests(): void {
|
||||
aliasesByPhysicalPaneKey.clear()
|
||||
}
|
||||
@@ -32,6 +32,12 @@ import {
|
||||
isOrcaDispatchPrompt,
|
||||
orchestrationLabelsMatchLiveDispatch
|
||||
} from '@/lib/agent-row-primary-text'
|
||||
import {
|
||||
resolveAgentPaneAuthorityKey,
|
||||
retireAgentPaneAuthorityAliases,
|
||||
retireAgentPaneAuthorityAliasesByOwnerTab,
|
||||
transferAgentPaneAuthorityAlias
|
||||
} from './agent-pane-authority'
|
||||
import { createFreshnessScheduler } from './agent-status-freshness-scheduler'
|
||||
|
||||
/** Snapshot of a finished (or vanished) agent status entry, kept around so
|
||||
@@ -133,6 +139,16 @@ export type AgentStatusSlice = {
|
||||
* drop late in-flight IPC statuses and stale main-cache replays. */
|
||||
recentlyClosedAgentStatusTabIds: Record<string, true>
|
||||
|
||||
/** Exact pane authorities retired while sibling panes in the tab stay live. */
|
||||
recentlyRetiredAgentStatusPaneKeys: Record<string, true>
|
||||
|
||||
retireAgentPaneAuthority: (paneKey: string) => void
|
||||
transferAgentPaneAuthority: (args: {
|
||||
fromPaneKey: string
|
||||
toPaneKey: string
|
||||
ptyId?: string | null
|
||||
}) => void
|
||||
|
||||
/** Update or insert an agent status entry from a status payload. */
|
||||
setAgentStatus: (
|
||||
paneKey: string,
|
||||
@@ -838,6 +854,7 @@ function getLaunchConfigForEntry(
|
||||
// so it must outlive the tab briefly — but tabId is ephemeral and it was only
|
||||
// ever added to, growing one entry per tab-close for the renderer's whole life.
|
||||
export const RECENTLY_CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
||||
export const RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX = 1024
|
||||
|
||||
// delete-then-set for LRU recency, then evict the oldest keys past the cap (Record
|
||||
// key order is insertion order for non-integer string keys). A status event for a
|
||||
@@ -862,6 +879,58 @@ function boundRecentlyClosedAgentStatusTabIds(
|
||||
return next
|
||||
}
|
||||
|
||||
function boundRecentlyRetiredAgentStatusPaneKeys(
|
||||
existing: Record<string, true>,
|
||||
paneKeys: readonly string[]
|
||||
): Record<string, true> {
|
||||
const additions = new Set(paneKeys)
|
||||
const next: Record<string, true> = {}
|
||||
for (const key of Object.keys(existing)) {
|
||||
if (!additions.has(key)) {
|
||||
next[key] = true
|
||||
}
|
||||
}
|
||||
for (const paneKey of additions) {
|
||||
next[paneKey] = true
|
||||
}
|
||||
const keys = Object.keys(next)
|
||||
for (const stale of keys.slice(0, -RECENTLY_RETIRED_AGENT_STATUS_PANE_KEYS_MAX)) {
|
||||
delete next[stale]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function movePaneKeyedRecord<T>(
|
||||
record: Record<string, T>,
|
||||
fromPaneKey: string,
|
||||
toPaneKey: string,
|
||||
transform: (value: T) => T = (value) => value
|
||||
): Record<string, T> {
|
||||
const value = record[fromPaneKey]
|
||||
if (value === undefined || fromPaneKey === toPaneKey) {
|
||||
return record
|
||||
}
|
||||
const next = { ...record }
|
||||
delete next[fromPaneKey]
|
||||
next[toPaneKey] = transform(value)
|
||||
return next
|
||||
}
|
||||
|
||||
function removePaneKeys<T>(
|
||||
record: Record<string, T>,
|
||||
paneKeys: ReadonlySet<string>
|
||||
): Record<string, T> {
|
||||
const matchingKeys = Object.keys(record).filter((key) => paneKeys.has(key))
|
||||
if (matchingKeys.length === 0) {
|
||||
return record
|
||||
}
|
||||
const next = { ...record }
|
||||
for (const key of matchingKeys) {
|
||||
delete next[key]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function getLaunchConfigForStatusMetadata(
|
||||
state: AppState,
|
||||
metadata: AgentLaunchConfigStatusMetadata
|
||||
@@ -1011,8 +1080,142 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
agentLaunchConfigByPaneKey: {},
|
||||
retentionSuppressedPaneKeys: {},
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
recentlyRetiredAgentStatusPaneKeys: {},
|
||||
scheduleAgentStatusFreshness: () => freshness.schedule(),
|
||||
|
||||
retireAgentPaneAuthority: (paneKey) => {
|
||||
const ownerPaneKey = resolveAgentPaneAuthorityKey(paneKey)
|
||||
const retiredPaneKeys = retireAgentPaneAuthorityAliases(paneKey)
|
||||
const retiredPaneKeySet = new Set(retiredPaneKeys)
|
||||
let hadLive = false
|
||||
set((s) => {
|
||||
const retiredLivePaneKeys = retiredPaneKeys.filter((key) => key in s.agentStatusByPaneKey)
|
||||
hadLive = retiredLivePaneKeys.length > 0
|
||||
let nextRetentionSuppressedPaneKeys = removePaneKeys(
|
||||
s.retentionSuppressedPaneKeys,
|
||||
retiredPaneKeySet
|
||||
)
|
||||
if (
|
||||
retiredLivePaneKeys.length > 0 &&
|
||||
nextRetentionSuppressedPaneKeys === s.retentionSuppressedPaneKeys
|
||||
) {
|
||||
nextRetentionSuppressedPaneKeys = { ...nextRetentionSuppressedPaneKeys }
|
||||
}
|
||||
for (const key of retiredLivePaneKeys) {
|
||||
nextRetentionSuppressedPaneKeys[key] = true
|
||||
}
|
||||
return {
|
||||
agentStatusByPaneKey: removePaneKeys(s.agentStatusByPaneKey, retiredPaneKeySet),
|
||||
runtimeAgentOrchestrationByPaneKey: removePaneKeys(
|
||||
s.runtimeAgentOrchestrationByPaneKey,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
retainedAgentsByPaneKey: removePaneKeys(s.retainedAgentsByPaneKey, retiredPaneKeySet),
|
||||
sleepingAgentSessionsByPaneKey: removePaneKeys(
|
||||
s.sleepingAgentSessionsByPaneKey,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
agentLaunchConfigByPaneKey: removePaneKeys(
|
||||
s.agentLaunchConfigByPaneKey,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
acknowledgedAgentsByPaneKey: removePaneKeys(
|
||||
s.acknowledgedAgentsByPaneKey,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
paneForegroundAgentByPaneKey: removePaneKeys(
|
||||
s.paneForegroundAgentByPaneKey,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
unreadTerminalPanes: removePaneKeys(s.unreadTerminalPanes, retiredPaneKeySet),
|
||||
unreadAgentCompletionPanes: removePaneKeys(
|
||||
s.unreadAgentCompletionPanes,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
lastTerminalInputAtByPaneKey: removePaneKeys(
|
||||
s.lastTerminalInputAtByPaneKey,
|
||||
retiredPaneKeySet
|
||||
),
|
||||
cacheTimerByKey: removePaneKeys(s.cacheTimerByKey, retiredPaneKeySet),
|
||||
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
|
||||
recentlyRetiredAgentStatusPaneKeys: boundRecentlyRetiredAgentStatusPaneKeys(
|
||||
s.recentlyRetiredAgentStatusPaneKeys,
|
||||
retiredPaneKeys
|
||||
),
|
||||
agentStatusEpoch: hadLive ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
|
||||
sortEpoch: hadLive ? s.sortEpoch + 1 : s.sortEpoch
|
||||
}
|
||||
})
|
||||
if (hadLive) {
|
||||
queueMicrotask(() => freshness.schedule())
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.api?.agentStatus?.retirePaneAuthority?.(ownerPaneKey)
|
||||
}
|
||||
},
|
||||
|
||||
transferAgentPaneAuthority: ({ fromPaneKey, toPaneKey, ptyId }) => {
|
||||
const transfer = transferAgentPaneAuthorityAlias({ fromPaneKey, toPaneKey, ptyId })
|
||||
if (!transfer || transfer.previousOwnerPaneKey === transfer.ownerPaneKey) {
|
||||
return
|
||||
}
|
||||
const from = transfer.previousOwnerPaneKey
|
||||
const to = transfer.ownerPaneKey
|
||||
const targetTabId = getTabIdFromPaneKey(to) ?? undefined
|
||||
const targetLeafId = getLeafIdFromPaneKey(to) ?? undefined
|
||||
set((s) => ({
|
||||
agentStatusByPaneKey: movePaneKeyedRecord(s.agentStatusByPaneKey, from, to, (entry) => ({
|
||||
...entry,
|
||||
paneKey: to,
|
||||
tabId: targetTabId
|
||||
})),
|
||||
runtimeAgentOrchestrationByPaneKey: movePaneKeyedRecord(
|
||||
s.runtimeAgentOrchestrationByPaneKey,
|
||||
from,
|
||||
to
|
||||
),
|
||||
retainedAgentsByPaneKey: movePaneKeyedRecord(
|
||||
s.retainedAgentsByPaneKey,
|
||||
from,
|
||||
to,
|
||||
(retained) => ({
|
||||
...retained,
|
||||
entry: { ...retained.entry, paneKey: to, tabId: targetTabId },
|
||||
tab: targetTabId ? { ...retained.tab, id: targetTabId } : retained.tab
|
||||
})
|
||||
),
|
||||
sleepingAgentSessionsByPaneKey: movePaneKeyedRecord(
|
||||
s.sleepingAgentSessionsByPaneKey,
|
||||
from,
|
||||
to,
|
||||
(record) => ({ ...record, paneKey: to, tabId: targetTabId })
|
||||
),
|
||||
agentLaunchConfigByPaneKey: movePaneKeyedRecord(
|
||||
s.agentLaunchConfigByPaneKey,
|
||||
from,
|
||||
to,
|
||||
(entry) => ({
|
||||
...entry,
|
||||
identity: { ...entry.identity, tabId: targetTabId, leafId: targetLeafId }
|
||||
})
|
||||
),
|
||||
acknowledgedAgentsByPaneKey: movePaneKeyedRecord(s.acknowledgedAgentsByPaneKey, from, to),
|
||||
paneForegroundAgentByPaneKey: movePaneKeyedRecord(s.paneForegroundAgentByPaneKey, from, to),
|
||||
unreadTerminalPanes: movePaneKeyedRecord(s.unreadTerminalPanes, from, to),
|
||||
unreadAgentCompletionPanes: movePaneKeyedRecord(s.unreadAgentCompletionPanes, from, to),
|
||||
lastTerminalInputAtByPaneKey: movePaneKeyedRecord(s.lastTerminalInputAtByPaneKey, from, to),
|
||||
cacheTimerByKey: movePaneKeyedRecord(s.cacheTimerByKey, from, to),
|
||||
retentionSuppressedPaneKeys: movePaneKeyedRecord(s.retentionSuppressedPaneKeys, from, to)
|
||||
}))
|
||||
if (typeof window !== 'undefined') {
|
||||
window.api?.agentStatus?.transferPaneAuthority?.({
|
||||
fromPaneKey: from,
|
||||
toPaneKey: to,
|
||||
...(transfer.ptyId ? { ptyId: transfer.ptyId } : {})
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setRuntimeAgentOrchestrationByPaneKey: (entries) => {
|
||||
const generatedTitleUpdates: AgentStatusEntry[] = []
|
||||
set((s) => {
|
||||
@@ -1180,8 +1383,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
},
|
||||
|
||||
setAgentStatus: (paneKey, payload, terminalTitle, timing, routing, metadata) => {
|
||||
paneKey = resolveAgentPaneAuthorityKey(paneKey)
|
||||
const updatedAt = timing?.updatedAt ?? Date.now()
|
||||
if (
|
||||
paneKey in get().recentlyRetiredAgentStatusPaneKeys ||
|
||||
// Why: a closed terminal tab is no longer a valid destination for hook
|
||||
// replays or late status events, even if main still receives them.
|
||||
isRecentlyClosedAgentStatusTab(
|
||||
@@ -1893,6 +2098,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
|
||||
dropAgentStatusByTabPrefix: (tabIdPrefix, opts) => {
|
||||
const prefix = `${tabIdPrefix}:`
|
||||
const retiredAliasPaneKeys = retireAgentPaneAuthorityAliasesByOwnerTab(tabIdPrefix)
|
||||
let hadLive = false
|
||||
set((s) => {
|
||||
const completedOrphanKeys = findCompletedOrphanPaneKeysForTabClose(
|
||||
@@ -1932,6 +2138,10 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
s.recentlyClosedAgentStatusTabIds,
|
||||
tabIdPrefix
|
||||
)
|
||||
const nextRetiredPaneKeys = boundRecentlyRetiredAgentStatusPaneKeys(
|
||||
s.recentlyRetiredAgentStatusPaneKeys,
|
||||
retiredAliasPaneKeys
|
||||
)
|
||||
|
||||
if (
|
||||
liveKeys.length === 0 &&
|
||||
@@ -1942,10 +2152,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
if (nextAck !== s.acknowledgedAgentsByPaneKey) {
|
||||
return {
|
||||
acknowledgedAgentsByPaneKey: nextAck,
|
||||
recentlyClosedAgentStatusTabIds: nextClosedTabs
|
||||
recentlyClosedAgentStatusTabIds: nextClosedTabs,
|
||||
recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys
|
||||
}
|
||||
}
|
||||
return { recentlyClosedAgentStatusTabIds: nextClosedTabs }
|
||||
return {
|
||||
recentlyClosedAgentStatusTabIds: nextClosedTabs,
|
||||
recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys
|
||||
}
|
||||
}
|
||||
hadLive = liveKeys.length > 0
|
||||
|
||||
@@ -2004,6 +2218,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
migrationUnsupportedByPtyId: migrationUnsupported.next,
|
||||
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
|
||||
recentlyClosedAgentStatusTabIds: nextClosedTabs,
|
||||
recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys,
|
||||
...(nextAck !== s.acknowledgedAgentsByPaneKey
|
||||
? { acknowledgedAgentsByPaneKey: nextAck }
|
||||
: {}),
|
||||
|
||||
@@ -108,7 +108,7 @@ export type TabsSlice = {
|
||||
activateTab: (tabId: string, opts?: { preservePreview?: boolean }) => void
|
||||
closeUnifiedTab: (
|
||||
tabId: string,
|
||||
opts?: { recordInteraction?: boolean }
|
||||
opts?: { recordInteraction?: boolean; terminalRetirementHandled?: boolean }
|
||||
) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null
|
||||
reorderUnifiedTabs: (
|
||||
groupId: string,
|
||||
@@ -899,6 +899,15 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
return null
|
||||
}
|
||||
|
||||
if (tab.contentType === 'terminal' && !opts?.terminalRetirementHandled) {
|
||||
const dedupedGroupOrder = dedupeTabOrder(group.tabOrder)
|
||||
const wasLastTab = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId)).length === 0
|
||||
// Why: unified-only hydrated tabs still own provider sessions even when
|
||||
// their legacy terminal row is missing, so every terminal close retires by entity id.
|
||||
get().closeTab(tab.entityId, { recordInteraction: opts?.recordInteraction })
|
||||
return { closedTabId: tabId, wasLastTab, worktreeId }
|
||||
}
|
||||
|
||||
const dedupedGroupOrder = dedupeTabOrder(group.tabOrder)
|
||||
const remainingOrder = dedupeTabOrder(dedupedGroupOrder.filter((id) => id !== tabId))
|
||||
const wasLastTab = remainingOrder.length === 0
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolveWindowsShiftEnterEncodingForPane } from '@/components/terminal-p
|
||||
import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers'
|
||||
|
||||
describe('syncPaneDetachPtyOwnership agent identity', () => {
|
||||
it('moves process and launch identity to the detached leaf without forging hook identity', () => {
|
||||
it('moves process, hook, launch, and resume authority to the detached leaf', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo::/repo/worktree'
|
||||
const sourceTabId = 'tab-source'
|
||||
@@ -49,6 +49,21 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
|
||||
prompt: '',
|
||||
agentType: 'droid'
|
||||
})
|
||||
store.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[sourcePaneKey]: {
|
||||
paneKey: sourcePaneKey,
|
||||
tabId: sourceTabId,
|
||||
worktreeId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().syncPaneDetachPtyOwnership({
|
||||
detachedLeafId,
|
||||
@@ -76,11 +91,19 @@ describe('syncPaneDetachPtyOwnership agent identity', () => {
|
||||
tabId: targetTabId,
|
||||
leafId: detachedLeafId
|
||||
})
|
||||
// Why: the PTY keeps emitting its immutable source ORCA_PANE_KEY. A copied
|
||||
// target hook row would look live but could never receive later updates.
|
||||
expect(state.agentStatusByPaneKey[sourcePaneKey]).toBeUndefined()
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBe(true)
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toMatchObject({
|
||||
paneKey: targetPaneKey,
|
||||
tabId: targetTabId,
|
||||
state: 'working'
|
||||
})
|
||||
expect(state.sleepingAgentSessionsByPaneKey[sourcePaneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
|
||||
paneKey: targetPaneKey,
|
||||
tabId: targetTabId,
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
})
|
||||
expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined()
|
||||
expect(state.paneForegroundAgentByPaneKey[siblingPaneKey]).toEqual({
|
||||
agent: 'antigravity',
|
||||
shellForeground: false
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
getTerminalPtyOwnershipIdentity,
|
||||
type TerminalTabRetirementPlan,
|
||||
type TerminalTabRetirementState
|
||||
} from './terminal-tab-retirement'
|
||||
|
||||
export function reserveTerminalRetirementTeardowns(
|
||||
state: TerminalTabRetirementState,
|
||||
plan: TerminalTabRetirementPlan,
|
||||
scheduledPtyOwners: Set<string>
|
||||
): { plan: TerminalTabRetirementPlan; newlyScheduledPtyOwners: string[] } {
|
||||
const cleanupOnlyPtyIds = new Set(plan.cleanupOnlyPtyIds)
|
||||
const newlyScheduledPtyOwners: string[] = []
|
||||
const reserve = (ptyId: string): boolean => {
|
||||
const owner = getTerminalPtyOwnershipIdentity(state, ptyId, plan.worktreeId)
|
||||
if (scheduledPtyOwners.has(owner)) {
|
||||
cleanupOnlyPtyIds.add(ptyId)
|
||||
return false
|
||||
}
|
||||
scheduledPtyOwners.add(owner)
|
||||
newlyScheduledPtyOwners.push(owner)
|
||||
return true
|
||||
}
|
||||
return {
|
||||
plan: {
|
||||
...plan,
|
||||
localOrSshPtyIds: plan.localOrSshPtyIds.filter(reserve),
|
||||
runtimeTerminals: plan.runtimeTerminals.filter((terminal) => reserve(terminal.ptyId)),
|
||||
cleanupOnlyPtyIds: [...cleanupOnlyPtyIds]
|
||||
},
|
||||
newlyScheduledPtyOwners
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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,
|
||||
makeTabGroup,
|
||||
makeUnifiedTab,
|
||||
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('preserves shared-owner snapshots while closing the source tab', async () => {
|
||||
const store = createTestStore()
|
||||
const snapshot = { snapshot: 'shared snapshot' }
|
||||
const coldRestore = { scrollback: 'shared scrollback', cwd: 'C:\\workspace' }
|
||||
seedStore(store, {
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeTab({ id: 'tab-1', worktreeId: 'wt-1', ptyId: 'pty-shared' }),
|
||||
makeTab({ id: 'tab-2', worktreeId: 'wt-1', ptyId: 'pty-shared' })
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-shared'], 'tab-2': ['pty-shared'] },
|
||||
pendingSnapshotByPtyId: { 'pty-shared': snapshot },
|
||||
pendingColdRestoreByPtyId: { 'pty-shared': coldRestore }
|
||||
})
|
||||
|
||||
store.getState().closeTab('tab-1')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockKill).not.toHaveBeenCalled()
|
||||
expect(store.getState().pendingSnapshotByPtyId['pty-shared']).toBe(snapshot)
|
||||
expect(store.getState().pendingColdRestoreByPtyId['pty-shared']).toBe(coldRestore)
|
||||
})
|
||||
|
||||
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('retires a unified-only terminal instead of removing only its wrapper', async () => {
|
||||
const store = createTestStore()
|
||||
const unified = makeUnifiedTab({
|
||||
id: 'unified-tab-1',
|
||||
entityId: 'terminal-tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1'
|
||||
})
|
||||
seedStore(store, {
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
unifiedTabsByWorktree: { 'wt-1': [unified] },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
makeTabGroup({
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: unified.id,
|
||||
tabOrder: [unified.id]
|
||||
})
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'terminal-tab-1': ['pty-unified-only'] }
|
||||
})
|
||||
|
||||
store.getState().closeUnifiedTab(unified.id)
|
||||
await vi.waitFor(() => expect(mockKill).toHaveBeenCalledWith('pty-unified-only'))
|
||||
|
||||
expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([])
|
||||
expect(store.getState().ptyIdsByTabId['terminal-tab-1']).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,270 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import {
|
||||
buildTerminalTabRetirementPlan,
|
||||
buildTerminalTabRetirementPlans,
|
||||
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'
|
||||
}
|
||||
],
|
||||
cleanupOnlyPtyIds: [],
|
||||
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('protects a scoped runtime terminal referenced through its legacy alias', () => {
|
||||
const scoped = 'remote:env-1@@terminal-1'
|
||||
const legacy = 'remote:terminal-1'
|
||||
const state = makeState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab('tab-1', 'wt-1', legacy)],
|
||||
'wt-2': [makeTab('tab-2', 'wt-2', scoped)]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [legacy], 'tab-2': [scoped] }
|
||||
})
|
||||
|
||||
const plan = buildTerminalTabRetirementPlan(state, 'tab-1')
|
||||
expect(plan.sharedPtyIds).toEqual([legacy])
|
||||
expect(plan.runtimeTerminals).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates legacy and scoped aliases owned by the closing tab', () => {
|
||||
const state = makeState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab('tab-1', 'wt-1', 'remote:terminal-1')]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['remote:terminal-1', 'remote:env-1@@terminal-1']
|
||||
}
|
||||
})
|
||||
|
||||
expect(buildTerminalTabRetirementPlan(state, 'tab-1').runtimeTerminals).toEqual([
|
||||
{
|
||||
ptyId: 'remote:terminal-1',
|
||||
environmentId: null,
|
||||
handle: 'terminal-1'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
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([])
|
||||
})
|
||||
|
||||
it('deduplicates batch-owned PTYs while protecting owners outside the close set', () => {
|
||||
const state = makeState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeTab('tab-1', 'wt-1', 'pty-batch'),
|
||||
makeTab('tab-2', 'wt-1', 'pty-batch'),
|
||||
makeTab('later-tab', 'wt-1', 'pty-external')
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-batch', 'pty-external'],
|
||||
'tab-2': ['pty-batch'],
|
||||
'later-tab': ['pty-external']
|
||||
}
|
||||
})
|
||||
|
||||
const plans = buildTerminalTabRetirementPlans(state, ['tab-1', 'tab-2'])
|
||||
|
||||
expect(plans.get('tab-1')).toMatchObject({
|
||||
localOrSshPtyIds: ['pty-batch'],
|
||||
sharedPtyIds: ['pty-external']
|
||||
})
|
||||
expect(plans.get('tab-2')).toMatchObject({
|
||||
localOrSshPtyIds: [],
|
||||
cleanupOnlyPtyIds: ['pty-batch'],
|
||||
sharedPtyIds: []
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes live owners once for a 100-tab batch', () => {
|
||||
const tabs = Array.from({ length: 100 }, (_, index) =>
|
||||
makeTab(`tab-${index}`, 'wt-1', `pty-${index}`)
|
||||
)
|
||||
let terminalStoreScans = 0
|
||||
let unifiedStoreScans = 0
|
||||
const state = makeState({
|
||||
tabsByWorktree: new Proxy(
|
||||
{ 'wt-1': tabs },
|
||||
{
|
||||
ownKeys(target) {
|
||||
terminalStoreScans += 1
|
||||
return Reflect.ownKeys(target)
|
||||
}
|
||||
}
|
||||
),
|
||||
unifiedTabsByWorktree: new Proxy(
|
||||
{},
|
||||
{
|
||||
ownKeys(target) {
|
||||
unifiedStoreScans += 1
|
||||
return Reflect.ownKeys(target)
|
||||
}
|
||||
}
|
||||
),
|
||||
ptyIdsByTabId: Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]]))
|
||||
})
|
||||
|
||||
const plans = buildTerminalTabRetirementPlans(
|
||||
state,
|
||||
tabs.map((tab) => tab.id)
|
||||
)
|
||||
|
||||
expect(plans).toHaveLength(100)
|
||||
expect(terminalStoreScans).toBe(1)
|
||||
expect(unifiedStoreScans).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
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,221 @@
|
||||
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
|
||||
import type { AppState } from '../types'
|
||||
import {
|
||||
getRuntimeEnvironmentIdForWorktree,
|
||||
type WorktreeRuntimeOwnerState
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
|
||||
export type TerminalTabCloseReason = 'user' | 'cleanup' | 'pty-exit'
|
||||
|
||||
export type TerminalTabRetirementState = WorktreeRuntimeOwnerState &
|
||||
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
|
||||
}[]
|
||||
cleanupOnlyPtyIds: string[]
|
||||
sharedPtyIds: string[]
|
||||
unroutablePtyIds: string[]
|
||||
}
|
||||
|
||||
function appendPtyId(ids: Set<string>, ptyId: string | null | undefined): void {
|
||||
if (ptyId) {
|
||||
ids.add(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTerminalPtyOwnershipIdentity(
|
||||
state: TerminalTabRetirementState,
|
||||
ptyId: string,
|
||||
worktreeId: string | null
|
||||
): string {
|
||||
const remote = parseRemoteRuntimePtyId(ptyId)
|
||||
if (!remote?.handle) {
|
||||
return `pty:${ptyId}`
|
||||
}
|
||||
// Why: hydrated legacy runtime IDs omit their owner, but still refer to the
|
||||
// same provider terminal as a scoped ID in the owning worktree.
|
||||
const environmentId =
|
||||
remote.environmentId?.trim() || getRuntimeEnvironmentIdForWorktree(state, worktreeId) || ''
|
||||
return JSON.stringify(['runtime', environmentId, remote.handle])
|
||||
}
|
||||
|
||||
function collectPtyIdsForTab(
|
||||
state: TerminalTabRetirementState,
|
||||
tabId: string,
|
||||
rowPtyId: string | null | undefined
|
||||
): string[] {
|
||||
const ids = new Set<string>()
|
||||
for (const ptyId of state.ptyIdsByTabId[tabId] ?? []) {
|
||||
appendPtyId(ids, ptyId)
|
||||
}
|
||||
appendPtyId(ids, rowPtyId)
|
||||
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 collectLiveTerminalTabs(
|
||||
state: TerminalTabRetirementState
|
||||
): Map<string, { worktreeId: string; rowPtyId: string | null }> {
|
||||
const liveTabs = new Map<string, { worktreeId: string; rowPtyId: string | null }>()
|
||||
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
liveTabs.set(tab.id, { worktreeId, rowPtyId: tab.ptyId })
|
||||
}
|
||||
}
|
||||
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'terminal' && !liveTabs.has(tab.entityId)) {
|
||||
liveTabs.set(tab.entityId, { worktreeId, rowPtyId: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
return liveTabs
|
||||
}
|
||||
|
||||
function hasOwnerOutsideTargets(
|
||||
ownerTabIds: ReadonlySet<string> | undefined,
|
||||
targetIds: ReadonlySet<string>
|
||||
): boolean {
|
||||
for (const ownerTabId of ownerTabIds ?? []) {
|
||||
if (!targetIds.has(ownerTabId)) {
|
||||
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 {
|
||||
return buildTerminalTabRetirementPlans(state, [tabId]).get(tabId)!
|
||||
}
|
||||
|
||||
export function buildTerminalTabRetirementPlans(
|
||||
state: TerminalTabRetirementState,
|
||||
tabIds: readonly string[]
|
||||
): Map<string, TerminalTabRetirementPlan> {
|
||||
const targetIds = [...new Set(tabIds)]
|
||||
const targetIdSet = new Set(targetIds)
|
||||
const liveTabs = collectLiveTerminalTabs(state)
|
||||
const ptyIdsByLiveTab = new Map<string, string[]>()
|
||||
const ownerTabIdsByIdentity = new Map<string, Set<string>>()
|
||||
|
||||
// Why: bulk close must not rebuild the live-owner index after every tab;
|
||||
// one snapshot keeps planning linear while preserving shared-surface safety.
|
||||
for (const [tabId, owner] of liveTabs) {
|
||||
const ptyIds = collectPtyIdsForTab(state, tabId, owner.rowPtyId)
|
||||
ptyIdsByLiveTab.set(tabId, ptyIds)
|
||||
for (const ptyId of ptyIds) {
|
||||
const identity = getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId)
|
||||
const owners = ownerTabIdsByIdentity.get(identity) ?? new Set<string>()
|
||||
owners.add(tabId)
|
||||
ownerTabIdsByIdentity.set(identity, owners)
|
||||
}
|
||||
}
|
||||
|
||||
const plans = new Map<string, TerminalTabRetirementPlan>()
|
||||
const scheduledPtyOwners = new Set<string>()
|
||||
for (const tabId of targetIds) {
|
||||
const owner = liveTabs.get(tabId)
|
||||
const worktreeId = owner?.worktreeId ?? null
|
||||
const ptyIds =
|
||||
ptyIdsByLiveTab.get(tabId) ?? collectPtyIdsForTab(state, tabId, owner?.rowPtyId ?? null)
|
||||
const sharedPtyIds: string[] = []
|
||||
const localOrSshPtyIds: string[] = []
|
||||
const runtimeTerminals: TerminalTabRetirementPlan['runtimeTerminals'] = []
|
||||
const cleanupOnlyPtyIds: string[] = []
|
||||
const unroutablePtyIds: string[] = []
|
||||
|
||||
for (const ptyId of ptyIds) {
|
||||
const ownerIdentity = getTerminalPtyOwnershipIdentity(state, ptyId, worktreeId)
|
||||
const ownerTabIds = ownerTabIdsByIdentity.get(ownerIdentity)
|
||||
if (hasOwnerOutsideTargets(ownerTabIds, targetIdSet)) {
|
||||
sharedPtyIds.push(ptyId)
|
||||
continue
|
||||
}
|
||||
if (scheduledPtyOwners.has(ownerIdentity)) {
|
||||
// Why: another closing tab already owns provider teardown, but this
|
||||
// tab can still hold alias-keyed snapshots that must be discarded.
|
||||
cleanupOnlyPtyIds.push(ptyId)
|
||||
continue
|
||||
}
|
||||
scheduledPtyOwners.add(ownerIdentity)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
plans.set(tabId, {
|
||||
tabId,
|
||||
worktreeId,
|
||||
ptyIds,
|
||||
localOrSshPtyIds,
|
||||
runtimeTerminals,
|
||||
cleanupOnlyPtyIds,
|
||||
sharedPtyIds,
|
||||
unroutablePtyIds
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
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,13 @@ import {
|
||||
removeSleepingRecordsReplacedByManualWorktreeSleep,
|
||||
type AgentStatusWorktreeShutdownReason
|
||||
} from './agent-status'
|
||||
import {
|
||||
buildTerminalTabRetirementPlan,
|
||||
isTerminalTabPresent,
|
||||
removeSleepingAgentSessionsForTab,
|
||||
type TerminalTabCloseReason,
|
||||
type TerminalTabRetirementPlan
|
||||
} from './terminal-tab-retirement'
|
||||
|
||||
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
|
||||
const usedOrdinals = new Set<number>()
|
||||
@@ -593,7 +603,16 @@ 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
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
precomputedRetirementPlan?: TerminalTabRetirementPlan
|
||||
}
|
||||
) => void
|
||||
reorderTabs: (worktreeId: string, tabIds: string[]) => void
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
@@ -1197,18 +1216,69 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
},
|
||||
|
||||
closeTab: (tabId, opts) => {
|
||||
const closeReason = opts?.reason ?? 'user'
|
||||
const retiresSession = closeReason === 'user' || closeReason === 'cleanup'
|
||||
const retirementPlan =
|
||||
opts?.precomputedRetirementPlan?.tabId === tabId
|
||||
? opts.precomputedRetirementPlan
|
||||
: 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>[] = opts?.localPtyTeardownOwnedExternally
|
||||
? []
|
||||
: 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
|
||||
for (const wId of Object.keys(next)) {
|
||||
const before = next[wId]
|
||||
const closingTab = before.find((t) => t.id === tabId)
|
||||
if (closingTab) {
|
||||
closingWorktreeId = wId
|
||||
if (!closingPtyId) {
|
||||
closingPtyId = closingTab.ptyId ?? null
|
||||
}
|
||||
}
|
||||
const after = before.filter((t) => t.id !== tabId)
|
||||
if (after.length !== before.length) {
|
||||
@@ -1225,6 +1295,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 +1334,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 +1384,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.cleanupOnlyPtyIds,
|
||||
...retirementPlan.unroutablePtyIds
|
||||
])
|
||||
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 +1407,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.
|
||||
@@ -1382,7 +1470,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
|
||||
)
|
||||
if (workspaceItem) {
|
||||
get().closeUnifiedTab(workspaceItem.id, opts)
|
||||
get().closeUnifiedTab(workspaceItem.id, {
|
||||
recordInteraction: opts?.recordInteraction,
|
||||
terminalRetirementHandled: true
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1872,6 +1963,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)
|
||||
@@ -2800,6 +2896,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
sourceTabId,
|
||||
targetTabId
|
||||
}) => {
|
||||
const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId)
|
||||
const targetPaneKey = makePaneKey(targetTabId, detachedLeafId)
|
||||
set((s) => {
|
||||
const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {}))
|
||||
const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter(
|
||||
@@ -2839,62 +2937,19 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId)
|
||||
: sourceTabsByWorktree
|
||||
|
||||
const sourcePaneKey = makePaneKey(sourceTabId, detachedLeafId)
|
||||
const targetPaneKey = makePaneKey(targetTabId, detachedLeafId)
|
||||
const sourceForeground = s.paneForegroundAgentByPaneKey[sourcePaneKey]
|
||||
const sourceLaunchConfig = s.agentLaunchConfigByPaneKey[sourcePaneKey]
|
||||
const hadSourceHookStatus = sourcePaneKey in s.agentStatusByPaneKey
|
||||
let nextPaneForegroundAgentByPaneKey = s.paneForegroundAgentByPaneKey
|
||||
let nextAgentLaunchConfigByPaneKey = s.agentLaunchConfigByPaneKey
|
||||
let nextAgentStatusByPaneKey = s.agentStatusByPaneKey
|
||||
let nextRetentionSuppressedPaneKeys = s.retentionSuppressedPaneKeys
|
||||
if (sourceForeground) {
|
||||
nextPaneForegroundAgentByPaneKey = { ...s.paneForegroundAgentByPaneKey }
|
||||
delete nextPaneForegroundAgentByPaneKey[sourcePaneKey]
|
||||
nextPaneForegroundAgentByPaneKey[targetPaneKey] = sourceForeground
|
||||
}
|
||||
if (sourceLaunchConfig) {
|
||||
nextAgentLaunchConfigByPaneKey = { ...s.agentLaunchConfigByPaneKey }
|
||||
delete nextAgentLaunchConfigByPaneKey[sourcePaneKey]
|
||||
nextAgentLaunchConfigByPaneKey[targetPaneKey] = {
|
||||
...sourceLaunchConfig,
|
||||
identity: {
|
||||
...sourceLaunchConfig.identity,
|
||||
tabId: targetTabId,
|
||||
leafId: detachedLeafId
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hadSourceHookStatus) {
|
||||
nextAgentStatusByPaneKey = { ...s.agentStatusByPaneKey }
|
||||
delete nextAgentStatusByPaneKey[sourcePaneKey]
|
||||
nextRetentionSuppressedPaneKeys = {
|
||||
...s.retentionSuppressedPaneKeys,
|
||||
[sourcePaneKey]: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ptyIdsByTabId: nextPtyIdsByTabId,
|
||||
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
|
||||
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}),
|
||||
...(nextPaneForegroundAgentByPaneKey !== s.paneForegroundAgentByPaneKey
|
||||
? { paneForegroundAgentByPaneKey: nextPaneForegroundAgentByPaneKey }
|
||||
: {}),
|
||||
...(nextAgentLaunchConfigByPaneKey !== s.agentLaunchConfigByPaneKey
|
||||
? { agentLaunchConfigByPaneKey: nextAgentLaunchConfigByPaneKey }
|
||||
: {}),
|
||||
...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey
|
||||
? {
|
||||
// Why: the PTY keeps its immutable source ORCA_PANE_KEY; retire
|
||||
// rather than re-key a hook row that future events cannot update.
|
||||
agentStatusByPaneKey: nextAgentStatusByPaneKey,
|
||||
agentStatusEpoch: s.agentStatusEpoch + 1,
|
||||
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys
|
||||
}
|
||||
: {})
|
||||
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {})
|
||||
}
|
||||
})
|
||||
// Why: detach keeps the process and its immutable physical pane key alive;
|
||||
// move resume/status authority to the new surface before the source can close.
|
||||
get().transferAgentPaneAuthority({
|
||||
fromPaneKey: sourcePaneKey,
|
||||
toPaneKey: targetPaneKey,
|
||||
ptyId: detachedPtyId
|
||||
})
|
||||
},
|
||||
|
||||
queueTabStartupCommand: (tabId, startup) => {
|
||||
|
||||
@@ -745,7 +745,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
onMigrationUnsupportedClear: () => noopUnsubscribe,
|
||||
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
|
||||
drop: () => {},
|
||||
dropByTabPrefix: () => {}
|
||||
dropByTabPrefix: () => {},
|
||||
retirePaneAuthority: () => {},
|
||||
transferPaneAuthority: () => {}
|
||||
},
|
||||
mobile: {
|
||||
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
|
||||
@@ -2804,7 +2806,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
clearPendingPaneSerializer: () => Promise.resolve(),
|
||||
management: {
|
||||
listSessions: () => Promise.resolve({ sessions: [], degraded: false }),
|
||||
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0 }),
|
||||
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }),
|
||||
killOne: () => Promise.resolve({ success: false }),
|
||||
restart: () => Promise.resolve({ success: false })
|
||||
}
|
||||
|
||||
@@ -155,6 +155,47 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string):
|
||||
state.claudeLeadStateByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
||||
function movePaneScopedMapEntries<T>(
|
||||
map: Map<string, T>,
|
||||
fromPaneKey: string,
|
||||
toPaneKey: string
|
||||
): void {
|
||||
for (const [key, value] of Array.from(map.entries())) {
|
||||
if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) {
|
||||
continue
|
||||
}
|
||||
map.delete(key)
|
||||
map.set(`${toPaneKey}${key.slice(fromPaneKey.length)}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
function movePaneScopedSetEntries(set: Set<string>, fromPaneKey: string, toPaneKey: string): void {
|
||||
for (const key of Array.from(set)) {
|
||||
if (key !== fromPaneKey && !key.startsWith(`${fromPaneKey}\0`)) {
|
||||
continue
|
||||
}
|
||||
set.delete(key)
|
||||
set.add(`${toPaneKey}${key.slice(fromPaneKey.length)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function movePaneCacheState(
|
||||
state: HookListenerState,
|
||||
fromPaneKey: string,
|
||||
toPaneKey: string
|
||||
): void {
|
||||
if (fromPaneKey === toPaneKey) {
|
||||
return
|
||||
}
|
||||
movePaneScopedMapEntries(state.lastPromptByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.lastToolByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.lastStatusByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.antigravityCompletedTranscriptByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey)
|
||||
movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey)
|
||||
}
|
||||
|
||||
function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void {
|
||||
state.lastPromptByPaneKey.delete(paneKey)
|
||||
state.lastToolByPaneKey.delete(paneKey)
|
||||
|
||||
@@ -3569,7 +3569,10 @@ export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookR
|
||||
|
||||
export type LegacyPaneKeyAliasEntry = {
|
||||
ptyId: string
|
||||
/** Physical pane key retained by the live process. Field name is persisted
|
||||
* for compatibility; UUID keys are used after pane-to-tab detach. */
|
||||
legacyPaneKey: string
|
||||
/** Current logical owner pane key. May belong to another tab after detach. */
|
||||
stablePaneKey: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
@@ -285,8 +285,12 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
// Why: calling window.api.repos.add() goes through the same code path as
|
||||
// the "Add Project" UI flow, ensuring worktrees are fetched and the session
|
||||
// initializes properly.
|
||||
await page.evaluate(async (repoPath) => {
|
||||
await window.api.repos.add({ path: repoPath })
|
||||
const seededRepoId = await page.evaluate(async (repoPath) => {
|
||||
const result = await window.api.repos.add({ path: repoPath })
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
return result.repo.id
|
||||
}, repoPath)
|
||||
|
||||
// Fetch repos in the renderer store so it picks up the new repo, then opt
|
||||
@@ -300,13 +304,13 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
await playwrightExpect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async (repoPath) => {
|
||||
page.evaluate(async (repoId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
await store.getState().fetchRepos()
|
||||
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
const repo = store.getState().repos.find((candidate) => candidate.id === repoId)
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
@@ -314,7 +318,7 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
// repos hide those by default after the visibility rollout.
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
return true
|
||||
}, repoPath),
|
||||
}, seededRepoId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: `Expected e2e repo to be loaded: ${repoPath}`
|
||||
@@ -322,21 +326,18 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Best-effort fetch of every repo's worktrees. Why: the renderer can still
|
||||
// Best-effort fetch of the seeded repo's worktrees. Why: the renderer can still
|
||||
// re-navigate during initial hydration and destroy the execution context
|
||||
// mid-evaluate; the authoritative seeded-worktree poll below is the real wait,
|
||||
// so swallow a hydration-reload failure here instead of failing setup.
|
||||
await page
|
||||
.evaluate(async () => {
|
||||
.evaluate(async (repoId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
const repos = store.getState().repos
|
||||
for (const repo of repos) {
|
||||
await store.getState().fetchWorktrees(repo.id)
|
||||
}
|
||||
})
|
||||
await store.getState().fetchWorktrees(repoId)
|
||||
}, seededRepoId)
|
||||
.catch(() => false)
|
||||
|
||||
// Why: parallel specs mutate real git worktrees in the shared fixture repo.
|
||||
@@ -345,18 +346,14 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
await playwrightExpect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async (repoPath) => {
|
||||
page.evaluate(async (repoId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return 0
|
||||
}
|
||||
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
if (!repo) {
|
||||
return 0
|
||||
}
|
||||
await store.getState().fetchWorktrees(repo.id)
|
||||
return store.getState().worktreesByRepo[repo.id]?.length ?? 0
|
||||
}, repoPath),
|
||||
await store.getState().fetchWorktrees(repoId)
|
||||
return store.getState().worktreesByRepo[repoId]?.length ?? 0
|
||||
}, seededRepoId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: 'seeded e2e worktrees did not load'
|
||||
@@ -378,21 +375,22 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
// Why: workspaceSessionReady restoration can overwrite activeWorktreeId
|
||||
// after earlier setup calls. Selecting it here ensures every test starts on
|
||||
// the seeded repo instead of the "Select a worktree" empty state.
|
||||
await page.evaluate((repoPath: string) => {
|
||||
await page.evaluate((repoId: string) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
||||
const testWorktree = allWorktrees.find(
|
||||
(worktree) => worktree.path === repoPath || worktree.path.startsWith(repoPath)
|
||||
// Why: provider-returned identity is stable across Windows path casing
|
||||
// and separator normalization, unlike comparing renderer path strings.
|
||||
const testWorktree = state.worktreesByRepo[repoId]?.find(
|
||||
(worktree) => worktree.isMainWorktree
|
||||
)
|
||||
if (testWorktree) {
|
||||
state.setActiveWorktree(testWorktree.id)
|
||||
}
|
||||
}, repoPath)
|
||||
}, seededRepoId)
|
||||
|
||||
// Best-effort seed of a baseline terminal tab when a fresh isolated
|
||||
// profile has none yet.
|
||||
|
||||
@@ -130,36 +130,40 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P
|
||||
throw new Error(`attachRepoAndOpenTerminal: ${repoPath} is not a git repo`)
|
||||
}
|
||||
|
||||
await page.evaluate(async (repoPath) => {
|
||||
await window.api.repos.add({ path: repoPath })
|
||||
}, repoPath)
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
await store.getState().fetchRepos()
|
||||
})
|
||||
|
||||
const repoId = await page.evaluate(async (repoPath) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
const result = await window.api.repos.add({ path: repoPath })
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
// Why: this restart fixture uses the global e2e repo, whose seeded Git
|
||||
// worktree is external to Orca's workspace root after the visibility rollout.
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
return repo.id
|
||||
return result.repo.id
|
||||
}, repoPath)
|
||||
|
||||
if (!repoId) {
|
||||
throw new Error(`attachRepoAndOpenTerminal: expected e2e repo to be loaded: ${repoPath}`)
|
||||
}
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async (repoId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
// Why: repos.add emits a concurrent refresh whose generation can
|
||||
// supersede this fetch; poll until either refresh publishes the repo.
|
||||
await store.getState().fetchRepos()
|
||||
const repo = store.getState().repos.find((candidate) => candidate.id === repoId)
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
// Why: this restart fixture uses the global e2e repo, whose seeded Git
|
||||
// worktree is external to Orca's workspace root after the visibility rollout.
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
return true
|
||||
}, repoId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: `attachRepoAndOpenTerminal: expected e2e repo to be loaded: ${repoPath}`
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await page.waitForFunction(
|
||||
() => window.__store?.getState().workspaceSessionReady === true,
|
||||
@@ -170,10 +174,7 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P
|
||||
// Why: fetchWorktrees() is async. Awaiting the outer page.evaluate returns
|
||||
// before the Zustand worktree slice has observed the hydrated state, so a
|
||||
// single evaluate() that reads worktreesByRepo can see an empty map. Poll
|
||||
// the store until *any* worktree shows up, then pick the one that matches
|
||||
// our seeded repo. Matching by basename is sufficient because each test
|
||||
// run uses a unique tmp-dir suffix, and it avoids symlink-canonicalization
|
||||
// mismatches between the path we pass in and the one the store records.
|
||||
// the store until the seeded repo's worktree shows up.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
@@ -192,28 +193,22 @@ export async function attachRepoAndOpenTerminal(page: Page, repoPath: string): P
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const repoBasename = path.basename(repoPath)
|
||||
const worktreeId = await page.evaluate((repoBasename: string) => {
|
||||
const worktreeId = await page.evaluate((repoId: string) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
}
|
||||
const state = store.getState()
|
||||
const allWorktrees = Object.values(state.worktreesByRepo).flat()
|
||||
// Why: the primary worktree lives at the repo root, so its path ends with
|
||||
// the repo directory name. The secondary worktree is a sibling directory
|
||||
// and will not match this suffix. This gives us the primary deterministically
|
||||
// without depending on boolean fields on the worktree record.
|
||||
const primary =
|
||||
allWorktrees.find(
|
||||
(worktree) => worktree.path.split(/[\\/]+/).findLast(Boolean) === repoBasename
|
||||
) ?? allWorktrees[0]
|
||||
// Why: repo identity remains stable when Windows canonicalizes path casing
|
||||
// or separators between the IPC and renderer layers.
|
||||
const repoWorktrees = state.worktreesByRepo[repoId] ?? []
|
||||
const primary = repoWorktrees.find((worktree) => worktree.isMainWorktree) ?? repoWorktrees[0]
|
||||
if (!primary) {
|
||||
return null
|
||||
}
|
||||
state.setActiveWorktree(primary.id)
|
||||
return primary.id
|
||||
}, repoBasename)
|
||||
}, repoId)
|
||||
|
||||
if (!worktreeId) {
|
||||
throw new Error('attachRepoAndOpenTerminal: test repo did not surface in the store')
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { parkHiddenTabBehindDecoy } from './helpers/terminal-hidden-parking'
|
||||
import {
|
||||
execInTerminal,
|
||||
getTerminalContent,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneIdentitySnapshot
|
||||
} from './helpers/terminal'
|
||||
import { nodeTerminalCommand } from './terminal-node-command'
|
||||
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
})
|
||||
|
||||
async function hasPtySession(page: Page, ptyId: string): Promise<boolean> {
|
||||
return page.evaluate(async (id) => {
|
||||
const sessions = await window.api.pty.listSessions()
|
||||
return sessions.some((session) => session.id === id)
|
||||
}, ptyId)
|
||||
}
|
||||
|
||||
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<void> {
|
||||
const tabId = await page.evaluate((id) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(id, undefined, undefined, { activate: true })
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return tab.id
|
||||
}, worktreeId)
|
||||
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
expect((await waitForPaneIdentitySnapshot(page, 1)).tabId).toBe(tabId)
|
||||
}
|
||||
|
||||
test('closing a parked terminal tab retires its exact PTY session', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
const tabId = snapshot.tabId
|
||||
const ptyId = snapshot.panes[0]?.ptyId
|
||||
if (!ptyId) {
|
||||
throw new Error('active terminal pane did not bind a PTY')
|
||||
}
|
||||
|
||||
const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`)
|
||||
await expect(tab).toBeVisible()
|
||||
|
||||
const marker = `PARKED_CLOSE_READY_${randomUUID()}`
|
||||
const keepAliveScript = `process.stdout.write(${JSON.stringify(`${marker}\n`)}); setInterval(() => {}, 1000)`
|
||||
await execInTerminal(orcaPage, ptyId, nodeTerminalCommand(['-e', keepAliveScript]))
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage), {
|
||||
timeout: 10_000,
|
||||
message: 'long-lived terminal child did not print its ready marker'
|
||||
})
|
||||
.toContain(marker)
|
||||
await expect.poll(() => hasPtySession(orcaPage, ptyId), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
// Why: the most recently hidden tab stays warm, so tab B must take that
|
||||
// exemption before the helper opens decoy tab C and makes tab A parkable.
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, {
|
||||
parkDelayMs: PARKING_DELAY_MS
|
||||
})
|
||||
// Why: parking must remove only the renderer view; otherwise the retirement
|
||||
// assertion could pass because the PTY died before the close action ran.
|
||||
await expect.poll(() => hasPtySession(orcaPage, ptyId), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await orcaPage.evaluate((id) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is unavailable')
|
||||
}
|
||||
store.getState().closeTab(id)
|
||||
}, tabId)
|
||||
|
||||
await expect
|
||||
.poll(() => hasPtySession(orcaPage, ptyId), {
|
||||
timeout: 15_000,
|
||||
message: `parked tab close did not retire PTY ${ptyId}`
|
||||
})
|
||||
.toBe(false)
|
||||
await expect(tab).toHaveCount(0)
|
||||
})
|
||||
Reference in New Issue
Block a user