Commit Graph
878 Commits
Author SHA1 Message Date
Neil 98299d879b fix(terminal): persist a parked remote pane's scrollback across a hard restart (#21295) (#21367)
* fix(terminal): route a parked pane's scrollback patch to the remote host's partition

A park capture changes only terminalLayoutsByTabId, so its debounced session
patch carries no tabsByWorktree. splitWorkspaceSessionByHost built its
tab->worktree index from the patch alone, resolved nothing, and routed every
layout to the 'local' partition, where main's pruneLocalTerminalScrollbackBuffers
strips scrollback it cannot attribute to a remote worktree. The remote host's
runtime:<id> partition never received the capture, so anything parked since the
last clean checkpoint was lost on a crash, SIGKILL, or a forced kill during an
app update (#21295).

Route tab-keyed patch fields with the renderer's live tab catalogs as a fallback
when the payload names no tab rows. Payload rows still win, so full-payload
writes are byte-identical. Once routed to runtime:<id>, main merges the
partition's own prior tabsByWorktree and the prune preserves.

Proven by tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts: a
hard kill (no checkpoint) then relaunch, asserting the capture is in the remote
host's partition on disk. Mutation: reverting the routing fix turns that
assertion red and fails the 3 catalog-dependent unit routing tests.

(cherry picked from commit 58a344c1d4)

* test(terminal): read both scrollback homes in the restart spec, and ratchet the resolver to the cap's home list

The restart spec read only buffersByLeafId, but the ordinary park now writes
localOnlyScrollbackByTabId, so its own proof reported a false zero and both tests failed for the
wrong reason. Both readers now go through resolveLeafScrollbackBuffers: the on-disk reader calls
it directly (it is a pure function), and the store reader — which runs inside page.evaluate —
reaches it through a new window.__terminalParkingDebug.resolveLeafScrollback(tabId) handle.

resolveTabScrollbackBuffers is typed off TERMINAL_SCROLLBACK_SESSION_HOMES and its unit test
enumerates that constant, so adding a third home fails to compile and fails a test until the
resolver reads it — the 'no consumer reads a home directly' invariant becomes enforceable.

The clean-quit control no longer asserts tokenAfterReveal (measured true, true, false on identical
product code; the live host can serve the reveal from its own tail). It keeps the five
deterministic fields and logs the reveal; the hard-kill test still asserts it, because there the
host is forced unavailable and the reveal must come from the client copy.

(cherry picked from commit 0cd3db1489)

* docs(persistence): pin why the local-only scrollback home stays outside full normalization

The two scrollback homes look symmetric (TERMINAL_SCROLLBACK_SESSION_HOMES), so the missing key
reads as an oversight. It is load-bearing: adding it would route the field through the fail-closed
strip and reintroduce the loss this branch fixes. The renderer prunes it with attribution before
the patch is sent, so the cap still holds without main as a second line.

(cherry picked from commit 1092e35b0a)
2026-09-20 22:57:33 -07:00
SahilZ0810andNeil 9324bb8137 fix(editor): preserve Markdown preview when following wiki links (#19790)
* fix(editor): preserve preview when following wiki document links

* test(editor): avoid cast in markdown navigation fixture

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-20 22:52:45 -07:00
SahilZ0810 ffb79c71e0 fix(editor): open plain details blocks in rich markdown mode (#19784)
* fix(editor): allow plain details blocks in rich markdown mode

* fix(editor): preserve case-sensitive details class values
2026-09-20 22:05:30 -07:00
Neil 438744ca77 fix(opencode): preserve global config discovery (#21854) 2026-09-20 17:58:37 -07:00
Neil 72d61c459f fix(e2e): wait for terminal remount after golden worktree switch (#21837)
Mac release goldens failed after switching back to the original worktree:
sidebar aria-current landed while the store still pointed at the child tab,
so waitForActiveTerminalManager timed out. Wait for activeWorktreeId, force
the terminal tab visible, and restore this spec from the workflow ref so
older cut SHAs pick up the harness.
2026-09-20 14:28:03 -07:00
8dee68a8d1 fix(terminal): preserve Polish and Option-composed text in kitty panes (#21082)
* fix(terminal): preserve Option-composed text in kitty panes

Adapt the composition fix from #20579 and the input-source correction from
#20164. Extend coverage to every Polish letter, live setting changes,
associated text, and Chromium-to-PTY word entry.

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>

* test: guard native Korean IME against background launch

---------

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>
2026-09-20 00:28:25 -07:00
Neil 85a3ba6d42 fix(terminal): align CJK IME preedit spacing (#19367)
* fix(terminal): align IME preedit to terminal cell grid

* fix(terminal): preserve native shaping and reuse IME preedit on repaint

* fix(terminal): preserve native shaping with bounded IME spacing runs

* test(terminal): account for inline preedit subpixel rounding

* test(terminal): keep the IME grid fixture wide at every DPI

* chore: regenerate xterm patch after rebase

* test(terminal): remove IME assertion lint findings

* test(terminal): avoid reflective IME fixture access

* test(e2e): run IME renderer matrix with WebGL available

* fix(ci): restore editor line budget
2026-09-19 16:44:38 -07:00
Neil f016d38e9c test(e2e): prove Pi EOF removes sidebar agent row (#21722) 2026-09-19 13:00:46 -07:00
Neil e8a7be4ce2 fix(omp): recover retired pane status with validated restart authority
Merged after fresh run 35448889017 passed all required checks, including static analysis, typecheck, package jobs, all test shards, changed E2E, Docker SSH E2E, and verify.
2026-09-19 08:05:33 -07:00
Neil e7da72c3d7 fix(omp): attach desktop and mobile images through file mentions
Merge fully verified: desktop/mobile focused suites, node and mobile typechecks, changed-code quality, hosted RPC recording pin, package checks, all test shards, and verify pass. This fixes #20389 across composer, drop, picker, and mobile clipboard-accessory paths.
2026-09-19 07:05:20 -07:00
Neil b0ec11f5b0 fix(omp): redact credential references before status transport (#21673) 2026-09-19 05:58:09 -07:00
Neil 5cf65d79fc fix(omp): ignore nested task session ownership (#21663) 2026-09-19 05:25:16 -07:00
NeilandSudoAI-DEV ae9c06c941 feat(omp): discover and switch native-chat models on desktop and mobile (#20612)
* fix(omp): discover and switch native-chat models

Report the running OMP provider/model and discover available choices on
the execution host for desktop and mobile. Register an extension command
to switch through the OMP API because its TUI does not accept /model args.
Advertise that command in status so older hosts remain read-only.

Addresses the OMP portion of #17603; Pi chat enablement remains separate.
Model reporting begins on lifecycle activity; no startup status is invented.

Co-authored-by: SudoAI-DEV <220139811+SudoAI-DEV@users.noreply.github.com>

* refactor(omp): check generated model metadata types

* test(omp): verify model picker command and reported selection

* test(omp): add repeatable real model-switch runtime proof

* test(omp): require model capability delivery in runtime smoke

* fix(mobile): decode OMP model discovery through RPC operations

* fix(omp): preserve exact reported model selectors

* fix(omp): preserve generated extension syntax after rebase

* fix(omp): merge generated harness UI context types

* test(omp): model switching keeps one session manager

* test(omp): include transcript path in model status proof

* test(omp): avoid renderer error-type union

* test(omp): keep renderer test type explicit

---------

Co-authored-by: SudoAI-DEV <220139811+SudoAI-DEV@users.noreply.github.com>
2026-09-19 05:11:48 -07:00
Neil eca2ff6730 test(omp): keep transcript smoke resume proof current (#21661) 2026-09-19 05:10:21 -07:00
latteandNeil bb2afe1792 fix(ai-vault): discover and parse Devin sessions on Windows (#21337)
* fix(ai-vault): discover and parse Devin sessions on Windows, restore workspace mapping

Devin sessions never appeared in the AI Vault on Windows, and parsed
nearly empty elsewhere:

- The transcripts root hardcoded the XDG layout
  (~/.local/share/devin/cli/transcripts), but Devin CLI writes under
  %APPDATA%/devin/cli/transcripts on Windows. The root is now
  platform-aware (APPDATA on win32, XDG_DATA_HOME elsewhere) for both
  local scans and win32 remote hosts, and APPDATA joins the scanner
  child's env allowlist so relocated AppData resolves.
- The parser read metadata.is_user_input / created_at / metrics, which
  real ATIF-v1.7 transcripts don't carry. It now also accepts the ATIF
  step shape (source, timestamp, step-level metrics/model_name,
  plain-string message) while keeping the legacy shape.
- ATIF transcripts carry no working_directory, so sessions couldn't
  group under a workspace. The sibling sessions.db index is now merged
  through the existing sidecar seam: it fills cwd/title/model/
  timestamps, honors the db's hidden flag, and re-merges on db-only
  changes without re-reading transcripts.

* fix(ai-vault): inline Devin transcripts root, harden parser/db edge cases

- Resolve the platform-aware Devin cli dir in agent-sources instead of
  importing the shared devin-cli-data-dir module, which is not part of
  this change (broke typecheck).
- Exclude source:'system' steps unconditionally, even when legacy
  metadata fields would classify them as user/assistant messages.
- Guard unix-seconds conversion against out-of-range values so a single
  bad sessions.db row cannot mark the whole index unreadable.

* fix(ai-vault): watch sessions.db-wal so live Devin metadata cannot go stale

In WAL mode, committed rows sit in sessions.db-wal while sessions.db
keeps its stat until checkpoint, so keying the dependency on the db
alone could serve a stale index. The dependency now observes the wal
when one exists; the reader still opens sessions.db itself.

* fix(ai-vault): probe sessions.db-wal through the WSL-gated stat

existsSync bypasses wslGatedStat and can hang a scan on a stalled 9P
mount; the fs-import guard forbids it in session-scanner modules. The
dependency path resolution is now async and probes through the gate.

* fix(ai-vault): honor zero metrics and array messages in Devin steps

- firstDevinMetricValue skipped explicit numeric zeros, letting a
  lower-priority positive metric win and overstating token totals.
- ATIF allows step.message as an array of content parts; route it
  through extractContentText so those steps still feed title/preview.

* test(ai-vault): cover array-valued ATIF message extraction

The extractDevinStepText fallback that routes an array-valued
step.message through extractContentText shipped without a fixture that
produces that shape, so a future refactor could silently drop the
branch. Pin that an array of text parts feeds the step's title and
preview.

* fix(ai-vault): invalidate old Devin caches and bound database retries

* Discover current Devin ATIF exports alongside legacy transcripts

* Recognize drawn geometry in the browser markup contract test

* Deduplicate Devin exports across transcript roots

* Account for the workspace sleep-state reader in scan budget

* Align OMP integration tests with recorded-path resume

* fix: update scan benchmarks and await relay environment test

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-19 03:32:12 -07:00
Neil ff934256ae fix(omp): retain recorded transcript paths when resuming (#20634)
Based on the resume-locator proposal in stablyai/orca#16276 by @CodeHourra. Retains UUID-based ownership and existing reattach behavior.
2026-09-19 02:02:50 -07:00
Neilandunknown 4dec64d533 feat(source-control-ai): support OMP generation (#20624)
* feat(source-control-ai): support OMP text generation

Read prompts on stdin, retain OMP configured model by default, and reuse JSON model discovery.

Co-authored-by: unknown <1784931579@qq.com>

* test(source-control-ai): cover OMP large input and model overrides

* fix(omp): keep configured model default out of discovered catalog

* fix(omp): hide config default from model discovery catalog

* fix(omp): separate terminal discovery from generation defaults

* test(omp): keep model probe import compatible with CLI typecheck

* test: align Source Control AI registry contracts with OMP

---------

Co-authored-by: unknown <1784931579@qq.com>
2026-09-19 01:54:50 -07:00
Neil ef03188956 fix(sidebar): retain remote OMP rows before tab hydration (#20616)
* fix(sidebar): retain remote OMP rows before tab hydration

* fix(sidebar): sweep host-retracted rows without hydrated tabs

* perf(sidebar): scope mirrored retractions to indexed host tabs

* fix(sidebar): scope status retractions to host identity

* fix(sidebar): retain host identity on mirrored statuses

* chore(ci): refresh validation against fixed main baseline

* fix: scope mirrored tab retractions to owning host panes

* fix(sidebar): preserve foreign pane metadata during host retraction

* fix(sidebar): retain remote workspace attribution after tab hydration
2026-09-19 01:49:50 -07:00
Neil 605a4ef868 fix(omp): start new tasks without auto-resuming old sessions (#20622)
* wip(omp): prove fresh settings overlay without redirecting storage

* fix(omp): guard fresh launches with execution-host settings

* fix(omp): preserve unmodelled shell launch commands

* test(omp): consolidate shell fixture path import

* preserve fresh OMP launch status

* test: cover preserved OMP launch status

* fix: recognize wrapped fresh OMP launches

* chore(ci): refresh validation against fixed main baseline

* fix(omp): recognize generated fresh launch guards across shells

* fix(omp): preserve draft status and clear prefill across Unix shells

* fix(omp): run cmd draft cleanup after either guard branch

* fix(omp): launch drafts safely with nounset enabled

* fix(omp): select draft shell without parser diagnostics

* test(omp): await relay environment augmentation
2026-09-19 01:42:14 -07:00
Neilandstevelliu ea02d90704 fix(omp): answer startup Kitty queries before renderer handoff (#20620)
* fix(omp): answer startup Kitty queries before renderer handoff

Forward actual renderer capability through local and remote spawn. Preserve source ranges and following keyboard mode pushes, and retain independent ConPTY color authority.

Refs #17081. Secondary review: #17082.

Co-authored-by: stevelliu <stevelliu@tencent.com>

* test(omp): cover fragmented keyboard modes and ConPTY handoff

* fix: preserve keyboard startup intent without terminal colors

* fix: negotiate keyboard support for host-authoritative agent launches

* fix: keep terminal creation within line budget

* fix(omp): negotiate keyboard support for paired web launches

* test: remove obsolete message type import after main integration

* fix: validate paired launch results and retry incomplete SSH test snapshots

* fix(omp): negotiate keyboard support for background paired launches

---------

Co-authored-by: stevelliu <stevelliu@tencent.com>
2026-09-19 01:29:16 -07:00
Neil db478bd7d8 fix(omp): keep task storage aligned with the login shell
Verified across macOS, Windows, Linux, local shell/config roots, SSH E2E, changed E2E, package, typecheck, static analysis, and Pullfrog.
2026-09-19 01:17:57 -07:00
Kien LeandKien Le 3af09f824b fix(sidebar): keep group headers visible when measured rows grow (#20883)
Co-authored-by: Kien Le <122910950+kien-ship-it@users.noreply.github.com>
2026-09-19 01:12:47 -07:00
Jinjing a37282bb1d feat(diff-comments): add multi-line range selection with drag and keyboard (#20959)
* feat(diff-comments): add multi-line range selection with drag and keyboa

Enable users to select a range of lines for diff comments by dragging in the gutter, with auto-scroll and visual feedback. Add keyboard shortcut (Mod+Shift+A) to open a comment on the editor selection. Extract line-range logic into testable utilities and add comprehensive test coverage including E2E scenarios.

* feat(diff-comments): handle upward drags and gutter-only presses

- Preserve anchor/focus directionality in selections to handle upward drags correctly
- Restrict gutter presses to line numbers only, leaving fold chevrons to Monaco
- Prevent add-note chord from opening composer while a gutter drag is in progress

* fix diff note range annotation test

* fix(diff-comments): capture release coordinate missed by pointermove

Pointerup can arrive at a position no pointermove event reported, causing fast drags to lose their final lines if the release landed before the animation frame fired. Capture the release coordinate and resolve it before committing the range.

Also: extract resolveFocusLine for clarity, improve test harness to track pointer position, add polling to gutter detection for rendering delays.
2026-09-19 01:04:21 -07:00
Yoann TERUEL 0e1a5f39d0 fix(browser): enable screenshot markup on client-hosted pages
Verified with focused Vitest, web typecheck, changed-file quality/Oxlint, and paired Electron headless E2E.
2026-09-19 01:01:56 -07:00
NeilandBrennan Benson 4d3b0cca87 fix(omp): publish parent transcript paths for native chat (#20615)
* fix(omp): publish owning session transcript paths

Adopt stablyai/orca#19529 on the child-session ownership fence. Preserve id-based resume and execution-host transcript boundaries.

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>

* test(omp): render authoritative transcript reader output

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-09-19 00:47:32 -07:00
Jinjing 58de75acf8 test(e2e): use bounding box for quick-open hover positioning (#21505)
Handle cases where the quick-open result element remounts by tracking
its current bounding box and using absolute mouse positioning instead
of relative row.hover() calls.
2026-09-19 00:46:40 -07:00
Neil 9841f4f790 fix(omp): keep child sessions from reporting into the parent pane (#20611)
* fix(omp): fence pane status to the root session manager

* test(omp): preserve root preview and recovery through child hooks

* test(omp): exercise status ownership through actual runtime runner

* fix(omp): honor runtime subagent provenance when available

* test(omp): avoid writes to the read-only hook status view

* fix(omp): preserve child status ownership provenance

* fix(omp): normalize child transcript paths across platforms

* fix(omp): clean status handler rebase

* fix(omp): keep prefill inside session ownership fence
2026-09-19 00:29:55 -07:00
Neil a913f1b457 fix(omp): resolve transcripts from active XDG and profile roots (#20633)
* fix(omp): resolve XDG session root fallback

* fix(omp): share profile-aware transcript root resolution

* test: verify actual OMP XDG transcript discovery and reading

* test(omp): satisfy reader smoke loop lint

* test(omp): resolve vault fixture paths without redundant cache

* fix(omp): refuse normalized Windows drive-relative roots
2026-09-19 00:17:49 -07:00
Neil 96c0e5ad77 fix(omp): load task drafts through the status extension
* fix(omp): load task prefill through the status extension

* test(omp): capture installed runtime task prefill in hidden Orca
2026-09-19 00:17:31 -07:00
NeilandTim Maximilian Lucas 097677d1da fix(omp): acknowledge completion delivery and retire stale retries
* fix(omp): acknowledge completion delivery and retire stale retries

Co-authored-by: Tim Maximilian Lucas <7103424+timaxlucas@users.noreply.github.com>

* test(omp): exercise completion recovery over native HTTP in platform CI

* test(omp): verify rendered status clears after completion retry

---------

Co-authored-by: Tim Maximilian Lucas <7103424+timaxlucas@users.noreply.github.com>
2026-09-19 00:17:27 -07:00
Neil 277c289bd4 fix(editor): share code-fence boundaries for Markdown links (#21574)
* fix(editor): share fence boundaries for Markdown links

* test(editor): assert highlighted lines across Monaco token spans

* perf(editor): scan fence lines without substring allocations

* fix(editor): match fence suffix whitespace rules
2026-09-19 00:06:56 -07:00
Neil e8a956e833 fix(remote): reassert terminal geometry after reveal (#21568)
* fix(remote): reassert terminal geometry after reveal

* test(remote): expose layout queues through fixture

* test(remote): narrow layout target assertion
2026-09-18 23:35:05 -07:00
Neil 2a53293b11 test(e2e): stop a spec's parking-delay override from leaking into the rest of its worker (#21571)
* test(e2e): scope the parking-delay override to the spec that needs it

A Playwright worker imports many spec files into one Node process, and the app
fixtures launch Electron with a spread of that process's env. The split-
orientation spec set ORCA_E2E_TERMINAL_PARKING_DELAY_MS at module scope, so the
2s override outlived the file and reconfigured every app launched by every spec
that followed it in the same worker — measured directly: a probe spec sees a
30000ms cold-park delay on its own and 2000ms when that spec runs first.

Module scope is the part that cannot be undone. A write inside a test body can
save and restore, as four other specs here do; a write at import time runs
before any hook exists to restore it. test.use({ orcaAppExtraEnv }) reaches the
app launch without touching the worker every other spec shares.

The ratchet holds the module-scope writer count at zero.

* test(e2e): re-apply screen-reader mode while reading the accessibility tree

Separate from the env leak above, and unproven against the CI failure it
resembles: this is robustness, not a diagnosed fix.

screenReaderMode is an option on the xterm instance and the accessibility tree
belongs to that instance's DOM. The SSH cold-activation spec set it once,
imperatively, then waited on the node. A pane that parks and remounts, or
rebinds after a reconnect, comes back as a new instance with the option off, so
the one-shot mutation stops producing the node the wait is waiting for and the
wait reports "element(s) not found" rather than a content mismatch.

The helper re-applies the option inside the poll and returns null when the node
is absent, so a replaced instance is retried instead of being fatal. Five other
specs still use the one-shot pattern and are left alone.
2026-09-18 23:25:52 -07:00
Neil b6f6122bcd fix(terminal): preserve idle Linux IME direct commits (#21567) 2026-09-18 20:51:30 -07:00
Neil 2038376d8e fix(terminal): a park must not discard the only copy of a remote pane's scrollback (#21285)
* fix(terminal): keep a client copy of a parked remote pane's scrollback

A remote-runtime pty's bytes never transit the client's main process, so the pane's
xterm buffer is the only client-side copy. The ordinary cold-park unmounted that pane
without capturing it, licensed by TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY — a static
build string that says nothing about whether the host retained this pty's buffer. On
reveal, a host that answers 'no-serializable-buffer' (or stays silent past the request
timeout) collapses to a null snapshot and the pane paints blank: tabs and splits survive,
the scrollback is gone.

Capture before every park, not only the retention-budget force-park, so the reveal has a
copy to replay when the host cannot answer. An unverifiable host answer is not proof the
pane was empty; keep the buffer, never discard it.

Adds ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE so an e2e can reproduce the
host-retains-nothing state, mirroring the existing forced-truncation lever.

* test(terminal): prove a parked remote pane survives a host that answers nothing

The oracle is a token the test types into the terminal before the park and the fixture
echoes back. Nothing replays stdin, so a respawned command cannot reproduce that line —
only the pre-park buffer can. An earlier argv marker passed vacuously for exactly that
reason.

The control ('host retains the buffer') is insensitive to the fix and fails if the harness
never parks, never reveals, or never echoed the token, so the regression case cannot be
green for a harness reason.

* refactor(terminal): validate the paired host terminal RPC shape instead of casting it

The merge-commit consistent-type-assertions gate flags every new `as`. Two were fixture
shapes that a type annotation states directly, and the third hid an unchecked RPC payload —
readCreatedTerminalTab now fails with the shape named rather than surfacing later as an
undefined surface id.

* fix(terminal): let a park capture survive an unhydrated repo catalog

Reading state.repos unguarded threw out of the cold-park effect whenever the catalog was
absent, which would break parking itself. Capture is best-effort evidence; an empty catalog
also fails open in shouldPreserveTerminalScrollbackBuffers, the safe direction for a park.

* docs(terminal): pin why the two unhydrated-catalog fallbacks point opposite ways

shouldPreserveTerminalScrollbackBuffers fails open toward 'remote' because a worktree wrongly
judged local parks with no copy at all. worktree-runtime-owner.ts resolves the same unhydrated
catalog to 'local', which is safe there and would be data loss here. A reader pattern-matching
'fail open' across the two gets one of them backwards.

* fix(terminal): keep a parked pane's scrollback across a reconnect merge

The direct-SSH pull replaces a replaced tab's layout wholesale, and a park capture does not
bump tab.generation — so a just-parked tab is not in locallyPreservedTabIds and the only
client-side copy of its remote scrollback went with the layout it replaced. That is the same
data loss this branch already fixes, one layer down, and it is the layer that decides whether
the fix survives the app update the user actually performed.

Carry the client's leaf-keyed scrollback into the host's layout, filtered to the host's own
root leaves. Structure stays the host's verbatim, so a split it added while we were away still
wins and a leaf it retired still drops its bytes. Local wins a conflict: neither copy is then
the only one, but remote-wins would overwrite the tail captured since the last upload and
propagate that backwards on the next replace-session patch.

Not a generation bump: the pane key is `${tab.id}-${tab.generation}`, so bumping would remount
the pane and destroy the very buffer the capture just serialized, lift the recovery-storm
ledger ceiling, and let a stale local ptyId win through preserveNewerLocalTerminalFields.

* fix(terminal): carry a parked pane's scrollback through the mirrored-layout rebuild

Found in review of this PR by rc-ssh-remoting. chooseRemoteTerminalLayout rebuilds a
mirrored tab's layout from the host's picture and never carried buffersByLeafId or
scrollbackRefsByLeafId forward, though it already receives existingLayout. The host
publishes no scrollback of its own, so ANY session-inventory frame landing between park and
reveal dropped the only client-side copy: the rebuild is bufferless, terminalLayoutEqual
compares buffers so the write is not bailed out, and apply-terminal-records assigns it
wholesale.

Measured before the fix: 336 bytes captured at park, 0 after one forced frame, blank pane on
reveal. After: 411 bytes survive the frame and the reveal repaints.

The e2e passed either way because no frame happened to land in its window, so it was not
covering the destroying event. It now forces one inside the park -> reveal window and asserts
the capture survives it.

An identical fix was written and reverted earlier in this branch as 'no measurable effect' —
that measurement ran on a harness deleting the client profile between launches, so nothing
downstream of persistence could register. It was never actually tested.

* feat(session): add a local-only home for ordinary-park scrollback

localOnlyScrollbackByTabId is a top-level session field, tabId -> leafId -> buffer, that never
rides the remote projection: exportRemoteWorkspaceSession is an explicit allowlist of named
top-level fields, so a new one is omitted for free, whereas anything added to
TerminalLayoutSnapshot is copied whole. It is also outside the two records the mirrored-tab apply
rewrites, so a host inventory frame cannot wipe it.

Registered in every exhaustive session registry ('tabKeyed'), hydrated and scoped like the layout
map, dropped with its tab on close/removal/purge/repo removal/mirrored retirement, copied on profile
transfer, emitted by the incremental patch builder, and capped by pruneLocalTerminalScrollbackBuffers
alongside the shared home — with a per-home test so an uncapped path cannot go unnoticed.

Known ceiling, not widened here: the field routes through the partition router that falls back to
'local' when the repo catalog is unknown at write time (#21295).

* fix(terminal): keep ordinary-park scrollback off the upload, and read both homes through one resolver

The ordinary cold park fires on every workspace hide. Its capture now splits: structure (root,
ptyIds, titles) stays in the shared layout, bytes go to localOnlyScrollbackByTabId. Force-park,
hibernate, sleep and shutdown keep writing buffersByLeafId, because that copy is what a second
desktop cold-restores from; a shared capture clears the local copy so the two homes never hold two
versions of one leaf.

resolveLeafScrollbackBuffers is the only read across the two homes (local wins a conflict: it is
the later write by construction). restoreTerminalPaneLayout no longer reads buffersByLeafId
directly, the capture's merge prior comes from the resolver, and the post-replay release covers
both homes.

Measured with the projection at 20 tabs x 2 panes at the per-leaf cap: the shared-layout shape
exports ~22 MiB per replace-session; the local-only shape exports the bufferless baseline.

* test(sync): pin that the mirrored rebuild carries the client scrollback refs

The carry-through added in f210dece83 keeps scrollbackRefsByLeafId for leaves the host still
names (a ref is the only pointer to a local scrollback file), so the rebuilt layout equals the
stored one and the write bails. The old assertion expected the refs to be dropped and has been red
on this branch since that commit.

* test(e2e): assert where a park's bytes land, and re-point the inventory-frame check at the force-park

Once ordinary parks stop writing buffersByLeafId, the existing survivedInventoryFrame assertion
passes trivially — there is nothing in the layout to wipe. The ordinary scenarios now assert the
store-level upload contract (bytes in the local-only home, shared home empty) and that the local
home is out of a host frame's reach; a third scenario reaches a force-park (host without paired
parking, client retention limit 1) and asserts the shared capture survives the forced frame, which
is where the mirrored-layout carry-through is load-bearing.

* test(e2e): leave the force-park reveal out of the assertion instead of matching it loosely

expect.any(Boolean) reads as a check but cannot fail. The force-park reveal is served by the host
tail on a host without paired parking, so it is logged for diagnosis and the assertion carries the
three fields that are deterministic on this topology.
2026-09-18 19:49:38 -07:00
09073086a8 feat(terminal): inline images via @xterm/addon-image (perf-first) (#19512)
* feat(terminal): inline images via @xterm/addon-image, perf-first

Add opt-in inline terminal images (SIXEL, iTerm2 IIP, Kitty graphics)
through @xterm/addon-image, designed to keep idle terminals unaffected.

Performance:
- The addon (base64-inlined wasm decoders + protocol handlers) loads off
  the boot critical path via a deferred loader that mirrors the WebGL
  addon: primed after first paint only when the setting is on, read back
  synchronously at attach, with a 3-attempt cap so a transient failure
  never disables images for the session and a missing chunk never
  refetches per pane. renderer-boot-graph guards against eager import.
- enableSizeReports:false so the addon never sets windowOptions and
  double-answers Orca's own CSI 14t/16t responder.
- Perf-tuned decode/storage limits (storageLimit, sixel/iip/kitty size
  caps) in one place.

Correctness:
- Orca's DA1 handler wins over the addon's (last-registered-first), and
  the default DA1 response never advertised Sixel (;4), so DA1-detecting
  tools (chafa, img2sixel, viu, timg) never emitted it. The winning
  handler now appends ;4 while the setting is on, resolved per query so a
  live toggle changes the next DA1; idempotent against the ConPTY
  response that already lists it.
- ORCA_IMAGE_PROTOCOL=kitty is exported to spawned shells (local, daemon,
  relay/SSH) and forwarded across the WSL boundary, so image-capable
  agents can pick an encoder. Unknown image sequences are swallowed by
  xterm when the addon is detached, so this never garbles output.
- Settings toggle (default on) gates rendering and DA1 advertisement.

Cross-checked against community PRs #7775, #11706, and #19201 at the end;
credited below.

Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>

* fix(terminal): bound inline image memory and classify Kitty replies

* fix(terminal): bound image decode and release image resources on cleanup

* fix(terminal): address image addon review feedback

* test(terminal): stub setPaneInlineImagesEnabled in appearance manager fakes

* fix(terminal): evict unplaced kitty payloads before displayed images

Byte-budget eviction dropped the oldest transmitted blob regardless of
placement, so a new upload could erase a visible image while abandoned
blobs still held budget. Unplaced payloads now go first and displayed
ones only when that is not enough. The incoming image is always stored,
so an oversized one overshoots the cap by one payload instead of being
dropped after the protocol already acked OK.

* fix(terminal): gate DA1 Sixel on real addon attachment; claim SSH image spec in CI

- DA1 advertised Sixel from the setting alone, so a pane whose lazy addon
  chunk was still loading (or had failed all three attempts) told
  feature-detecting tools to emit DCS that nothing could render. Track the
  attached decoder per terminal and require it before setting the ;4 bit.
- tests/e2e/terminal-inline-images-ssh.spec.ts was Docker-gated but claimed
  by no lane runner, so pr-e2e-gate-contract failed and the spec would have
  self-skipped green forever.
- Reject non-positive PNG IHDR dimensions before decode: they are parsed with
  signed shifts, so a dimension >= 0x80000000 came back negative and slipped
  past the pixel-limit comparison.
- One resolveTerminalInlineImagesEnabled() for the default-on setting; the
  four call sites mixed '?? true' with '!== false', which disagree on null.
- One readInlineImageResources() walk of the addon internals instead of two
  copies that could drift against the patched dependency.
- Isolate the deferred-attach drain per pane; make the zoom-invariance and
  backing-storage e2e assertions fail when the feature is dead.

* refactor(terminal): one lazy xterm addon loader for webgl and image

terminal-image-addon-loader was a structural clone of the webgl one — same
memo, attempt cap, and .then(ok,err)-clears-memo recovery. Both now wrap
createLazyXtermAddonLoader; each keeps its literal import() specifier so the
bundler still splits the chunk (verified against a fresh build: addon-image
stays out of the boot graph).

* refactor(terminal): name openTerminal's addon flags; pin image addon limits

Two adjacent optional booleans could be swapped without a type error once
inline images added the second one.

* docs(terminal): state the real per-pane image ceiling; drop test ordering dependency

storageLimit:32 reads like the pane's budget but keys three pools — decoded
pixels, retained encoded Kitty blobs, and pending WASM decoders — so the worst
case is ~98 MB per pane with no cross-pane governor. Say so at the constant.

pane-inline-images.test.ts's deferred case needed to run first; it now takes a
fresh module instead, and the rest prime in beforeAll. Verified by running the
file with that test moved last.

* fix(terminal): satisfy rebased static analysis gate

* fix(terminal): complete casting gate cleanup

* fix(terminal): recover failed image addon loads

* fix(terminal): bound image decoder allocations

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: s546126 <s546126@users.noreply.github.com>
Co-authored-by: XRX193 <XRX193@users.noreply.github.com>
Co-authored-by: lmsh7 <lmsh7@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-18 16:32:49 -07:00
Neil 5c2d3322c1 fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped

`buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement
with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose
`paneKey` still parses to its own `tabId` passes that forever, including long
after the session graph dropped the pane, so a terminal that had lost its
surface reported `orphaned: false, connected: true, writable: true` and a
`tabId` no tab has: field-for-field identical to a healthy one (#18191).

Consult the leaf topology instead, gated on a graph statement having had the
standing to contradict the record. `graphSequence` counts authoritative graph
statements; every statement re-records the surface of every pane it publishes,
so a pane the current graph holds carries the current stamp and is answered
without touching the leaf map. That covers the two absences that are not
evidence, without a second flag: a surface recorded since the last statement
(spawn records the pane before the graph carrying it arrives, #7587), and a
lost graph clearing every leaf at once without advancing the sequence. A pane
already observed dropped keeps its stale stamp and stays named, because losing
the ability to re-check is not a reason to un-see it.

`orphaned: true` is shipped vocabulary that both consumers already read, so no
capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and
now reaches this population, and the duplicate-surface index
(`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's
live owner.

* fix(runtime): publish a terminal retirement proof on the exit's own evidence

A paired client may drop a mirrored terminal on exactly two kinds of host
evidence: a `retiredTerminalSurfaces` proof naming the handle, or two
authoritative `terminal.list` inventories that omit it. The second needs two
host publications, and a quiet workspace publishes one, so the proof is the
only evidence that rides the frame carrying the retraction.

That proof was minted only as a byproduct of persistence *accepting a change*,
which made one value carry two meanings: "a change was accepted" and "the PTY
exited". The host renderer's close transaction de-persists the surface and
republishes without it, so when it got there first the exit found nothing left
to accept and the attestation died with it. Measured on a real paired client:
the host retracted in under 500ms, published no proof, then froze its
snapshotVersion for 60s while the client kept a dead pane in its tab bar.

Persistence still gates *removal* — publishing absence before the membership
fence is durable would let a crash resurrect the surface. It no longer gates
the proof: the observed exit is itself the attestation.

The exit-first ordering already had a passing test; the renderer-first ordering
had none, and that is the one users hit. Both orderings are now pinned, with
exit-first as the control that makes the renderer-first failures mean something.

Wire: `retiredTerminalSurfaces` is an existing optional field on an existing
path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is
Rule 1 — an old client that ignores it degrades to the two-inventory route it
already uses today, so no capability gate is needed. The sentence "the host
starts sending a frame it did not send before" reads like Rule 3; it is not,
because the frame shape, the field, and the reader contract are all unchanged.

* test(runtime): pin the removal frame retiring a still-live publisher

KNOWN RED (`it.fails`), no product change. Found while verifying the close
retraction fix: once the emptying actually reaches paired clients — a state the
previous behaviour never allowed, because nothing propagated — re-adoption of a
later create is flaky. Measured 1 failure in 6 runs of the two-client journey.

`decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>`
retraction as a publisher handover: it retires the still-live renderer epoch and
installs the retraction as current, while the removal also clears the live
freshness record. The next frame from that same running publisher then matches
no lineage and reads as a retired generation, so it is outranked and the
publisher is locked out of the worktree until its generation changes.
`local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact
scenario and has a revive escape; the mirror path has none.

The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match,
so a republication carrying `:headless-merge:` walks past the fence and only a
bare same-epoch republication is locked out.

Not fixed here on purpose. Dropping the retirement makes the red case pass but
breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against
delayed predecessor epochs`, which asserts a same-epoch higher-version frame
after a removal must be rejected. At this layer those are the same frame — this
function holds no `receivedFrame`, so it cannot separate a delayed predecessor
from the live publisher speaking again. The fix belongs in
`shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and
currently defers to the same epoch fence. That is a contract change across two
functions and an existing invariant, not a one-liner.

* fix(runtime): a removal retraction is not a publisher handover

The host drops a worktree's entry when its last tab closes and announces it
with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a
publication: `decideWebSessionTabsSnapshot` and
`recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as
current, which pushed the still-live renderer epoch onto `retired`. The removal
also drops the live freshness record, so the next frame from that same running
publisher matched no lineage, read as a retired generation, and was outranked.
The live publisher was locked out of its own worktree until its generation
changed. That is fail-closed, and it is why re-adoption after an emptying was
flaky once the emptying actually reached paired clients.

A retraction and the live publisher's next frame are the same epoch at a higher
version, so epoch identity cannot separate them and never could. Delivery order
can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the
worktree's newest received evidence instead of deleting the ledger, so
`shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply
path passes before `decideWebSessionTabsSnapshot` — fences a frame that
reserved its received frame before the retraction while admitting one that
arrives after it. The boundary carries the retraction's own epoch, which never
matches a host publication, so a later live frame may still restart its version
counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the
same conclusion for the local path: a retired epoch is not proof of a dead
generation.

`keeps a removed worktree fenced against delayed predecessor epochs` pinned the
delayed predecessor at the raw decision layer, which is the same call as the
live publisher's republication. It now pins the identical scenario — same
epoch, higher version, still rejected — through the receive-and-apply path that
actually holds the ordering, plus the composed gate as production spells it.

The committed `it.fails` repro is not sufficient on its own: it records no
received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement
turns it green while the publisher stays locked out on every real path. A
receive-and-apply case is added alongside it to close that gap.

* test(runtime): pin the retraction boundary against a stale inventory omission

Mutation testing left a survivor: writing the boundary unconditionally, instead
of only when it advances the ledger, passed the whole runtime suite. It is not
inert. A visibility-resume inventory reserves its received frame before it
lists, so an omission it reports can be older than a stream frame that landed
meanwhile; without the guard that stale omission rewinds the ledger, forgetting
the stream frame's version, and a delayed list reserved in between is then
readmitted instead of outranked. This pins that ordering.

The one remaining survivor is the boundary's `snapshotVersion`, and it is inert:
the ledger's version is read at exactly two sites, both reachable only when the
incoming frame's epoch equals the stored one, and a retraction epoch never
equals a live publication.

* test(runtime): cover the fences the retraction change narrowed

Two gaps found by mutating the fences themselves rather than the fix.

Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed
the entire runtime suite. It is not unreachable: a superseded generation whose
sibling stream delivers its frame after the handover outranks the successor on
delivery order, and only the retired-epoch check rejects it. Retractions used to
exercise that fence too; now that they no longer retire anything, a genuine
handover is the only thing left that reaches it, and nothing covered that. The
fence is narrower than it was, not dead.

The second case pins rate-independence. The defect surfaced 1 run in 6 because
`hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage`
treats `:headless-merge:` as the same publisher, so a merged republication walked
past a fence a bare one hit. The removal path is now asserted over both epoch
shapes through the full path, so a fix that only re-rated the defect instead of
removing it would fail here.

* fix(runtime): give "same publisher" one answer across the epoch fences

Separable from the retraction fix beneath it, and it changes handover-path
behaviour: a superseded generation that republishes under a merged epoch is now
rejected where it was previously accepted. Take it independently or not at all.

`publisher-identity-fences.ts` held two answers to "is this the same publisher".
`noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base
and retired the base when the merged form became current, while
`sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are
contradictory, and the retired-value check's exact-string match was the shim
that kept them from ever meeting: a merged frame was a different string, so it
never looked retired no matter what had been retired.

The cost was that the same predecessor was accepted or rejected depending on
which shape it arrived in. A generation a successor had replaced was fenced when
it republished bare and admitted when it republished merged — the fail-open half
of the same disagreement whose fail-closed half was the removal defect, and the
reason that defect reproduced 1 run in 6 rather than every time.

This cannot be fixed in the fence alone. Making the fence lineage-aware while a
merged epoch still retires its base has the generation retire itself: the
rebuild arrives, retires its own base, and the fence then rejects it as a
retired generation. So both sides move together — a lineage sibling advances the
current epoch instead of superseding it, and inherits its generation's
retirement instead of escaping it.

Scoped to the publication-epoch functions. Runtime-id retirement keeps exact
matching, and `local-structured-session-tabs-sync` keeps its own
`hasRetiredValue` call, where a lineage sibling is already excused explicitly
and a retired epoch is deliberately not treated as proof of a dead generation.

* test(e2e): journeys for a reopened client and two clients on one host

Two gaps this suite had no coverage for, both driven end to end against a real
paired desktop client rather than at a seam.

A relaunched client holding a live remote terminal: every paired restart spec
here restarts around a browser pane, none around the terminal the user is
actually mid-work in. The host-side fixture's on-disk sink is the oracle — one
READY for the whole run proves the host never re-spawned the session, and a
recorded line for input sent after the relaunch proves the restored pane is
wired to that same process rather than painted with its scrollback.

Two clients on one host across an emptied workspace: the tombstone is
client-local on the runtime path, so a client that never held a row still seeds
into a workspace another client deliberately emptied. That asymmetry is by
design; a client falling out of step with the host and staying there is not.
Phase 0 is the control — without it a later divergence cannot be attributed to
the emptying rather than to mirroring never having worked.

The input probe goes through `pane.terminal.input`, not `window.api.pty.write`:
a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a
direct write is swallowed and the assertion passes on nothing. The pre-restart
control exists to catch exactly that, and did.

* test(e2e): keep the two-client journey spec type-clean

* test(e2e): pin the close retraction a paired host does not publish

* docs(e2e): say why the red close-retraction spec sits on this PR

The spec was written on a branch carrying neither of this PR's publish-side
fixes, and its own diagnosis -- the fault is the host's publish-after-close,
not any client's mirror -- names exactly what they change. Landing it here
makes CI the measurement rather than leaving a red spec parked on a branch
with no fix in it.

Records the one thing a reader needs to not do: skip-tagging it. And why the
obvious split is not a block move -- phase 2 depends on phase 1b's emptying
and both share the two-client pairing fixture, so splitting means duplicating
the fixture.

* test(e2e): the close-retraction spec is green on this branch, measured

It was written to pin a defect and was red where it was written. On this
branch, with `publish a terminal retirement proof on the exit's own evidence`
and `a removal retraction is not a publisher handover` both present, it passes
-- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a
prior baseline of "none reached either client within 90 seconds".

So the KNOWN RED header had become the thing it warned about: a test carrying
prose asserting the very behaviour the commits beside it remove. Rewritten to
record the measurement and the numbers to regress against, and to keep the one
instruction that still applies -- if it reddens again, do not skip-tag it; the
failure shape is a 90s timeout on both clients at once while creates still
propagate.

No assertion changed. Comment only.

* test(wire): pair the session-tabs retirement proof across two builds

The stack makes a host start sending a retirement proof on its own frame
when no surface removal carries one. The change argues Rule 1; Rule 3's
fourth bullet covers a frame the host starts sending on an existing path,
so the claim is measured against v1.4.199 rather than accepted.

Neither existing cross-version suite reaches session-tabs: the terminal
one covers the binary stream, the agent-session one covers agentSession.*.

Result: the old client acts on the proof-only frame, because the whole
client half of this surface is unchanged. The old-host cells are pinned
to a release that cannot publish the frame at all, which is what makes
the new-host cells mean something.

* fix(lint): clear the casting gate on the surface-lost inventory

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. The retraction
read narrows on the property instead of casting; the fixture and
cross-build-import casts carry per-site SAFETY rationales.

* fix(lint): bind the protected-stamp cast to a name

The leading-semicolon parenthesised call put the suppression on a line
oxfmt then reflowed away from the assertion it covers. Naming the
narrowed handle keeps the directive next to the cast.

* fix(runtime): route every non-null surface write through the stamped writer

`ptyHoldsRecordedSurface` trusts a record only while its stamp is current;
after that the leaf map answers. Four writers still named a pane with a bare
`tabId = / paneKey =` — orphan adoption (both branches), split, create on an
adopted stable pane, and TUI-owner recovery — so a record that had already
been contradicted stayed contradicted after the claim, and `terminal list`
reported the just-claimed PTY `orphaned: true` until the renderer's next
graph statement re-recorded it. Before this branch those sites read as
attached at once, so this was a regression window of one round-trip, and
`indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned".

`recordPtySurface` is now the one writer; the adoption module reaches it
through a port because it has no `graphSequence` of its own. The nulling
writers are untouched: a null surface is never held, stamped or not.

* test(runtime): keep one copy of each publisher-fence case

The removed-frame suite asserted four properties that another case in the
same suite or the lineage suite already pinned:

- the decide-only readmit and the bare full-path readmit are the bare arm of
  the parameterized full-path readmit, verbatim;
- the merged-suffix decide-only readmit is the merged arm of the same loop;
- "still fences a predecessor a successor replaced" is the lineage suite's
  bare arm with different version numbers;
- the recovery-gate handover case is the lineage suite's recovery-gate case
  with a bare late frame instead of a merged one, so that test now runs both
  shapes and this copy goes.

Mutation-checked: reverting each of the five renderer changes on this branch
(retire-on-removal in decide, noting a retraction current, the exact-match
retired fence, merge-supersedes-base, dropping the ledger on removal) still
fails at least one of the remaining ten cases.

Also corrects the suite header: a retraction carries a synthetic `removed:`
epoch, so it is the in-flight predecessor frame, not the retraction, that
shares the live publisher's epoch and needs delivery order to be separated.

* test(e2e): fail the two-client journey when phase 1a cannot run

Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace
that starts with one terminal skipped the control silently while 1b and 2
still ran, and the spec passed green without ever exercising the
close-with-others-open retraction it was written to measure. The skip is now
a recorded failure naming the host count.

* fix(runtime): order every session-tabs apply path against the retraction

A closed terminal came back on the other client because "this worktree was
retracted" was neither durable nor universal:

- `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in
  receipt order at all, so a list the host answered before the close applied
  after the retraction had already cleared the worktree. It is a production
  path for close, create, activation, split and PTY reconnect.
- the boundary lived in a single receipt slot the next stream frame overwrote,
  and in a fence that only existed when a recovery happened to be pending when
  the retraction landed, so a pre-close list could out-rank the republication
  on `snapshotVersion` alone.

Replace both with one raise-only removal watermark per (environment, worktree)
and give the list path a receipt position, reserved by the request and carried
in its answer so a dedupe joiner inherits it rather than minting a newer one.
The pending-recovery fence and its bookkeeping are dead once the boundary is
monotonic. The exact-match retirement check in the receipt ledger becomes the
one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be
noted as current and retire the live publisher out of its own worktree.

On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence`
at write time, so any `paneKey` write claimed the standing of a fresh graph
statement. The inventory restore in `terminal list` therefore un-dropped the
very pane the read was meant to report, on every listing. A surface claim now
carries no graph standing unless its writer names one: the graph statement,
live leaf output and spawn do, while the inventory restore, the floating
liveness restore and the mobile projection replay do not. Defaulting this way
means a writer that says nothing fails safe and self-corrects, which the type
alone could not guarantee across the projection contract's own `recordPty`.

Spawn claims now span the one graph statement the renderer may already have in
flight, and retirement proofs compare by identity instead of by position, so a
re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing.

* fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher

A worktree the host has published nothing for still answers a forced list, with
a synthesized `none`/v0 frame that means "ask me later"
(host-session-snapshot-authority.ts). Every post-close list and every
activation of an emptied worktree gets one. Noting it as a publication retired
the renderer generation that is still live, and because that epoch is
per-process, the terminal the user created next never reached this client — the
same lockout the retraction path was already careful to avoid, through a door
it did not cover. `local-structured-session-tabs-sync` already skips the
placeholder for this exact reason; the web mirror now does too, on both the
receipt ledger and the frame decision.

Bound the receipt ledgers by frame age rather than entry count. One bootstrap
inventory records a receipt per worktree under a single reserved frame, so
evicting by insertion order dropped that batch's own earlier entries, and an
absent receipt is what the recovery gate reads as "no evidence for this
worktree". Only a receipt no in-flight frame can still be ranked against is
droppable.

Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh
path. Ordering is that path's gate, not an optional collaborator a caller's
module mock may leave out, and being reachable only through the barrel is how
the path came to have no ordering at all.

* fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it

`recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner
binding — the same replayed-evidence class as the inventory restore — but
stamped it with the current graph sequence, so a pane the renderer had already
dropped read as attached for one more statement. The guard below it needs the
tabId and paneKey, not the standing.

Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does
and does not cover: an unpublished-worktree placeholder is withheld from epoch
noting only. It still applies, because rejecting it outright would drop the
terminal reconciliation that legitimately rides on it.

* fix(runtime): keep the retraction boundary out of the receipt bound

Bounding the removal watermark alongside the receipt ledger reintroduced the
defect the watermark exists to prevent: past 512 retracted worktrees, evicting
a boundary readmits every pre-close frame it was fencing, and a delayed list
resurrects the closed tab. A boundary is not a cache. One number per worktree
ever retracted on an environment is the cheaper price, and environment teardown
drains it; only the receipt ledger stays bounded, by frame age.

Split the orphan-adoption port by provenance so the last writer that disagreed
with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans`
replays the persisted binding when the claim already matches it and writes a
new one otherwise, and both went through a single `recordSurface` that stamped
the current graph sequence — so re-adopting an already-adopted orphan lifted a
dropped pane's stale stamp and reported it attached, in a quiet workspace
possibly forever. The replay now names the pane without standing and the fresh
claim takes spawn standing, like every other writer.

Replace a receipt-count assertion that was vacuous for a map keyed by
environment and worktree with the mirror state and freshness it was standing in
for.

* fix(runtime): keep a closed-tab worktree under the epoch already publishing it

`closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its
sibling headless writers carry the stored `publicationEpoch` forward and mint
only when there is no snapshot to inherit from — because a write to a worktree
is not a claim to publish it. The close was the one writer that claimed.

A paired client retires the epoch a new publisher displaces, and the web
mirror's retirement is final: there is no revive lane, and the per-worktree
tracking teardown deliberately keeps the epoch history. So an ordinary close
published a stranger for a worktree the renderer generation still owned, retired
that generation on every client, and the renderer's next publication — carrying
the epoch the close had just retired — was rejected forever. The user emptied a
workspace, created a terminal, and it never arrived on either machine while
`session.tabs.list` showed the host holding it.

This is the same thesis the retraction path already states, through the door
next to it: a retraction is not a handover, and neither is a close.

Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each:
phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s
budget) and 0/6 after, with both clients adopting in single-digit milliseconds.

* fix(lint): give the fixtures real types instead of casting past them

The casting gate failed on eight assertions this branch added. All eight were
suppressible, but the suppressions were not the problem: the casts were hiding
fixtures that did not match the contracts they stood in for.

`sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not
a `TerminalTab` field and eight required ones were missing — and layouts holding
only `ptyIdsByLeafId`. `as never` made both compile. They are now real
`TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked
against the type `listTerminals` actually reads.

`terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for
a field typed with *snapshot* tabs, which forced `as never` at the call and a
cast on the snapshot itself. Production reads only `type`, `parentTabId`,
`leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only
fields were inert; dropping them lets the declared
`RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab
is now held by name rather than recovered from `snapshot.tabs[0]`.

The remaining three casts are unchanged in kind and now carry correctly placed
SAFETY rationales: reaching a protected member is the only way to drive these
paths. `graphSequence` folds into the reach-through that was already there
rather than opening a second one, and the map read narrows instead of asserting.

Mutation-tested, all three suites, regression re-introduced for each:
- epoch mint on close restored -> 1 failed | 1 passed
- orphan check reverted to self-consistency -> 4 failed | 4 passed
- placeholder retirement guard removed -> 1 failed | 7 passed

src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed.
`check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean.

* fix(runtime): stop the headless placeholder graph from dropping every restored pane

A headless server publishes one empty graph at launch so status clients see a
ready server. It names no renderer pane and is never replaced, but it was
counted as an authoritative graph statement all the same: `graphSequence` went
0 -> 1 while the leaf map stayed empty for the life of the process.

Every surface claim written without standing - a persisted replay, an inventory
restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the
`>=` guard fails, the empty leaf map answers "no pane holds this", and the
terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it,
because the only graph that host will ever publish has already been published.
On a headless or SSH host that is permanent, and it is the same lie #18191 is
about, pointed the other way.

The placeholder no longer spends a graph statement. A renderer graph still does,
so a pane a real graph drops is still reported dropped - including on a desktop
window promoted from headless, which the third case pins as a negative control.

Mutation: restoring the unconditional bump fails the first two cases
("expected 1 to be +0", "expected true to be false"); the promoted-window
control passes either way, as a control should.

Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so
adding it had switched the job's gate on, but the job runs an explicit file list
that omitted it - the test executed nowhere in CI. It passes 8/8.
2026-09-18 01:56:07 -07:00
Neil 9d1826ae65 fix(session): repoint the rows a worktree re-key strands (latent; producer is flag-disabled) (#20057)
* fix(session): keep a renamed worktree's rows from matching on the id it lost

Three persisted session fields survived a worktree re-key still naming the old
identity. Two of them are suppression records, so a stale id does not read as
residue -- it silently re-admits state the user removed:

- closedTerminalTabTombstonesByTabId: the remote merge only suppresses a host
  tab when the tombstone's worktree equals the tab's, and no snapshot ever
  covers the old id, so the tombstone never retires either.
- clientHostedBrowserCloseIntentsByEnvironment: the replay targets the intent's
  worktree, and an unresolvable selector answers selector_not_found -- which the
  replay reads as definitively gone and uses to DROP the intent.
- clientHostedBrowserPagesByWorktree: keyed by worktree and re-checked against
  the row's own workspaceId, so both halves have to move or the pages are never
  rehydrated.

Fixed on both sides of the rename: the main-process persisted migration and the
renderer's live store, which would otherwise write the stale values straight
back. The coverage test drives off WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND,
the census these three fell out of, with the shipping owner collector as its
oracle.

* docs(session): record why a re-key clobbering an existing target stays unfixed

Not a missing guard -- an unresolvable one. Keeping the target is correct when
it holds a real closed-last-terminal tombstone; keeping the source is correct
when the target row is a stub; nothing records which is newer. The recency map
is the only one that can settle it, because Math.max needs no such ordering.

* test(persistence): measure the downgrade direction for worktree identity

The stack widens migrateWorktreeIdentity to repoint worktreeId inside
session rows. That changes what lands on disk with no wire change, which
is Rule 3's shape applied to persistence, so it is measured against
v1.4.199 rather than reasoned about.

Result: new-build state does not break the old build. The old build
renames over it without throwing and loses no row; the two row kinds it
cannot repoint stay stale, which is exactly what its own renames already
produce.

The numbers are measured. A first draft asserted the old build repointed
no inner rows at all; it repoints two of four, and the probe is what
caught that.

* test(ci): run the worktree-identity downgrade lane instead of describing it

The cross-version job names its files explicitly, so a new one is inert until
it is listed; the sharded unit job excludes the whole directory and the E2E
router only takes `*.spec.ts`. Also pairs the forward-compat case against the
current build — the stack's own field-list walk is the guarantee that matters,
and only the frozen build was exercised.

* refactor(session): drop the type assertions the rename migration leaned on

`consistent-type-assertions` landed on main after this branch last built, and
three of the `as never` fixtures were hiding real contract drift: a browser
workspace row missing six required fields, a tab group naming three fields the
type does not have while omitting the two it requires, and a sleeping-agent row
whose `providerSession` had neither `key` nor `id` and whose `state` was not in
`AgentStatusState`.

Indexing the session by a computed field name is what forced the casts in the
migration, so the four row maps are now spelled out; the census test is what
keeps a fifth from joining silently. The renderer test builds its state from
the real slice instead of casting a four-field partial.

* refactor(test): name the module namespace the skew harness reads

`object` is too broad for the anti-slop gate, and the import helper already
declares what it hands back.

* docs(test): say which maps the harness actually supplies

The two under test live in slices this harness does not mount, so calling it
"the real slice's state" overclaimed.
2026-09-18 01:11:03 -07:00
Neil 8d2f16856f fix(session): scope agent resume to the host that captured the session (#21288)
* fix(session): scope agent resume to the host that captured the session

A provider session id names a transcript in one machine's agent state
directory. Nothing in the resume path compared that machine against the
one the resume executes on, so a record captured on host A reached a
`--resume` run on host B, which answers `No conversation found with
session ID`.

Three things make the drift reachable: `worktreeId` is `repoId::path`
with no host component, sleeping records are `'sleepingAgentKeyed'` so
boot-time host-contention parking never arbitrates them and every
partition merges into one map without retaining provenance, and both
issuers resolve their launch target from the current catalog.

Both issuers are gated. The activation sweep hands `quit`/`live` records
whose pane still exists to the pane's own cold restore, so gating the
sweep alone changed nothing in the SSH lane.

Declines rather than guesses: the record is preserved and remains
resumable by hand. A refused resume is recoverable, a forked transcript
is not. The predicate fails open on anything it cannot positively rule
out -- an unstamped record, an empty stamp, or a `runtime:` host, which a
paired client uses to relabel its host's own SSH workspaces.

The cold-restore gate consults both the pane's transport and the
catalog. The transport alone was racy: it is unresolved on an early
reattach frame, and that frame is exactly when a wrong resume escaped.

* docs(session): name the inverted fail-open direction at the resume gate

* fix(session): keep an unresolved catalog out of the resume host verdict

The worktree form of the resume gate resolved the current host through
getExecutionHostIdForWorktree, which answers 'local' for a worktree the
catalog has no row for. Read as a host, that made every SSH-stamped record
look foreign until its repo row landed, contradicting the module's own
contract that it reports only a positively-known disagreement. Add
getKnownExecutionHostIdForWorktree, which returns null in that silence
(no repo row for a git worktree, no folder-workspace row for a folder
workspace), and route the gate through it; the pair form already fails
open on a null host. The routing resolver keeps its default unchanged.

The CI red on the control case was a separate spec race: the ledger wait
returned as soon as the ledger was non-empty, and it already held the
first launch's `--version` probe, so the control read two probes and gave
up before the cold-restore had typed `--resume` (the failure screenshot
shows the command running in the pane). The spec now reads only the lines
the relaunch appended, anchors on the relaunch's PTY binding and its own
probe, and then waits for `--resume` for the control case or a bounded
grace for the refusal case.
2026-09-17 22:12:55 -07:00
Neil 5c8540948d test(e2e): name the paired-client quit that preserves the profile (#21300)
Quit-without-deleting is closeElectronAppForE2E + cleanupE2EDaemons — dispose's first two
steps without removeProfile. The composition is correct today but undiscoverable, and
getting it wrong is silent and expensive in both directions.

dispose() + reuseUserDataDir yields a FIRST RUN on an empty profile, so every persistence
assertion after it reads empty and is indistinguishable from data loss. That produced a
phantom data-loss report, live in two write-ups before a diagnostic listing zero session
FILES (rather than zero buffers) contradicted it.

Reaching for a bare app.close() to skip the deletion hangs instead: it lacks the timeout
and force-kill fallback that closeElectronAppForE2E wraps around it, and burned a ten
minute test deadline producing no reading at all.

Test infrastructure only; no production code. Unblocks restart-persistence coverage for
the paired topology.
2026-09-17 21:21:14 -07:00
Neil 066b4951b9 fix(terminal): keep a split's real direction when the leaf set moves (#21294)
resolveTerminalLayoutRoot discarded any known tree that did not cover the
published leaf set exactly and rebuilt the tab as a flat chain with a guessed
'horizontal' direction, restacking side-by-side panes. The guess is then
published, mirrored to every paired client, and written back over the real
tree, so the direction is gone from disk.

Prune a known tree to the leaves that survive and graft only the leaves no
tree places, which is now the sole place a direction is invented and is still
reported through onSynthesize.
2026-09-17 21:21:01 -07:00
Neil 8c6ae79e94 fix(relay): stop detached tools on immediate terminal close (#20645)
* fix(relay): sweep detached tools on immediate terminal close

* test(relay): reject failed process cleanup queries
2026-09-17 20:58:27 -07:00
Neil 691d9692e6 fix(pty): stop detached OMP tools on immediate terminal close (#20642)
* test(omp): add opt-in owned PTY closure probe

* fix(pty): sweep detached tools on immediate unrecognized shell close

* test(omp): create close probe evidence root in fresh worktrees

* test(pty): account for asynchronous immediate descendant cleanup

* test(pty): reject inconclusive descendant cleanup probes
2026-09-17 20:58:02 -07:00
OrcaWinandm4air c3c051dfa6 Release provider children after structured session holds disappear (#20978)
* fix(chat): release provider children after lost resume holds

* test: load audit fixtures as modules and verify combined mobile payload

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-17 20:32:45 -07:00
Jinwoo Hong a634bf9b49 test(bench): runtime-graph publication probe and optional CDP CPU throttle (#21107)
* test(bench): count runtime-graph publications from main

The build-provided `__orcaBenchmarkInstrumentation` is gone from the tree, so
the typing bench could no longer report graph-publication counts at all. The
renderer cannot supply them either: `window.api` is frozen by contextBridge,
so `runtime.syncWindowGraph` is not wrappable.

Count them where they land instead — main's `runtime:syncWindowGraph` invoke
handler — behind ORCA_TYPING_BENCH_GRAPH_PROBE=1, and record the result in the
bench report. Measured on an 870-worktree fixture: 21 publications over a 50 s
metadata-only window versus ~1,205 with recurring OSC title/status traffic.

The long-task fields ship unproven: an injected 250 ms renderer busy-wait
produced zero entries even though `longtask` is in `supportedEntryTypes`, so
their zeros mean "oracle unverified", not "no long task". The self-test knob
exists to make that falsifiable, and the file says so; per-publication build
time still needs a separate --cpu-profile run.

* test(bench): optional CDP CPU throttle around the typing window

* test(bench): report the throttle that ran and the long task the self-test caused

Two ways the bench could misreport its own conditions.

`cpuThrottleRate` was the requested rate, written into every report, but only
two of the three scenarios wrapped their typing window in the throttle — a
`--cpu-throttle 4` visible-split run claimed a 4x throttle it never applied.
Recording the rate per scenario would have made the report honest; it would
also have left one scenario silently ignoring the flag, and a fourth scenario
would inherit the same omission. So both: every scenario now goes through one
`measureTypingWindow` helper, and the value it returns is the rate the throttle
actually applied. `writeBenchReport` takes that composite instead of a bare
measurement, so a scenario cannot produce a report without saying what it ran
under. Unthrottled runs are unchanged — rate 1 still opens no CDP session.

`selfTestLongTaskMs` took the *earliest* long task starting before a cutoff
captured after the busy-wait. The observer has been live since probe start, so
any unrelated long task from fixture setup satisfied it — the field whose whole
job is to prove the oracle is live was the easiest one to fake. The busy-wait
now reports its own renderer-clock bounds and the matching entry is the one
containing their midpoint: main-thread tasks never overlap, so at most one can,
and it is the task the busy-wait ran in. That entry is then withheld from
`longTasks`, `longestLongTasks`, and `longTasksAroundPublication`, which had
been counting the oracle's injected 250 ms as workload.

A zero still means "oracle unproven" — it now also means it honestly.

* test(bench): stop the graph probe when the typing run throws

* test(e2e): drain queued long-task records before the probe disconnects
2026-09-17 17:32:20 -04:00
Brennan Benson 434365d2de Offer to reconnect native chats that were working when Orca restarted (#21096)
* feat(native-chat): resume structured chats that were working at restart

Teardown records a marker for every session this host was genuinely running a
turn for, derived from the LIVE runtime rather than a persisted status row, so
a stale `running` row left by an older crash can never trigger a resume. On the
next launch a modal lists exactly which chats would resume and resumes them via
native continuation (Claude resume/resumeSessionAt, Codex thread id) — never by
re-sending the prompt, which is what makes an agent redo finished work.

A session resumes only when all of these hold: a teardown marker exists and has
not expired, the record's lease is released and reconciled, a provider resume
cursor exists and still matches the marker, the journal's own turn record names
the same turn, and the marker has not already been spent. Markers are consumed
before the resume is submitted, so a crash mid-resume cannot double-fire, and an
admission gate refuses a second concurrent resume for one session. Resumes are
staggered three at a time rather than spawning every provider at once.

The modal's "Don't ask again" checkbox writes the nativeChatResumeWorkOnRestart
setting, which Settings can turn back off; automatic mode runs the identical
predicate and staggering and reports what it did. Declining consumes the markers
so the prompt cannot return every launch — nothing is lost, because opening a
chat still re-acquires it at the same cursor.

* fix(native-chat): compare handle ROOT and turn state when offering a resume

Four defects QA found in the restart-resume offer, fixed together because the
first two interact: shipping the root fix without the state fix would convert a
silent no-op into actively offering finished chats.

1. Claude was never offered (0/4). The marker recorded agentSessionProviderHandleKey,
   which embeds Claude's leaf uuid — a branch cursor. The adapter's own close path
   appends a `resumed` link with an advanced leaf during the SAME teardown, so the
   marker went stale seconds after it was written and the drift guard refused every
   Claude session forever. Record and compare agentSessionProviderHandleRoot instead:
   the root is the part a resume must preserve, and changing it is a fork, which is
   exactly what this guard is for. Codex is unaffected (its thread id is the whole
   key) but uses the root too, so the rule is uniform.

2. The predicate compared turn IDENTITY but discarded turn STATE, so a `completed`
   turn satisfied it as readily as an interrupted one. Eviction rewrites `running`
   to `interrupted` and never to `completed`, so the state is what separates work
   that was cut off from work that finished. Require `interrupted` or `unverifiable`.

3. A chat blocked on a pending approval or question was marked as working, because
   the teardown reader accepted any `running` turn while the product's own projection
   calls that state `attention`. Teardown now defers to that projection: an agent
   waiting on the USER is not interrupted work.

4. "Resume all" could silently no-op. The modal fetched candidates at mount; by click
   time the chat's own pane may have bound and taken the hold, moving the lease to
   `live` so the predicate dropped it and the call returned no results, leaving the
   dialog open behind a dead button. Re-derive at click time and settle an
   already-live session as resumed — it is running, which is what the user asked for.

Test fakes now model the Claude close path that advances the leaf, which is why no
unit test could previously exhibit defect 1. Ablation covers all eleven guards.

* fix(native-chat): gate the already-live settlement on the full resume predicate

Two follow-ups from re-QA, both cases of a rule stated by intent rather than by
discriminator.

1. The already-live path bypassed the predicate. "Resume all" sends no session
   ids, so the fallback's target set was every marker, and it was gated only on
   the session having a live provider child. A chat the predicate had refused --
   a completed turn, say -- whose pane happened to own the lease was therefore
   settled as `already_live` and had its marker spent, inflating the "Resumed N"
   count with chats that were never eligible. No provider spawned and no tokens
   were spent, but a marker the predicate rejected must never be consumed.

   The resumable set now takes an explicit `leaseState`. The already-live path
   derives a second set with ONLY the released-lease clause relaxed, and settles
   a session just when it is in that set. Every other clause still applies.

2. The `attention` rule was one-sided. Teardown refuses to mint a marker for a
   chat blocked on the user, but the set predicate had no equivalent, so a marker
   arriving by any other route was offered once eviction rewrote its turn to
   `interrupted` -- the same asymmetry the completed-turn case had.

   Gated on projectStructuredAgentSessionStatus === 'attention'. That projection
   tests for a pending approval or question BEFORE it looks at turn state, so it
   still reports `attention` after the turn is settled, which makes it the durable
   signal and keeps one source of truth with teardown.

Ablation now covers thirteen guards, including one for each of the above.

* fix(native-chat): capture awaits-user on the marker instead of re-deriving it

The awaits-user clause could never fire. It asked the live projection for
`attention`, which needs a prompt whose resolution is still `pending` -- but
teardown CANCELS that prompt a few phases after it writes the marker. By the next
launch the evidence is gone, for precisely the sessions the clause was written
for. QA measured the injection still being offered and then resumed.

This is the same shape as the leaf-drift bug: state read after teardown is not the
state that justified the marker. The discriminator, now applied across the whole
predicate:

  - a fact teardown itself destroys or mutates must be CAPTURED on the marker
    while it is still true;
  - a fact that evolves on its own must be RE-DERIVED at read time, never
    snapshotted.

So `awaitsUser` is now recorded at teardown and the predicate reads the recorded
value. Teardown still declines to mint a marker for such a session, so the
recorded flag is the second line rather than the only one.

Audit of every other clause against the same test:

  - turn id (captured) -- teardown rewrites turn STATE but never the id. Correct.
  - provider handle root (captured) -- the close path appends a resumed link, and
    appendAgentSessionProviderHandleLink refuses one that changes the root, so the
    root is invariant under exactly the mutation that broke the key. Correct.
  - turn state (re-derived) -- DELIBERATE exception, stated here rather than left
    implicit: we are not reading the state that justified the marker, we are
    reading teardown's receipt that it settled the turn. A turn still `running`
    means eviction never finished, and we refuse. Correct, and intentionally so.
  - lease reconciled / released / handoff stage (re-derived) -- these answer a
    different, launch-time question: may this host take the lease NOW. The
    teardown-time value would be meaningless, and `unreconciled` is cleared by
    this launch's own reconciliation. Correct.
  - adapter support, marker TTL, marker consumption (re-derived) -- all evolve
    independently of teardown. Correct.

Only awaitsUser was on the wrong side.

* fix(native-chat): drop the unreachable awaits-user marker flag

The captured flag was dead code. `awaitsUser` could only be true when the
projected status was `attention`, and `attention` hits the `continue` above the
push -- so every marker teardown can ever write carries `false` (QA measured
22 of 22 across two real teardowns). The predicate clause reading it was
unreachable by any production path.

A flag that is structurally always false is worse than no flag: it reads as a
safeguard, so the next person to touch this trusts it. The asymmetry it was
added to close was only ever reachable by fault injection, because teardown is
the sole writer of markers and already refuses attention sessions.

Removing it also drops an upgrade discontinuity: as a required field it made a
marker written by the previous build fail validation and be silently discarded,
costing a resume offer on precisely the upgrade where the user was mid-turn.
Markers predating the providerHandleRoot rename still will not parse, but those
carry a leaf-sensitive key the predicate would refuse anyway, so nothing usable
is lost.

In its place the teardown gate now states that `status !== 'working'` is the
SINGLE gate for awaiting-user sessions, why a predicate-side mirror would be
unreachable, and why it could not even re-derive the fact -- so the reasoning is
inherited rather than rediscovered.

Ablation is back to twelve guards; every other clause is unchanged.

* fix(native-chat): say reconnect, not resume, and show each offer's age

Two changes, both independent of the parked continuation decision.

1. The copy claimed something QA disproved. "Resuming continues each agent where
   it left off" is false: reconnection restores the session at the point it
   stopped, with full context and without re-sending the prompt, but the
   interrupted reply does not continue on its own. The toast's "Resumed N chats"
   implied work had restarted.

   Audited every user-facing string against the rule that none may claim work
   continues or that a reply resumes -- which caught more than the three strings
   the fix started from. The title, the row button, "Resume all", "Resuming...",
   the not-now hint ("picks it up where it left off"), the checkbox and its hint
   ("resume on their own"), the list's aria-label and the Settings row all made
   the same claim. The user-facing verb is now reconnect throughout; the body and
   update variant state outright that the interrupted reply will not continue.
   en.json synced, runtime boot catalog regenerated.

   If we later decide to send a continuation instruction, this is one commit to
   change back. Shipping text we know to be false was the worse option.

2. Rows now show each offer's age. The TTL is 24 hours and a stale offer looked
   identical to a fresh one. The marker already carried `recordedAt`, so this is
   a render change plus one field on the renderer's candidate type, formatted
   with the existing formatUiRelativeTime helper rather than a new one.

   The clock is stamped once when the list arrives rather than read during render:
   ages then stay stable across re-renders, and the render stays pure, which the
   react(purity) rule requires.

Guards, predicate and RPC are untouched; ablation still covers twelve.

* feat(native-chat): show the workspace name on each reconnect row

A row read `codex · folder:8f3a1c22-… · 8 hours ago`. Recognising which chats
would reconnect is the entire point of the list, and at twenty rows a UUID
identifies nothing.

No RPC or host change was needed: the renderer can already resolve this id.
Resolved the way automation dispatch resolves the same id space
(resolveAutomationDispatchWorkspace) -- a folder workspace by its full
`folder:<uuid>` key via getKnownWorktreeById, a git worktree by its bare
`repoId::path` id via allWorktrees. Both return a Worktree, whose displayName is
a required field, and DetectedWorktree extends Worktree so either shape answers.

Falls back to the id when nothing resolves, which is what the row showed before
and also covers the window before the worktree store has hydrated.

The lookup lives in a per-row subcomponent because a hook cannot run inside
`map`, and its selector returns a primitive string so repeated selector runs
cannot churn referential equality.

* feat(native-chat): group the reconnect modal by worktree and add opt-in continuation

Grouping. Rows are now grouped under a worktree heading with the repo glyph and
an agent count, using the sidebar's own collapse mechanics. Only presentational
pieces are reused -- RepoIconGlyph, CompactAgentExpansion, AgentIcon and
formatShortTimeAgo. The sidebar's agent row cannot be: worktree-card-compact-agent-row
imports DashboardAgentRow, the dashboard's own type, so both surfaces render one
live-agent model requiring a pane, tab and status entry. Every chat offered here
is by definition stopped, so supplying that would mean inventing live state.

Two things I had assumed were reusable and were not:

  - DashboardHostBadge returns null unless hostKind is ssh or remote. Structured
    chat is local-only, so it would always render nothing. The host line is
    omitted rather than faked; the badge is the right element to add if and when
    structured chat gains remote support.
  - No state dot. Every AgentDotState misleads here: idle and unverifiable both
    presuppose a live pane, interrupted renders red like an error, done green,
    working a spinner. A missing dot beats one saying these agents are running.

One worktree renders flat with no heading -- a name, count and chevron around a
single group says nothing the dialog has not already said.

The age column now uses formatShortTimeAgo for sidebar consistency. It takes
(timestamp, now) and subtracts internally rather than taking a delta, so the call
is (recordedAt, listedAt); passing the old delta would have rendered plausible
nonsense. The clock is still stamped once into state, so ages stay stable and the
render stays pure.

Continuation. A secondary "Reconnect and continue" action sends one message, from
a single shared constant, identical for both providers. Reconnect is unchanged and
still sends nothing. An info popover quotes the literal message read from that
same constant, so what is shown cannot drift from what is sent.

Ablation now covers fourteen guards. Two are new: continuation only follows a
reconnect that actually happened, and -- inversely -- a send injected into the
reconnect path must turn the test red, since "don't ask again" rests on reconnect
never sending.

* feat(native-chat): say terminal sessions kept running, and clear the quality gate

The modal lists stopped chats with no way to tell that CLI agents are fine, and
the true state of the world is counterintuitive: the terminal sessions survived
the restart and the chats did not. One line now says so, next to the heading
where it frames the list rather than as a footnote at the bottom.

Wording follows the app's own vocabulary rather than inventing a term: the
catalog settles on "terminal sessions" (terminalSessionCount, "Terminal sessions
are grouped by workspace", "No terminal sessions yet"), and UpdateCard already
reassures with "Your terminal sessions won't be interrupted during the update" in
the same text-xs text-muted-foreground treatment. "kept running" rather than
"were restored" -- nothing reconnected them, they never stopped, and the line
says nothing about why.

Also clears check:code-quality:changed, which I had not been running -- oxlint
alone covers neither the design-system nor the casting audit, so 18 findings had
accumulated across the branch.

  - design system (4): Button spacing hand-rolled as gap-1/px-2 is just size="xs";
    PopoverContent and DialogTitle own their typography and spacing, so the
    text-xs moved to the popover's own children and the title's icon gap moved to
    a plain wrapper.
  - casting (14): production code loses its assertions outright via Reflect.get,
    the idiom already used in managed-hook-detection-commands and
    worktree-name-retirement. The marker validator reads each field through
    Reflect.get and now checks recordedAt is a number rather than asserting it;
    the store-file parse uses the existing `file` shape instead of a second
    assertion; the runner narrows the admission error's owner with typeof.
    Test fixtures keep their assertions behind the line-specific SAFETY:
    rationale the repo mandates for exactly this case.

One trap worth recording: the audit reports an assertion at the line its
EXPRESSION OPENS, not where `as` appears, so a disable-next-line above the
closing brace of a multi-line literal is inert and silently changes nothing.

Guards unchanged; ablation re-proved 14/14 at this head.

* fix(native-chat): give the reconnect row's provider icon an accessible name

Every row rendered the provider as a bare AgentIcon, whose svg carries no
aria-label, title or alt. With a Claude chat and a Codex chat in one worktree the
two rows were identical to any non-visual consumer, and the dialog offered
several identically-named "Reconnect" buttons with nothing to tell them apart.

A regression from 233e37b2bd, where the row read `${agent} · ${workspace} · …` as
text. Moving the workspace name into the group heading was right; dropping the
provider to an unlabelled glyph is what lost the information.

AgentIcon takes no label prop, so the icon is wrapped the way
NativeChatSupportedAgents already names it: a span with role="img" and an
aria-label from formatAgentTypeLabel, the same labeller the sidebar and dashboard
rows use.

The per-row button also names its agent now ("Reconnect Claude chat"). The
identical buttons were half the reported harm, and an accessible name that opens
with the visible word keeps WCAG 2.5.3 satisfied. Say so if you would rather ship
only the icon label -- it is one attribute and one catalog key to drop.

Age code untouched, as asked: formatShortTimeAgo still takes (timestamp, now) and
is still called with (recordedAt, listedAt).

* fix(native-chat): scope resume markers to one launch and report the real dispatch

Three defects in the restart-resume path, all of which could resume a session
that was not genuinely working or claim one was continued when it was not.

Launch scoping. A durable marker with a 24h TTL is a write-ahead latch: a
teardown write that failed or timed out, or a store restored from its backup,
left a previous generation's marker actionable, and automatic reconnect would
have acted on it silently. Markers now carry the id of the launch that wrote
them, and only the launch immediately after may claim them. The launch id lives
in its own file with no backup mechanism, so it cannot roll back in step with
the markers it is proving adjacency for. Startup claims the previous launch's
markers into launch-scoped memory and deletes every durable copy in the same
step, so the durable fact dies at claim time rather than at use time. Both
halves fail closed: an unprovable predecessor and a clear that throws each
claim nothing.

Dispatch states. The send layer answers ok as soon as Orca owns the message;
the provider's own answer lives in the submission. Continuation read only the
envelope, so a rejected turn/start was reported as continued and stamped the
journal saying the agent had been asked to carry on. All four states are now
preserved, and only an accepted dispatch appends the attribution note.

Claude pre-echo sends. Claude cannot write a running turn until the SDK echoes
the user message back, which is seconds on a real journal, so a turn-id-only
marker dropped exactly the sessions that were working hardest. A send that has
not become a turn now carries its own identity, and the launch-side predicate
asks the journal about that submission's dispatch state instead.

* fix(native-chat): follow an accepted send to its turn, and settle before judging

Two defects found in QA, both reproduced twice.

Follow the submission forward. The launch-side predicate accepted a
submission-shaped marker only while its dispatch was pending or unknown, but the
window in which work is submission-shaped is precisely the window in which the
dispatch is about to be accepted: the send settles during teardown and the turn
it opened is then cut off as interrupted. Judgement was frozen at the moment the
marker was written, so the predicate refused the very sessions this was built
for and fired only when the send never reached the provider. An accepted
submission is now followed to the turn it opened -- matched through the user
item key a turn names and a submission is aliased by -- and that turn is judged
by the existing turn rule. Accepted alone still proves nothing: without the link,
or with a turn that completed, this refuses as before.

Settle before judging. A send resolves as soon as Orca owns the message, while
its dispatch is still pending; that is the ordinary successful path. Reading the
dispatch off the send result therefore reported every delivered continuation as
pending and never wrote the attribution note. The outcome is now decided on the
settled submission, through the host's existing settlement waiter, with the send
result as fallback when nothing settles in time.

The failed-note path no longer swallows its error. It stays best effort -- a
journal that refuses the note must not turn a delivered continuation into a
failure -- but the failure is reported through the host's error sink instead of
being discarded, so it cannot regress unseen again.

The surface's send is typed against the wire result rather than a hand-written
subset, which is what let a test assert a shape the host never returns. Binding
the surface to the host moves into its own file: the host was one line under the
line cap, and the bindings carry decisions that belong beside their consumer.

* feat(native-chat): show the reconnect offer the way the worktree sidebar does

The offer is a list of workspaces, so it should read like the one users already
know. Rows are now three tiers -- repo or project, then workspace, then the agent
sessions inside it -- and each agent carries a checkbox rather than its own
button, checked by default, with the footer acting on whatever is ticked.

Reused rather than rebuilt. The host chip is the sidebar's own: its markup lived
inline in the card's meta row, so it moves to a shared component both surfaces
render, and the label comes from getHostContextLabel, which is where "Local Mac"
has always come from. The repo glyph is RepoIconGlyph; a group with no repo uses
the FolderTree the sidebar's own project-group metadata uses. The agent row
reuses AgentIcon, the agent-type label helpers, formatShortTimeAgo and the same
model treatment.

Two things could NOT be reused, and both are deliberate. The sidebar's
CompactAgentRow needs a live pane, tab and status entry, and every chat here is
stopped by definition. And the sidebar has no git-worktree-vs-folder glyph
resolver at all -- both kinds render the same card, and the difference people
read is its status lane choosing GitBranch when a workspace has branch identity;
that single precedent is what the workspace glyph follows.

The model, the execution host and the workspace kind now travel with each
offered chat. All three are read off the durable record the predicate already
holds -- the model through the same normalizer the status feed uses -- so the
glyph is never inferred from a name and no new data source appears. They are
optional on the wire, so an older host still renders a row.

Selection changes which ELIGIBLE chats are acted on, never what is eligible. Ids
are seeded from the host's own answer and intersected back against it before any
call, and the host re-derives the predicate regardless of what it is sent.
Continuing still requires an explicit click, and the automatic path still calls
the reconnect method, which contains no send.

The badge's treatment becomes a variant instead of a pile of overrides, which is
what the design-system gate asks for once the markup is somewhere it can see it.

* fix(native-chat): title a folder workspace group with its project name

A folder workspace's synthetic worktree borrows the `repoId` slot to name the
project group it belongs to, so that field is NEVER null. The reconnect offer
read a non-null `repoId` as proof of a git repo, looked it up in the repos list,
found nothing, and rendered the raw `folder-workspace:<uuid>` string as the group
header. The project glyph written for the no-repo case was unreachable for the
one workspace kind it was meant for, and the string fallback behind it was dead
for the same reason.

The project group name was available all along and the sidebar already titles
these with it, which is what this list is meant to mirror.

Recognising the id now lives beside the code that mints it, so the two cannot
drift: there was no such helper, only forward constructions of the same prefix in
five places. The header choice itself moved into a pure resolver, so the branch
that was wrong is now the branch under test.

The dead fallback string is gone, along with its catalog entries.

* fix(native-chat): offer an accepted send the provider never opened a turn for

QA: a chat that was genuinely working was silently dropped from the offer. The
discriminator was how far the send had progressed -- it was the last chat
prompted before quitting, reachable by quitting a second or two after sending.

Mechanism, reproduced against the predicate. The marker was written while the
send was still pending, so it is submission-shaped. During teardown the dispatch
then settled to `accepted`, which took it out of the pending/unknown branch and
into the follow-forward branch. But the provider died before writing a turn row
for that send, so there was no turn to follow forward TO, and the branch demanded
a proved link before it would answer. Both the no-turn-at-all case and the
newest-turn-belongs-to-an-earlier-exchange case therefore refused.

An accepted send that never became a turn cannot be finished work, because
finishing writes a turn row. The marked send is also the newest work in the
session, so any turn it opened would be the newest turn.

That makes the link unnecessary to prove for a safe answer. When the newest turn
is interrupted or unverifiable the two readings agree: if the row really is this
send's under a key we failed to match, it was cut off; if it belongs to an
earlier exchange, this send opened no turn at all. Either way the work was
interrupted. A journal with no turn row at all is the same case with nothing to
disagree about.

The readings only diverge on a `completed` row, where an unmatched one might be
this very send's finished turn under a key we did not recognise. That stays
refused. Ambiguity resolves to no, because resuming finished work is the one
outcome never worth risking.

* fix: write the grouping separators as escapes so the files stay text

Five separators in the reconnect-offer redesign were written as raw NUL bytes
instead of the `\0` escape. The runtime strings were correct and the app behaved,
but git classifies a file containing a NUL as binary -- so the two central files
of that redesign rendered as "Binary file not shown" in review, and `rg` skipped
them silently, returning no matches rather than an error.

The escape produces the identical string, so the NUL separator is kept: the
previous separator was a space, and a workspace id containing one would corrupt
the join/split pair this grouping depends on.

Nothing could have caught this. Typecheck, lint, the quality gate, the
localization verifiers and the full suite all passed throughout, because none of
them look at file encoding. So this adds a check that does, wired into the
pre-commit hook where it costs nothing and catches the next one at the moment it
is written.

Two files already on main carry a raw NUL for the same reason -- one a template
separator, one a deliberately tricky test alphabet whose neighbours are all
written as escapes. They are grandfathered rather than fixed here, since they
belong to their own change, and the gate fails if the list ever grows or goes
stale.

* fix: parse markers into a domain type, and declare the four restart methods

Two CI failures, both ours.

Static analysis. `Reflect.get` was adopted to clear the casting audit, and the
anti-slop rule forbids it -- the two gates disagree, and the rule text says what
both want: parse dynamic input into a named type once, then read typed fields off
it. Markers re-enter from a file this process may not have written and decide
whether an agent is handed a provider child, so they now go through a single zod
parse. Unknown keys still pass, and a malformed marker is still dropped rather
than thrown, so a bad entry cannot make a user's sessions unreadable. The launch
stamp is parsed the same way, the resume-admission refusal becomes a named error
carrying a typed `owner` instead of a bag assigned onto `new Error`, and the test
harness gets a named journal type instead of reaching into `unknown`.

Cross-version wire. The four restart methods are added to the manifest rather
than the count being bumped, so the suite now exercises them in both skews. They
are bare additions, not capability-negotiated: an unknown RPC method answers
`method_not_found`, which is explicit and visible during negotiation, unlike a
stream opcode that is dropped in silence. The whole `agentSession.*` surface
already sits behind its runtime capability, so an old client is told it does not
exist and never reaches a host method.

The stub's spies stay a flat map because callers iterate it asserting each entry
is a spy that did not run; a composer reassembles the member the host really
exposes. The manifest and its params builders move to their own module, which is
what keeps the suite under its line cap as the surface grows.

* Prevent duplicate restart continuation and release reconnect holds

* fix: recheck interrupted work when admitting restart continuation

* fix(native-chat): invalidate restart offers after newer user work

* Consume restart recovery offers from an isolated advisory capsule

* Refuse completed restart work and report recovery outcomes

* fix(native-chat): honor queued completion and uncertain restart delivery

* fix(native-chat): preserve restart refusal and teardown evidence

* fix(native-chat): rederive recovery evidence before continuing

* Validate restart continuation at provider dispatch

* fix(native-chat): finish restart refusal and attribution delivery

* fix(native-chat): keep recovery teardown errors out of logs

* fix(native-chat): validate restart continuation at provider dispatch

* Revalidate restart continuation when Claude dequeues input

Check continuation authority after the SDK input queue wait and arm replay correlation only after authorization. Preserve typed pre-dispatch refusal, ordinary send behavior, and cleanup when the provider exits or capacity fills during authorization.

* Deduplicate settlement test import

* Keep merge update scoped to restart recovery

* Polish continuation popover spacing
2026-09-17 12:59:03 -07:00
Shahar MorandNeil de15227a1d feat(terminal): search match count + Cmd+F focus parity (#9035)
* feat(terminal): show search match count and keep Cmd+F from closing search

Bring the terminal search bar to parity with the editor find bars:

- Show a live match indicator (0/0, current/total, "No results", or
  <count>+ past the highlight limit) driven by the xterm SearchAddon
  onDidChangeResults event.
- A repeat Cmd+F while the search is open now re-focuses and selects the
  query instead of toggling the panel closed; Esc remains the close path.

Adds unit coverage for the indicator states and the toggle decision.

* Use auto-generated localization key for TerminalSearch no-results (#9035)

Replace the hand-written "noResults" i18n key with the SHA1-based auto key
(auto.components.TerminalSearch.10e039b591) to match the repo's auto-keying
convention, and sync the key across all locale catalogs.

Addresses CodeRabbit review feedback.

* test(terminal): cover search dispatch after keyboard module split

* fix(terminal): refocus search from its input and verify real matches

* test(terminal): use portable echo commands for search proof

* refactor(terminal): keep search subscription and cleanup together

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-17 03:00:17 -07:00
Jinjing 25dd70e611 Test: target question card title by testid instead of text (#21153)
* test: target question card title by testid instead of text

Add data-testid to NativeChatQuestionCard's title element and update the
e2e test to query by testid with a text filter. The transcript row also
renders the question text, so the previous test could match either location,
causing flaky results. Gating on the card's own title node ensures the
assertion verifies the card is actually rendered.

Fixes #20724

* test(browser-history): budget the fastest sample, not p95

The prepare/match budget assertions measure wall clock inside a vitest
worker that shares cores with the rest of the shard, so a slow sample
records a preemption rather than the matcher. CI shard 6/8 measured a
p95 of 3.57 ms against the 2 ms ceiling while the same test passes in
isolation; #18788 already records this file failing the same way.

Assert the fastest sample instead, matching the estimator the palette
matcher budget already uses for the same reason. Ceilings stay at 2 ms.

Measured locally (20 samples per batch): the fastest sample moved
0.05 ms -> 0.08 ms between idle and a 3,374-file parallel run, while
p95 of those same batches swung 0.09 ms -> 0.50 ms.

* test: target question card title by testid instead of text

Add data-testid to NativeChatQuestionCard's title element and update the
e2e test to query by testid with a text filter. The transcript row also
renders the question text, so the previous test could match either location,
causing flaky results. Gating on the card's own title node ensures the
assertion verifies the card is actually rendered.

Fixes #20724
2026-09-17 00:51:34 -07:00
0e3b71f605 fix(session): give an SSH workspace one owning partition so its tabs stop round-tripping as deletions (#19572)
* fix(session): give an SSH workspace one owning partition so its tabs stop round-tripping as deletions

`workspaceSessionPartitionHostId` answered differently depending on who asked: the
renderer mapped an SSH worktree's session to the `local` blob, the main-process
runtime read-modify-wrote `ssh:<targetId>`. One workspace's session lived in two
stores and no reader reunited them, so whatever landed on the unread side did not
read as unknown — it round-tripped as absence. The remote-workspace upload is a
`replace-session` patch, which turned that absence into deletion on the host, and
the next pull applied the deletion locally and re-poisoned the snapshot.

Collapse the two answers into one: every non-'local' host owns its partition.
Boot hydration and the export fallback now read the SSH partition, and rows a
shipping build left in `local` are folded back in once, gap-filling only — an
empty tab row is a gap, never proof that anything was closed.

Folder workspaces deliberately keep their existing 'local' routing: boot
discovers SSH partitions from the repo catalog, so an SSH target that owns only a
folder workspace has no partition any reader enumerates. They are still adopted
back out of an SSH partition when a repo does name the host.

Fixes #12721
Supersedes #12722

Co-authored-by: Robert Nisipeanu <github@nisipeanu.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>

* test(session): pin the old-client empty-publish skew direction

* fix(session): adopt every workspace the host partition names, not only tabbed ones

Review caught that gating adoption on `host.tabsByWorktree[key].length > 0` traded
the #12721 deletion for a narrower one. The write path routes EVERY worktree-scoped
field to the owning partition, so an SSH workspace with open editor files or browser
tabs and no terminals had all of it dropped on every restart — and unlike terminal
state it cannot be recovered from the host snapshot, which carries terminal fields
only, so an unsaved `dirtyDraftContent` was destroyed outright.

The defect was not a missing field. It was a hand-maintained field list deciding what
the read recovers while the write used the ownership table, so the two could disagree.
Adoption now walks `WORKSPACE_SESSION_FIELD_OWNERSHIP` with an exhaustive switch, and
a new ownership kind is a compile-time decision rather than a silent omission.

Session keys are normalized through the shared `normalizeWorkspaceSessionKeyToWorkspaceId`
so host-qualified visit recency (`ssh:target|worktreeId`) reaches its workspace, and the
regression is pinned by feeding the shipping split's own output back through the real
boot read rather than a hand-built fixture.

Co-authored-by: Robert Nisipeanu <github@nisipeanu.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>

* fix(session): stop adoption overwriting rows it was never told about

Three losses, one cause: the reader walks its own description of the
partition layout while the writer walks another, so the two agree on
which ownership kinds exist and not on what a kind means.

- An empty host row replaced a populated base row, destroying an unsaved
  dirtyDraftContent the header comment says must never be destroyed. The
  host holding nothing is not evidence the base is wrong.
- A contested bare id was adopted as if local and ssh:<target> were one
  workspace written twice, which is exactly the id where that premise is
  false. The read already reached that verdict and adoption could not ask
  for it, so it is passed in; contested keys are gap-filled, never
  replaced. mergeWorkspaceSessionsWithHostShadow now reports the real
  contested set, which primaryHostBySessionKey never was.
- Tab-, pane- and file-keyed rows are adopted through the split's own
  indexes, so unified-only tabs come back and the pane key is parsed once.
- A bare lastVisitedAtByWorktreeId key only fills a gap; the split has a
  dedicated branch for that field and the reader had none.

* test(session): pin the tombstone/gap boundary the two readings meet at

An explicit empty tabsByWorktree row means the user closed the last
terminal; adoption reads an empty base row as a gap to fill. Same value,
opposite readings, so the boundary is asserted rather than argued: the
tombstone lands in the owning partition, restores as a present empty row
rather than a deleted key, is declined by the real seeding predicate, is
published as an empty list, and the legacy-transition resurrection
happens once and cannot recur.

* docs(reliability): record the adoption guards and the tombstone boundary in the gate

* test(e2e): read the SSH restart assertions from the partition that owns them

ssh-cold-activation-restore asserted persistence through session.get()
with no host, which is the local partition an SSH worktree's rows no
longer live in. The invariant it means to check is that the state is
persisted where the boot read will find it, so it now unions local and
ssh:<targetId> and stays correct on both layouts.

Confirmed the product invariant separately rather than by the edit: the
behavioural half of both tests - the full app restart, the active
worktree, the eager terminal remount and the PTY-owner reclaim against a
real Docker OpenSSH host - runs after this check and passes. 2 passed in
48.9s.

* test(e2e): read ssh-restart-tab-accumulation from the owning partition too

Same layout-coupled read as ssh-cold-activation-restore: the pre-quit
flush asserted through session.get() with no host. Verified against a
real Docker OpenSSH target - both repeated quit/relaunch cycles keep
exactly the restored SSH tabs, no accumulation and no loss. 2 passed in
52.9s.

* fix(lint): clear the casting gate on the partition adoption

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. Most of the
round-trip fixtures did not need a cast at all -- three were hiding
wrong-shaped literals (a browser workspace keyed 'name', a unified tab
keyed 'type', a layout keyed 'direction'), now written as the types they
stand for. The adoption reads narrow through an isRecord predicate
instead of casting, which also stops a null entry throwing out of
Object.keys. What is left is dynamic-field writes and unknown-typed IPC
returns, each with its own SAFETY rationale.

* fix(session): give an SSH folder workspace one owning partition boot can find

The partition owner rule already names `ssh:<targetId>` for a repo-backed worktree, but
`getFolderWorkspacePartitionHostId` still answered 'local' for a folder workspace while
main's `RuntimeWorkspaceSessionController.getPreferredHostId` answered `ssh:<targetId>`
for the same key. That is #12723 unfixed for folder workspaces, and once the renderer
started writing `ssh:*` at all it got worse: a save's field-level patch carries only the
rows routed to that partition, so a `tabsByWorktree` write without the folder row erased
the row main had put there.

The reason the renderer could not route there was real - boot discovered SSH partitions
from the repo catalog, which cannot name a target whose only workspace is a folder. So
persistence now answers that directly over `session:list-host-ids`, and boot reads the
partitions that exist rather than the ones a catalog implies. Removing a folder workspace
prunes its rows from the owning partition too, or the census would adopt them back on the
next launch as a workspace the user already deleted.

Adoption now decides from the repo catalog instead of from co-presence. Two partitions
holding one bare `repoId::path` is not evidence of a collision - that is the exact shape
the repair exists for - so the verdict comes from `resolveWorktreeExecutionHost`: a repo id
registered on more than one host is contested and may only be gap-filled, and one the
catalog positively resolves to a different host is residue this partition does not own and
is not adopted at all. Without the second rule a stale partition sorting first won the read
and was then written into the live one. Nothing is deleted either way; the rows stay where
they are.

Finally, a workspace adopted out of a partition now routes back to that partition. Routing
used to re-derive an owner from the catalog, so a boot whose repos had not hydrated moved
the rows it had just reunited back into 'local' and re-stranded them. Contested ids are
withheld from that override, because routing the whole bare id to one host is the loss the
gap-fill prevents.

The publish path resolves each workspace's owner once for the whole publish, shared with the
projection, so the per-target catalog attribution does not repeat it per connected host.

* fix(session): drop a deleted workspace from every partition, not just the local blob

Adversarial review of the previous commit found three ways the partition census - which now
reads whatever persistence holds rather than what the repo catalog implies - keeps rows alive
that nothing should keep alive.

`deleteProjectGroup` pruned only the local blob, so every folder workspace under a deleted
group left its rows in `ssh:<targetId>`; the next boot adopted them back, named that partition
their owner and wrote them there again, forever. `removeFolderWorkspace` had the same hole for
a workspace whose partition its host expression could not name: main never persists a folder
workspace's `executionHostId`, and `RuntimeWorkspaceSessionController` can infer a connection
from the group's repos that the workspace row itself does not carry. Deriving the partition at
delete time is the wrong question - a deleted workspace owns nothing anywhere - so both paths
now remove it from every partition.

The third is on the read side. A contested id is deliberately withheld from the read-source
override so the write cannot carry one host's rows into another's partition, but the routing
that then re-derives an owner answers 'local' for an id the catalog cannot name. Adopting such
a row moved it out of the partition that owns it and into the blob: the two-store split this
change exists to remove. A contested id the assembled session holds no row for is therefore not
adopted at all. Gap-filling stays available for a contested id the session already names, since
that row's own partition is what the write follows. Declining to adopt leaves a row invisible
for one boot; it never deletes one.

Also: the folder-key guard in both catalog attributions was dead, because
`getRepoIdFromWorktreeId` hands back the whole key rather than nothing when there is no `::`.
The verdict was right and the resolution wasted; it now skips by shape. And the two type
assertions the changed-code casting gate rejected are gone rather than suppressed.

* fix(session): park the rows a partition read declines instead of letting the next write erase them

A partition write replaces each field with exactly what the unified session routed there. So a row
the read left out of that session is erased from its own partition the moment any sibling workspace
writes the same one - and with SSH partitions now the owning store, that row is then in no partition
at all. Three separate decisions produce such rows: residue the catalog attributes to another host,
a contested id withheld so the write cannot carry one host's rows into another's partition, and a
workspace the base already holds the live copy of. Declining to show a row was quietly deleting it.

The machinery for this already exists. `attachHostSessionShadow` writes a contested runtime
co-claimant's parked rows straight back into its own slice before the write, so the primary's write
cannot erase them; the ssh partitions simply were not among the slices the contention split
arbitrates. The read now parks everything it is not returning to an ssh partition into that same
shadow, and the existing re-attach puts it back. Leak, never kill - docs/reference/ssh-execution-
boundary.md - and a row no partition holds is unrecoverable.

Second, the contested branch of the tab adoption read `Object.hasOwn` as "the base has tabs here".
An empty list satisfies it, so whenever a legacy id happened to be contested, #12721's empty local
row won over the host's real one - the exact reading the module's own header, and the gate invariant
it is pinned by, say is wrong. An empty row is the gap this repair fills, so it is now treated as
one.

* test(session): pin the empty-base-row gap for a contested id

Mutation testing found the assertion missing: reverting the gate to `Object.hasOwn` left all 39
assertions passing, which makes the fix that reads an empty base tab row as a gap unguarded. The
#12721 shape does not stop being a gap because the id happens to be contested.

---------

Co-authored-by: Robert Nisipeanu <github@nisipeanu.com>
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
2026-09-16 22:24:33 -07:00
Jinjing 852ee907ee fix(e2e): stabilize flaky E2E tests against timing races (#20900)
* fix(e2e): stabilize flaky E2E tests against timing races

- Paired terminal: use stable cold activation assertion instead of
  racy one-shot read; background tabs park eagerly.
- Native chat: scope hydration assertions to transcript subtree to
  avoid false positives from UI chrome (worktree rows, tab titles).
- Onboarding: inject verified status snapshot with max sequence to
  prevent hydration from downgrading host health during skip-to-
  project-setup.
- Paired web: encode host health faults in snapshots with high
  sequence so real hydrations cannot outbid injected state.
- Quick open: clear prior tooltips and increase hover timeouts to
  handle streaming result remounting.
- Terminal attention: pass 'terminal-bell' to unread marker to match
  production contract (reads marker value, not presence).

* fix one last test
2026-09-16 15:27:35 -07:00