mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
4e45fd04a14f41accd28ea282cfbef93e353680f
878
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
438744ca77 | fix(opencode): preserve global config discovery (#21854) | ||
|
|
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. |
||
|
|
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> |
||
|
|
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 |
||
|
|
f016d38e9c | test(e2e): prove Pi EOF removes sidebar agent row (#21722) | ||
|
|
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. |
||
|
|
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. |
||
|
|
b0ec11f5b0 | fix(omp): redact credential references before status transport (#21673) | ||
|
|
5cf65d79fc | fix(omp): ignore nested task session ownership (#21663) | ||
|
|
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> |
||
|
|
eca2ff6730 | test(omp): keep transcript smoke resume proof current (#21661) | ||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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.
|
||
|
|
b6f6122bcd | fix(terminal): preserve idle Linux IME direct commits (#21567) | ||
|
|
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
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |