Commit Graph
9904 Commits
Author SHA1 Message Date
Neil d8bbcf2b1d perf(persistence): build the state file once per save instead of seven times
Every debounced save stringified the full persisted state, then ran two
`String.replace` passes per secret sentinel — one for the on-disk payload, one
for the guard hash. Each replace returns a rope the next one has to flatten
before it can search, so three sentinels cost seven flattened copies of a
4.65 MB state (a two-byte V8 string, ~8.9 MB each), and the state was then
UTF-8 encoded twice more: once inside `sha1.update(string)` and again inside
`handle.writeFile(payload, 'utf-8')`.

`applySecretSentinelSubstitutions` walks the state once with a single
alternation regex, encodes each literal run to a Buffer exactly once, and feeds
those same buffers to both the payload and the hash. Measured on the author's
4.65 MB store with three live secret slots: 48.8 MB -> 17.9 MB allocated per
save, 26.6 MB -> 0 of large_object_space churn, and 22.1 -> 15.1 ms (min) /
32.3 -> 16.9 ms (median) for build+hash+encode. Bytes on disk and the guard
hash are proven identical to the previous loop.

Separately, non-local host session partitions carried stale replicas of the
`browserUrlHistory` global — 589,807 bytes, 12.7% of the file — that neither
the split (which writes globals only to 'local') nor the merge (which reads
them only from 'local' unless local has none) can ever reach. The load path now
drops them when the local slice already holds the field. Only the two history
globals are dropped: the rest are read out of every partition by the worktree
ownership sweep or the mobile/runtime projections.
2026-09-02 04:02:22 -07:00
Neil f37d2fec97 fix(linux): land the reviewed Linux packaging stack on main (#18100)
* fix(linux): give the CLI one entrypoint by extracting the AppImage once

* refactor(linux): trim AppImage CLI registration seams

* test(cli): assert registration lock serialization

* fix(linux): fence AppImage terminal shim mounts

* fix(linux): accept extracted AppImage runtimes with APPDIR only

* docs(linux): make headless AppImage extraction runnable

* refactor(linux): import bundled launcher directly

* fix(linux): reclaim superseded AppImage payloads and packaged symlinks

Pruning removed 3215 of 3216 files from a superseded generation and always
stranded resources/app.asar, leaking ~105 MB per version update. Electron's
asar shim reports a *.asar file as a directory, so the recursive remove tried
to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it.
Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and
519M again once the payload is actually reclaimed.

removeExtractedAppImagePayload holds process.noAsar for the removal, counted
so overlapping removals cannot hand the shim back early, and the prune site
now warns with the path instead of swallowing the rejection. All three
removal sites use it -- staging cleanup and displaced roots leaked the same
way.

Also reclaim symlinks left by a packaged deb/rpm install, which the
extracted-cache-only rule turned into a hard conflict on a deb -> AppImage
migration, and name the remedy in the conflict error.

* fix(linux): bound the CLI registration lock wait

`retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per
attempt an IPC-driven registration could hang ~16 minutes against a wedged
holder with no feedback.

A legitimate holder is bounded by the extraction timeout, so wait that plus
slack and then fail with a message naming the lock file, rather than hanging.
`maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile.

* fix(linux): stop re-extracting the AppImage on inode metadata churn

The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime.
ctime moves on any inode metadata write -- `chmod +x`, which every AppImage
user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup
restore -- none of which alter a byte of the payload.

Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime
identical and moves ctime alone, so the key changed and the next launch paid
a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it
already had, then pruned the old generation.

Key on content identity instead. An in-place content change moves mtime and
almost always size; a replacement moves the inode. The existing
replace-in-place test still passes.

* fix(linux): stop CLI commands from falling through to Chromium startup

* refactor(cli): remove redundant command membership check

* test(cli): cover command-named project selectors

* fix(cli): redirect the open-url command before startup

* test(linux): cover AUR serve wrapper flags

* fix(linux): tighten CLI launch detection

* fix(linux): respect CLI flag value boundaries

* fix(linux): strip injected Chromium switches from CLI args

* fix(linux): report a missing display instead of dying in uv_close

* refactor(linux): read display locks without a preflight race

* fix(linux): preserve unverified external displays

* chore: format reliability gate manifest

* test(packaging): split runtime resource checks

* fix(linux): fail serve when no display is available

* fix(linux): do not treat a lockless X socket as a dead display

An X server writes its lock beside its socket and both survive a crash
(verified against Xvfb under SIGKILL), so a socket with no lock was never
left by a crashed server. It is an endpoint published from elsewhere: a
container bind-mounting only /tmp/.X11-unix, WSLg, or a foreign PID
namespace. Declaring those dead made the desktop gate exit(1) on displays
that work, with no workaround, and the serve gate refuse to start.

Liveness now splits by ownership. A foreign DISPLAY trusts a lockless
socket; Orca's own :99 does not, because removeStaleDisplayArtifacts
unlinks the lock before the socket and so manufactures that state itself --
adopting it would resurrect the orphan-socket bug and stop the cleanup from
self-healing. The stale-lock rejection is unchanged.

Also correct four doc statements this behaviour falsified.

* fix(linux): fail closed when a stale socket blocks the Xvfb rebind

Readiness only checked that /tmp/.X11-unix/X99 exists. A stale socket we
could not unlink still exists after our own Xvfb refused to bind, so Orca set
DISPLAY to a dead server and Chromium died in Ozone init.

Measured on Ubuntu 24.04 against the pre-fix build: with a leftover :99
socket and no lock, serve exits 139 (SIGSEGV), the socket inode is unchanged
before and after, and no lock is recreated -- it neither cleaned up nor
respawned. To a user that is a crash, not a misconfiguration.

This is reachable in the documented topology, where orca-xvfb.service has no
User= and runs as root while serve runs as User=orca: /tmp is sticky, so the
orca uid cannot unlink a root-owned socket, rmSync fails, and Xvfb exits with
the display already active.

Readiness now requires the display to actually be live -- our socket plus a
lock naming a running process -- so the same state reports an unusable
display and exits 1 with the existing diagnosis.

* fix(linux): recognise abstract X sockets and inherited Wayland fds

Two display setups this gate could not prove were refused outright, and on the
desktop path that is app.exit(1) with no workaround.

An X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which
leaves no filesystem socket to stat. Abstract addresses are kernel-owned and
vanish the moment the owner exits, so an entry in /proc/net/unix is proof of a
live server -- no lock file needed and no stale entry possible. Verified on
Ubuntu 24.04, where 139 such addresses were present.

WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so
there is no path to stat and WAYLAND_DISPLAY may be unset entirely. Its
presence is the display.

Both are consulted only after the filesystem-socket check fails, so no
existing verdict changes.

* fix(linux): never treat Orca's own display number as a foreign endpoint

Recognising a lockless X socket as live is correct for an endpoint published
from elsewhere -- a container bind mount, WSLg -- because an X server writes
its lock beside its socket and both survive a crash. It is wrong for
VIRTUAL_DISPLAY_NUMBER, because Orca's own teardown unlinks the lock before
the socket and so manufactures that exact state.

The managed branch was already strict, but a caller that sets DISPLAY=:99
explicitly takes the foreign path and skipped it, accepting a dead display
left by Orca's own interrupted cleanup. Route the managed number through the
strict probe on both paths.

Found by an adversarial audit of the asymmetry introduced earlier in this
branch; the documented systemd topology is unaffected because its Xvfb writes
a real lock.

* test(linux): add a packaged-artifact contract for the CLI launch paths

* test(linux): avoid buffered serve readiness detection

* test(linux): signal AppImage serve owner directly

* test(linux): tolerate readiness timeout boundary

* test(linux): add startup margin to shutdown oracle

* ci(linux): give package contracts timeout headroom

* fix(ci): route all Linux packaging contract changes

* test(linux): poll shutdown readiness without tail leaks

* test(linux): bound shutdown cleanup grace

* test(linux): assert on CLI output, not the harness's own control lines

run-cli-case.sh echoes `RESULT status=N case=<name>`, and the two cases named
*-skills asserted `expectOutput: 'skills'`. That substring was satisfied by
the case name in the harness's own line, so 2 of 8 cases asserted nothing
about the command -- gutting `skills` entirely would still have gone green.

Control lines are now excluded before matching, and both cases assert the
rendered help header, which only real help output produces. Verified on an
Ubuntu 24.04 host: 8/8 still pass against a stack-tip AppImage.

Also register the gate in reliability-gates.jsonc, which #15085 added a CI
Docker gate without. Red/green is recorded from a stock release AppImage
failing 4 of 8, three of them at status 133 (SIGTRAP).

* fix(linux): require static AppImage runtimes (#17319)

* test(linux): reject a wrong-architecture native binary at packaging time

Cross-building the arm64 slice on an x64 host silently packed an x86-64
`pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and
shipped the host's binary anyway. Every gate here inspects symbol versions,
which are perfectly valid on the wrong architecture, so nothing noticed.

Observed on a Raspberry Pi 5: the packaged app loaded, then failed with
"Failed to load native module: pty.node", and the launch contract reported
3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64
`pty.node` took the same build to 8/8.

Compare ELF `e_machine` against the slice being packaged and fail with the
offending path. Checked before the glibc pass, because a wrong-architecture
binary's symbol versions are valid but meaningless and would send the reader
down the wrong path.

Release CI builds arm64 on a native runner, so this guards local and future
cross-builds rather than a shipped artifact.

* test(linux): judge per-arch vendored binaries against their own path

The first CI run of the architecture gate failed the x64 package job on
`@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on
purpose: the package ships every architecture and its loader picks the match,
so its presence in an x64 build is correct.

Judge a binary against the architecture its own path names, falling back to
the slice when the path names none. That keeps the case this gate exists for
-- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what
shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through.

Dry-run over the real dependency tree flags nothing for either target arch.

* fix(linux): move deb/rpm update installation outside Orca (#17318)

* fix(linux): complete deb/rpm package metadata

* fix(linux): preserve CLI link during package upgrades

* docs(linux): document local RPM build prerequisites

* fix(linux): move deb/rpm update installation outside Orca

* fix(updater): preserve Linux recovery across stale events

* fix(updater): fence stale downloaded events by active target

* fix(updater): preserve active Linux package recovery

* test(linux): keep workflow order assertion in scope

* test(updater): assert stale recovery stays silent

* fix(updater): preserve Linux package recovery after checks

* refactor(updater): keep Linux marker message with status

* fix(linux): describe the right manual update path for deb/rpm hosts

A remote host installed from .deb or .rpm now reports
manual-service-update-required, and the guidance told the operator to
"update through the service manager that starts this server" -- which is
correct for unsupported-headless-serve but wrong for a package install,
where nothing about the remedy involves the service manager.

Say both, keyed on how the host was installed.

* docs(linux): document orcad update restart safety

* docs(linux): scope restart census omissions

* docs(linux): use absolute service CLI launcher

* fix(serve): validate in-process serve options before startup (#17683)

* fix(linux): stop offering updates a distro-managed install cannot apply (#17918)

Closes #17702.

The resources/package-type marker is authoritative but never checked against
the host, so any repackager that unpacks Orca's .deb -- AUR, Nix, a container
rebuild -- inherits `deb` verbatim. Install feasibility was then computed
after a ~165 MB download, so those users got check -> download -> a card
promising an install command -> a dead end.

Validate the marker against the host: a deb/rpm marker with no matching
package manager in the trusted directories means a package manager owns this
install. This reuses the exact lists and resolver that
buildLinuxPackageInstallCommand already loops over, so a false positive is
impossible by construction -- any host flagged here would have failed with
no-package-manager after the download anyway. The gate only moves that
verdict earlier. Verified across Debian 12, Ubuntu 24.04, Arch, Fedora 40 and
openSUSE Leap: no false positive on a real deb host, correct on every
repackaging host.

The release is still reported, because the user does want to know 1.4.194
exists and to update through their distro; only the download path is closed.
`externallyManaged` is an additive optional field on the existing `available`
status, so older paired clients decode it unchanged. downloadUpdate() refuses
authoritatively, since main owns this verdict rather than the card, and
unwinds any pinned-build state first -- a Linux pinned jump resolves to
'release', and stranding isPinnedBuildActive would silently kill every
background check for the rest of the process.

Note the fix the issue suggests cannot work: electron-updater builds a
PacmanUpdater whose doDownloadUpdate looks for a .pacman asset Orca does not
publish, then dereferences undefined.

* style(cli): restore prettier wrapping on install error copy

* test(linux): re-pin the child-process ratchets and the batch-shim allowlist after the merge
2026-09-02 03:08:01 -07:00
Neil aa3ae6f56e fix(ssh): close the pty master fd leak on relay hosts too (#17920)
* fix(ssh): close the pty master fd leak on Linux relay hosts

The app gets the FD_CLOEXEC patch through pnpm patchedDependencies (#17914);
the relay installs stock node-pty from npm, where no pnpm patch reaches. Linux
is where that matters -- it is the only relay platform that takes forkpty()'s
no-atomic-O_CLOEXEC path, and it is also the only one that already compiles
node-pty at install time, so the fix costs a second compile rather than a first.

Ships the patch as a relay asset applied like the existing Windows console-list
one, and rebuilds only after the probe has proven node-pty loadable. The rebuild
is non-fatal by construction: the working build is moved aside first and moved
back on any failure, a failed attempt drops a skip marker so the compile is
attempted at most once per relay directory, and the caller swallows the whole
step. macOS and Windows relays never run it.

Measured on node:22 with a relay-style npm install: before, the master is
cloexec=false and shows up as `26 -> /dev/pts/ptmx` in both a later pty child
and a later child_process child; after, cloexec=true and neither child sees it.

Closes #17915.

* test(ssh): feed the cloexec patch exec to the hand-rolled namespace fixtures

These sequences are positional, so the new Linux-only patch exec swallowed the
READY slot and every install/repair case timed out waiting for the relay.

* fix(ssh): patch the pty master before publishing the shared native-deps tree

* fix(ssh): refuse to publish a native-deps tree whose cloexec patch did not take
2026-09-02 03:02:27 -07:00
Neil 62e9949141 perf(renderer): index worktree owner lookups instead of rescanning every workspace (#18130)
`worktreeUsesRemoteConnection`, `getRemoteConnectionIdForWorktree`,
`worktreeUsesWslPath` and `rightSidebarShowsPullRequestData` each did
`Object.values(state.worktreesByRepo).flat().find(...)` plus a linear
`repos.find(...)`. They are called from unmemoized Zustand selectors
(`use-tab-agent.ts:263`, `use-visible-review-refresh.ts:45`), so every store
write re-ran the whole scan once per open tab.

Measured on a real instance (10 repos / 423 worktrees / 382 tabs): the `.find()`
predicate alone ran 1,320,424 times in 30s — 44,000 worktree visits/sec — while
the app was idle.

Switched to the existing WeakMap-cached `getIndexedWorktreeMap` /
`getIndexedRepoMap` from `store/worktree-repo-index.ts`, matching what
`connection-owner-resolution.ts` already does. Same duplicate-id and
host-collision semantics; no behavior change.

Benchmark at that scale, 200 store writes x 382 tabs x 3 lookups:
  before 2.762ms per store write
  after  0.167ms per store write   (16.6x)
At ~20 store writes/sec that is 55.2ms/sec of renderer CPU down to 3.3ms/sec.

The new scale test counts worktree `id` reads: 160,000 before, 800 after.
2026-09-02 03:01:07 -07:00
Neil 0da52453a7 fix(settings): surface why CLI registration failed (#18125)
The Settings CLI panel treated every resolved `cli:install` as a success,
so a refusal that arrives as data (conflict, missing launcher, unreadable
Windows PATH) produced a green "Registered `orca` in PATH." toast while the
switch stayed off. A thrown refusal fared little better: the raw Electron
`Error invoking remote method 'cli:install': ...` string went into a toast
that then disappeared, leaving the panel indistinguishable from "not yet
installed".

Inspect the returned status with the predicate the onboarding and
agent-skill flows already use (`state !== 'installed'`), unwrap the IPC
transport prefix off thrown installer messages, and persist the existing
main-process reason inline per STYLEGUIDE (toasts disappear; errors the
user must act on stay inline). No new error taxonomy — the reasons already
carry path and remedy; a conflict status, which names the path but not the
remedy, gets the installer's own remedy sentence.

Closes #3952
2026-09-02 02:55:38 -07:00
Neil 34999e328e fix(orcad): stop demanding a spawn-helper only macOS builds (#18122)
node-pty declares the spawn-helper target inside binding.gyp's OS=="mac"
block and pty.cc execs it only under __APPLE__. Asserting it on
`!== 'win32'` made every Linux orcad boot degraded with
spawn_helper_missing while its terminals worked fine.

Route all four sites through one shared `usesNodePtySpawnHelper`
predicate: the precondition verdict, the prebuilt slot install, the
+x repair, and the prebuilds build script (which threw outright on a
Linux slot build).

Fixes #17844
2026-09-02 02:49:09 -07:00
Neil 8197268956 fix(pty,remote): close the pty master fd leak, and two remote-terminal defects (#17914)
* fix(pty,remote): close the pty master fd leak and two remote-terminal defects

so on Linux every later child of the process -- both later pty children and
plain child_process spawns -- inherits it and keeps the /dev/pts device alive.
Measured on Linux with stock node-pty 1.1.0: master fd flags 0404002
(cloexec=false), and 17 -> /dev/pts/ptmx present in both a later pty child's
/proc/self/fd and a later child_process child's. Extend the existing node-pty
patch with pty_cloexec() on both PtyFork spawn paths; after the patch the flags
read 02404002 (cloexec=true) and neither child sees the master. This covers the
app and terminal daemon only -- the SSH relay installs node-pty from npm on the
remote host, so it stays exposed (see the report).

rejecting inspection as a renderer-global unhandledrejection, which an
unreachable runtime produced on every cadence tick.

path cleared the close intent for it exactly like a dropped connection, so a
host that keeps republishing the dead surface re-materialized the pane the user
just closed. Keep that intent and drop its TTL. Also route the banner's
"Remote terminal was closed." line through translate() so it stops mixing
English into a localized banner.

* test(pty,remote): make the fd-leak evidence positive and size the close intent to its RPC

The Linux 'does not hand an earlier pty master to a later pty child' case only asserted that ptmx was absent from the captured listing, so any run that produced no listing passed without inspecting a single fd. Block the child on stdin, emit a sentinel, and assert both the sentinel and a real /dev/pts fd row before the negative assertion. Verified in node:24-bookworm: passes with the patch, and with pty_cloexec() reverted it fails on four inherited /dev/pts/ptmx rows.

The close intent's TTL was a 10s literal while the close RPC that can still answer tab_not_found had its own 15s literal. A host that answered slowly while republishing the surface had its intent evicted by the republish path's own pending-check, so makeWebSessionCloseIntentDurable found nothing to flip and #9194 reproduced. Derive the TTL from the shared session.tabs RPC timeout so the two cannot cross, with an invariant test and a regression test for the slow answer.
2026-09-02 02:23:45 -07:00
Neil 4bc20cb842 fix(wsl): name an explicit Windows cwd for wsl.exe spawns (#17834)
* fix(wsl): name an explicit Windows cwd for wsl.exe spawns

Removing the worktree Orca was launched from broke every wsl.exe spawn for
the rest of the session. The WSL command builders passed `cwd: undefined`
meaning "the directory is inside the command" -- but CreateProcessW reads
NULL as "inherit the parent's", and the parent's was a \\wsl.localhost path
Linux had just deleted.

Fixes #16463

* fix(wsl): name the spawn directory at the six remaining wsl.exe sites

The first commit fixed the WSL command builders. Six spawn sites were left
inheriting the process cwd, which is the same deletable `\\wsl.localhost`
worktree: `wsl-availability` (both probes), the WSL filesystem watcher, the
agent-hook relay launch, the UNC delete, and the local worktree filesystem.

`wsl-availability` is the one that matters most, and it turns the bug into a
latching false negative. `isRetryableWslProbeFailure` returns false for ENOENT,
so a spawn that failed only because the inherited cwd was gone is cached as
"WSL is not installed" on the 10-minute definitive TTL with exponential
backoff up to 30 minutes. Git keeps working and Orca reports WSL unavailable --
worse than the bug being fixed.

ENOENT stays non-retryable. It is answer-shaped for the reason it is meant to
be -- wsl.exe is not on PATH -- and naming the directory is what removes the
one cause that was not. Making it retryable would instead re-probe every
non-WSL Windows machine on the short window, and would leave the false ENOENT
in place for the other five sites, which have no cache to correct.

Three of these are also on the `runWslProcess` W3 migration allowlist; this is
the interim until they move, and matches what #17837 does inside the runner.
2026-09-02 01:39:48 -07:00
5dc1195a47 fix(native-chat): keep disabled CLI models out of the Claude picker (#18055)
* fix(native-chat): keep disabled CLI models out of the Claude picker

The Claude CLI advertises models it cannot run yet as disabled placeholder
rows. On 2.1.237 `list_models` returns a sixth row alongside the four real
models:

  {"value":"cc-update-required-1","displayName":"Fable 5.1 (disabled)",
   "description":"Update to 2.1.255+ to use Fable 5.1","disabled":true}

`toListedModel` never read `disabled`, and for Claude the discovered list
replaces the seed catalog verbatim, so the picker rendered that row as a
selectable model and `/model cc-update-required-1` went to the CLI. It was
also adoptable as a launch default, putting the sentinel behind `--model`
on spawn. Drop disabled rows at the parse choke point, which both the
native-chat picker and commit-message model discovery share.

The two adjacent fixes are the same version-pinning bug the placeholder
announces. `compactTerminalText` strips only whitespace, so a point release
keeps its dot and the pinned consent literals (`fable5uses…`,
`switchtofable5?`) stop matching a "Fable 5.1" prompt — the switch would
degrade to `unknown` instead of `interaction-required`. Likewise the scoped
weekly usage window matched `display_name === 'fable'` exactly, so it would
disappear once the scope is named "Fable 5.1".

Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4

* fix(native-chat): make the Fable consent match version-optional

Probing a 2.1.258 CLI shows the shipped Fable 5.1 row carries displayName
"Fable" with the version only in the description:

  {"value":"claude-fable-5-1[1m]","resolvedModel":"claude-fable-5-1",
   "displayName":"Fable","description":"Fable 5.1 · Most capable for …"}

So the consent prompt may name the model with no digits at all. Requiring
a version would have missed that, the same way the old pinned literal
missed "Fable 5.1". Accept both.

Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4

---------

Co-authored-by: Merge Sim <sim@local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-02 01:34:58 -07:00
Neil 7eb13c184c fix(ssh): keep remote PowerShell commands inside what sshd's cmd.exe accepts (#17947)
* fix(ssh): keep remote Windows commands inside cmd.exe's command-line limit

Windows OpenSSH runs every exec request through sshd's DefaultShell, which is
cmd.exe on a stock install, and cmd.exe refuses a line over 8191 characters with
exit 1 and a localized "The command line is too long". `-EncodedCommand` spends
2.67 characters per script character, so five commands on the first-connect path
were already over: the stale upload-stage recovery that opens a fresh install
(23,210), promote (20,646), cleanup (19,798), the install-lock steal (11,798)
and reserve (9,434). A Windows-to-Windows `ssh:connect` died on the first of
them before the relay was ever uploaded (#16126).

powerShellCommand now falls back to a gzip self-extracting bootstrap once the
inline form passes the budget - these scripts are repetitive enough that the
worst one lands at 6.5KB - and throws a message naming the limit if even that
cannot fit, rather than letting cmd.exe answer in the host's locale. Commands
that already fit are byte-identical.

The real-binary PowerShell suite in ssh-relay-upload-stage-commands.test.ts
exercises the bootstrap end to end, including `exit` and here-string semantics
through Invoke-Expression.

* fix(ssh): cite the real command-line budget and reuse the cmd.exe ceiling
2026-09-02 01:29:30 -07:00
Neil 99d9111653 fix(relay): fail an over-budget RPC response, not the connection (#17968)
The relay's control lane is a shared 1 MiB budget, and `sendResponse` admitted
responses onto it with the fatal default: once the lane was full, admission
closed the client. A ~900 KB `fs.listFiles` reply from a large remote workspace
therefore took down the whole remote session -- every terminal on it -- rather
than failing the one Quick Open request. The substitute `ResponseOverCapacity`
frame already there only covered the `legacy-response` lane, because the fatal
close beat it to the client.

A JSON-RPC response is the droppable class of control frame: it carries an id,
so one caller can be told and can retry. `pty.replay` and `notifyControl` keep
the fatal default -- they are never re-sent, and a silent drop there desyncs the
client with nothing to retry. Both response enqueues now pass
`controlOverflow: 'reject'`, so the substitute error is what the caller sees;
in the corner where even ~150 bytes will not fit, the caller's own 30s request
timeout settles it and the session survives.

Old clients are unaffected: they already decode this error code and message
generically (`ssh-channel-multiplexer.handleResponse` rejects the pending
promise with both), and the frame shape is unchanged. What changes is that a
listing which used to drop the connection now returns an error on it.
2026-09-02 01:29:26 -07:00
hwantageandNeil 75dcda438f feat(editor): add Show Whitespace toggle option in diff viewer (#15120)
* feat(editor): add Show Whitespace toggle option in diff viewer

- Add `diffShowWhitespace` boolean option to `GlobalSettings` (defaults to false).
- Pass `ignoreTrimWhitespace: !diffShowWhitespace` to Monaco DiffEditor options in `DiffViewer`.
- Expose "Show Whitespace" checkbox in `EditorPanelMarkdownActionsMenu` for diff surfaces.
- Add unit tests for diff whitespace action menu binding in `EditorPanelMarkdownActionsMenu.test.tsx`.

* fix(editor): apply Show Whitespace to combined diffs

Honor the persisted preference in DiffSectionBody as well as DiffViewer,
add a combined-diff toolbar control, and extract a shared Monaco option
helper with settings UI and unit tests.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-02 01:24:29 -07:00
Neil 6d61305a96 docs(ssh): record that an app update strands relay-backed terminals as unverifiable (#17972)
* docs(ssh): record that an app update strands relay-backed terminals

The boundary doc listed two ways remote work can stop and omitted the third
outcome, where the work does not stop but becomes permanently unreachable
(#13852). Names the mechanism, keeps it in the live/unverifiable/exited
vocabulary, and contrasts it with the daemon's protocol-versioned endpoint.

* docs(ssh): cite the boundary's sources by symbol so the refs cannot rot
2026-09-02 01:18:57 -07:00
Neil c91edafec0 fix(web): declare a socket dead even when its probe cannot be sent (#17838)
* fix(web): declare a socket dead even when its probe cannot be sent

* chore: land the shared liveness policy with its first real adopter instead

* docs: drop the reference to a file this PR no longer adds
2026-09-02 01:11:35 -07:00
Neil e02347ae9b fix(ssh): keep the restored relay session id when a tab has not rebound yet (#17881)
Hydration nulls tab.ptyId, empties ptyIdsByTabId, and never restores
lastKnownRelayPtyIdByTabId, so between restore and rebind the persistence
layer saw no evidence a relay-backed tab owned a session and dropped both
remoteSessionIdsByTabId and activeWorktreeIdsOnShutdown - overwriting the
handle the local file and the relay snapshot still held. Losing them is
self-reinforcing: the next startup has nothing left to reconnect from.

Count the two reconnect maps the orphan sweep and retirement planning
already treat as live ownership. The !tab.ptyId sleep guard is unchanged.

Fixes #17743
2026-09-02 00:48:51 -07:00
Neil 3924b7276f fix(automations): report an unverifiable process loss as lost, not failed (#17967)
* docs(exit-cause): pin why isProvenProcessExit(0) must stay true

isProvenProcessExit asks whether the process ended; the cause resolvers ask
why. Their disagreement on 0 is the design, not a defect: login(1) wraps
every macOS local PTY once the TCC preflight passes, so routing
hostReportsChildExitStatus through the predicate would leave every pane a
user closed with `exit` mounted forever.

No behavior change; comment and regression tests only.

* fix(automations): report an unverifiable process loss as lost, not failed

Two automation readers consumed the raw PTY exit code with no liveness
check, so the -1 unverified sentinel — on SSH, a live relay whose reattach
failed — was published as status 'dispatch_failed' with "Automation process
exited with code -1." The run was asserted finished when all that happened
was that we lost contact.

Route both through the existing vocabulary:

- The completion tracker records no result for an unproven code. The run
  keeps its non-final 'dispatched' status, so it is never evicted and never
  shown as Failed, and stays owned by main's AutomationRunCompletionWatcher,
  which already reports a genuinely unobservable run truthfully ("lost the
  terminal for this run") rather than inventing an exit code. finalize() is
  never reached, so a terminal whose process cannot be proven dead is never
  closed. A later done can still complete the run.
- Both runtime `terminal.wait` readers defaulted an absent status to 0,
  minting a clean finish out of no evidence. They now share
  runtimeWaitExitCode, which defaults to the new UNVERIFIED_PROCESS_EXIT_CODE.
- The background-session exit handler no longer clears the tab-PTY binding
  on an unverified loss, matching pty-exit-hibernate.ts, and marks the tab
  so orphan cleanup cannot sweep an agent that may still be running.

A proven exit is unchanged: 0 still completes and finalizes, and a real
nonzero failure still reports dispatch_failed.
2026-09-02 00:31:27 -07:00
Neil 0f2fe13374 fix(remote): stop absence and rotation from latching terminal UI state (#17945)
Three latches that only cleared on an event that could no longer arrive.

- attachHostSessionMirror read "no terminal surface for this tab in the
  inventory snapshot" as removal evidence and surfaced "Remote terminal was
  closed." with no recovery epoch, no parked retry and no path back. A
  snapshot with nothing published for the tab is a client-side view of a host
  that may still be republishing: keep polling inside the bounded window and
  expire as unknown liveness. Positive absence (the tab's surfaces are listed
  and this leaf is not among them) and a host tab_not_found/terminal_not_found
  response still retire as before. Refs #17825, #15141.

- markDisconnected() cleared the retry timer, dropping the pane from the
  scheduled-recovery registry, while the deadline it imitates deliberately
  stops the timer and keeps the pane revivable. A UI latch was strictly more
  destructive than exhausting the whole recovery budget. Refs #17824.

- confirmSurfaceInventoryAbsence folded publicationEpoch into the fingerprint
  identifying the surface, so the two required observations had to come from a
  single publication - the same evidence counted twice - and the count reset
  on every host republication. A host that re-publishes between inventories
  could never reach two, so a stale binding was never prunable. Refs #9585.

None of these retire a live PTY: removal still requires a fresh-liveness
inventory that is neither truncated nor host-scope-unverifiable, and a live
sighting still resets the confirmation.
2026-09-02 00:31:03 -07:00
JinjingandNeil c05dd5f4bb fix(tab-create): prevent file creation for spaced input (#18054)
Space-separated phrases without path syntax are web queries, not
file creation intent. Without this fix, accidentally clicking
'create file' leaves empty files on disk that outrank search results.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-02 00:26:37 -07:00
Jinjing 32e4c6be4a Auto-focus editor when opening new markdown file (#18071)
* feat(markdown-preview): autofocus editor when opening new markdown file

Users should be able to start typing immediately after creating a markdown file without an extra click.

* add e2e tests

* add e2e tests
2026-09-02 00:22:59 -07:00
Jinjing 6dcab30608 Update WeChat community to group 8 (#18095)
- Remove group 7 QR code asset
- Consolidate to group 8 only
- Update all localized documentation
2026-09-02 00:11:10 -07:00
github-actions[bot] bf88bcae1a Update README downloads badge 2026-09-02 06:45:21 +00:00
Neil 0dbe9d0504 test(ssh): dockerized relay fault injection with verdict assertions (#18017)
* test(ssh): add a dockerized SSH fault-injection lane with four fault shapes

The existing SSH reconnect specs all reconnect by calling ssh.disconnect() then
ssh.connect() - a clean cycle the client knows is coming. Nothing covered the
faults the reconnect machinery exists for.

Four shapes, each documented with why it is not the others: killing sshd's
per-connection forks (transport dies, relay survives), `docker pause` (silence
with TCP still established), SIGKILLing every relay.js (the only fault where
`exited` is the correct verdict), and a 48MB flood with nobody attached.

The relay-kill case is the one that makes the rest meaningful: every other case
asserts the session survived, which only means something if a genuinely dead
session is distinguishable. It is the only case where replacing the pane is
correct, so it pins the boundary in
docs/reference/ssh-execution-boundary.md rather than just testing reconnection.

The `docker pause` case pins the other side of that boundary: after 30s of
silence from a healthy host the pane keeps its PTY and its scrollback, because
loss of contact is never evidence of death.

No network-blackhole fault: reconnecting the fixture does not restore its
published port mapping, so that fault is not reversible on this container and
would strand the worker it ran on.

* test(ssh): fixme the flood case pending #18018

It fails in CI on its first real run: the pane keeps its PTY and repaints,
but a command run after the flood produces no output within the poll budget.
Same shape as #18018 and not caused by this spec. The three verdict
assertions around it stay enforced.
2026-09-01 23:35:47 -07:00
Neil 3d3b4f9053 fix(ssh): scope every activate() release path to the record its caller owns (#18038)
Two of the three release/cancel sites in RelayPtySourcePublication.activate()
acted on `current` unconditionally. A superseded transport re-entering activate()
therefore released — or cancelled and deleted — the delivery its own replacement
had just opened: releasing the fence resumes a send the replacement is still
rotating, and retiring it blanks the pane that owns it. The live path is the
unadmitted/subscriber branch, so guarding only the first site leaves the defect
exactly as it was; all three now act only on a record the caller still owns.

Also give the restore-required token its own toast copy. It must not join
UNREATTACHABLE_SESSION_SOURCES: that copy says "Open a new terminal", which here
abandons a running agent on a PTY the relay has just proven alive
(docs/reference/ssh-execution-boundary.md).
2026-09-01 23:35:38 -07:00
OrcaWinandOrca Worker 3ae51076b1 fix(tooling): run oxlint gates without a Windows .cmd shim (#17894)
* fix(tooling): run oxlint gates without a Windows .cmd shim

`check:code-quality:changed` spawned `pnpm.cmd` without a shell, which Node
refuses under the CVE-2024-27980 mitigation, so the gate died with EINVAL
before linting anything. Resolve oxlint's own Node bin and run it under this
process's node instead — no shim, no shell, no quoting question — and add a
ratchet so the idiom cannot spread back into config/scripts.

* fix(tooling): validate the react-doctor diff base and widen the shim ratchet

`base` reaches cmd.exe unquoted on the shell fallback, so reject anything
outside a git revision before spawning. The ratchet matched only a handful of
runner names, which let `vitest.cmd` through even though config/scripts already
spawns vitest, playwright and electron-builder; match any batch-shim literal
instead, walk subdirectories, and cover tests/tools.

* docs(tooling): state what the shim ratchet and diff-base check miss

Both comments read as complete accounts of their guard's coverage. The revision
class rejects reflog syntax like HEAD@{1}, deliberately, since braces have no
business in a cmd.exe-bound argument; the ratchet misses a drive-lettered
literal because a colon is not in its class. Say so beside the template-literal
ceiling already noted.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-01 23:21:26 -07:00
0c9c3c00cf test(ci): ratchet Windows-gated tests into both registration lists (#18047)
* test(ci): ratchet Windows-gated tests into both registration lists

PR CI has one windows-2022 job running a curated explicit file list. Every
other job runs on ubuntu, where a Windows-gated suite self-skips and reports
success -- so an unregistered Windows-gated file executes on no machine and
passes green with nothing to tell the author.

Scans every test file for the win32 suite-level gate spellings in use plus the
.win32.test.* filename, and asserts each one appears in BOTH the
"Test Windows-specific boundaries" vitest argv and WINDOWS_PACKAGE_TESTS: the
classifier decides whether the job runs, the argv decides whether the file
runs. The eight already-unregistered files on main are held in a shrink-only
debt list.

* fix(ci): detect compound win32 gates in the lane-registration ratchet

The gate matcher anchored its argument on the closing paren, so
`runIf(platform === 'win32' && hasAddon)` was not matched at all -- the
guard excluded real Windows-gated files by accident of a regex rather
than by design, and would have missed a compound gate on a file that
genuinely needed registering.

Match the condition followed by `)` or `&&`, and resolve named flags from
their assignment in the same file, so `RUN_REAL = platform === 'win32' &&
env…` used as `runIf(RUN_REAL)` is detected whatever the flag is called
and whichever polarity it was written in. That replaces the hardcoded
`isWindows`/`IS_WINDOWS`/`isWin32` names, which guessed polarity from a
name; an imported flag stays undetected and is now documented with the
live example. `||` compounds are rejected on purpose: they can run off
Windows.

Ten env-opt-in suites surface as a result. They are win32-gated but also
require an `ORCA_REAL_*` env var, so registering them would not make CI
run them; they go in MANUAL_OPT_IN, whose entries are asserted to be
genuinely compound and env-gated so the list cannot become a quiet
parking spot.

Also: reuse `scanSourceTree` instead of a fifth divergent walk in the
repo (its docblock records the incident where a hand-rolled walk scanned
`tests/e2e/.cross-version-checkouts/`), adding an `extensions` option so
it can see `.mjs`; strip comments so prose about a gate is not a gate;
skip `mobile/`, which `classifyPrJobs` can never report as registered;
assert exactly one `windows-2022` job, the premise the guard rests on;
cap growth of both grandfathered lists; and test that the self-exemption
covers nothing but this file.

Corrects two docblock claims that were false: that nothing in the repo
computes a gate indirectly (three files did), and that a compound gate's
registration was asserted while only its execution was not (neither was).

* fix(ci): make the manual-opt-in exemption prove the env read reaches the gate

`requiresEnvOptIn` proved the file MENTIONED an env var, not that the gate
DEPENDED on one, so `runIf(platform === 'win32' && hasAddon)` in a file
that happens to read `process.env.RUNNER_TEMP` parked as manual. That is
the native-addon-bytes shape -- a test CI could run -- and only the cap
number stood in the way. Now the win32 check must be compound and one of
its other conjuncts must read `process.env` itself or name a const that
does, which still accepts all ten listed suites.

The compound clause guarding that hole was itself unasserted: deleting it
left every test green. Two fixtures close it, including an env read on the
same line as a bare gate, which is the case that makes the `&&` do work
rather than decorate.

Split FLAG_ASSIGNMENT by polarity. One shared `&&` lookahead was right for
`===` (a second conjunct narrows) and wrong for `!==` (it widens), so
`p = platform !== 'win32' && x` used as `skipIf(p)` read as Windows-only
though it runs on Windows and on POSIX when `x` is false. The literal form
was already rejected; routing it through a flag flipped the answer.

Widen the one-lane assertion from a `windows-2022` equality test to any
`runs-on` that could land on Windows -- `windows-latest`, a label array, a
`{ group, labels }` object -- treating an unresolvable `${{ }}` expression
as Windows so it fails closed.

Docblock: the case-level count is now deliberately approximate. The
reviewer measures 26 against this guard's 31; the figure moves with which
gate spellings are counted, and the policy does not rest on it.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-01 23:21:22 -07:00
ee82feb776 fix(build): pin config/scripts LF so Windows can run their tests (#18056)
core.autocrlf=true ships in the Git-for-Windows system config, so a fresh
Windows checkout materializes config/scripts/*.mjs with CRLF. Vite's SSR
transform finds the shebang with /^#!.*\n/, and \r is a JS regex line
terminator, so the pattern misses on CRLF: the hoisted import/export
preamble lands at offset 0 ahead of the shebang, which then defeats the
code[0] === '#' guard that blanks it. A literal #! survives into the middle
of the module and every suite importing the script dies at load with
SyntaxError: Invalid or unexpected token.

Eight suites were unrunnable on Windows. .gitattributes already pinned
eight of these scripts individually; replace those with one glob over the
directory so the pin does not have to be remembered per file, and add a
ratchet that fails when a shebanged script is left on the platform default.

Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-09-01 23:21:18 -07:00
OrcaWinandOrca Worker c3c764fa03 docs(win): record why the PTY OSC 133 bootstrap keeps -EncodedCommand (#17875)
The MDE report lists src/main/daemon/shell-ready.ts as a contributing
"suspicious PowerShell" site and cites VS Code as using -Command. Measured
against a real VS Code fork install, VS Code's -Command payload is a one-liner
that dot-sources a *file*; that shape is execution-policy gated and is blocked
under Restricted and AllSigned, so it would silently drop OSC 133 -- and with
it foreground-process and exit-code tracking -- on the managed fleets MDE runs
on.

Inline -Command does carry the payload intact through node-pty/ConPTY
(powershell.exe 5.1 and pwsh 7.6.5), so the switch is feasible. It is declined
because no PTY site spells -ExecutionPolicy Bypass, AMSI and script-block
logging decode the payload either way, and the swap would put
$ExecutionContext.SessionState.LanguageMode, a Global:prompt override and
[char]27-assembled control sequences in clear text on every terminal's command
line -- higher-signal than the token it removes.

No behaviour change. Adds the rationale at the payload's source of truth,
one-line pointers at the three PTY launch sites, and a ratchet that both
launch builders must deliver the bootstrap byte for byte.

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-01 23:21:14 -07:00
OrcaWinandOrca Worker 2c4989ea94 docs(windows): document the EDR signal surface (#17856)
* docs(windows): document the EDR signal surface

Six Microsoft Defender for Endpoint incidents fired against Orca 1.4.192 in
eight days on one enterprise Windows 11 / Intune tenant. All six were
behavioural process-tree scoring, not signature hits; two escalated to
multi-stage incidents mapped to ATT&CK Execution and Collection.

Add a reference doc mapping each attack-technique-shaped behaviour to the code
that produces it and to why it exists: the renamed daemon image (T1036), the
per-process PEB read, encoded policy-bypassed PowerShell (T1049), caret-escaped
cmd.exe lines, and computer-use screen capture plus runtime-compiled MSIL
(T1113). Records that signing is not the gate -- reputation is signer plus
hash-keyed prevalence -- and carries the two evidence gaps the report noted.

Adds an engineer checklist, deployment guidance for admins (AV path exclusions
do not suppress EDR behavioural alerts; an MDE alert suppression rule does), and
an explicit pre-deployment warning about computer use.

* docs(windows): correct the PowerShell flag inventory and admin paths

Review corrections to the EDR posture doc.

The "encoded, policy-bypassing PowerShell" list conflated three different
shapes and was incomplete. Split it into the three tiers an EDR actually scores
differently -- bypass plus encoding, encoding alone, and bypass alone -- and add
the sites it missed, including windows-mobile-firewall.ts, which encodes a
script and launches it elevated through Start-Process -Verb RunAs. system-fonts.ts
(-Command) and desktop-script-provider-bridge.ts (-File) were listed as encoded
and are not. Notes that a raw grep under-reports, because the hook sites reach
-EncodedCommand through wrapWindowsPowerShellEncodedCommand.

Attribute the in-payload Set-ExecutionPolicy move to #16576 rather than to
#16003's measurement, which keyed on -WindowStyle Hidden + -EncodedCommand, and
record that the launcher's own tradeoff is unverified on a real box.

Admin guidance was missing two ways a suppression rule pinned to one full path
misses real activity: the .staging-<hex> sibling that exists mid-update, which
is when the update-cluster incidents fire, and the userData fallback when
LOCALAPPDATA is unset.

Also: state the measurement conditions on the process-table timings, note that
Hermes has surface even though we have no telemetry for it, note that the
uninstaller names are electron-builder-generated and in no repo file, drop a
volatile line count, and mark the per-operation computer-use shape as being
addressed by an unmerged change. Drops the duplicated AGENTS.md section, keeping
the indexed bullet.

* docs(windows): reconcile the EDR posture doc with the shipped remediation

Three claims in this doc became false once the rest of the Windows EDR set
landed, and two told engineers the opposite of what the release does.

The process-table section still described one shared snapshot taken with
`Memory | CommandLine | CreationTime`, argued that splitting the cache per
field set "would restore exactly the fan-out it exists to prevent", and
concluded the shape was unfixable because "the information is only in the
PEB". The split shipped (identity opens no handle at all), `Memory` is
retired, and the command line now comes from the kernel through
`ProcessCommandLineInformation` -- `ReadProcessMemory` is absent from the
compiled addon and a ratchet asserts it against the import table. An engineer
reading the old text would have concluded both fixes were dead ends.

The PowerShell site inventories were stale in three of four lists: the port
scan went native, every `-ExecutionPolicy Bypass` + `-EncodedCommand` pair
was dropped as a measured no-op, and of the unencoded-bypass list only
`wsl-cli-scripts.ts` survives. Regenerated against the merged tree, including
the sites that reach the flag through `wrapWindowsPowerShellEncodedCommand`
and never spell it, which a raw `rg` misses.

Incident-evidence sections are left alone: they record what the tenant observed
on 1.4.192, not what the code does now.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-01 23:21:09 -07:00
Neil 7f4a17d8eb perf(ssh): key relay native deps on the deps, not the bundle hash (#18033)
Relay node_modules lived inside `~/.orca-remote/relay-<version>+<hash>`, so
any byte change in `src/relay/` or the `src/shared/` it pulls in minted a new
directory and a fresh `npm install node-pty@1.1.0 @parcel/watcher@2.5.6`. On
Linux node-pty has no prebuild, so that is a node-gyp source compile on every
new bundle — eight of the fifteen deploy minutes, daily, on a dependency set
that is a pinned constant (#18009).

The tree now lives at `~/.orca-remote/native/<platform>-<depsHash>/node_modules`
and each relay directory symlinks to it. depsHash covers RELAY_NATIVE_DEPS, an
explicit epoch, and the bytes of every shipped `node-pty-*` patch artifact, so a
patch change mints a new entry rather than leaving hosts on a stale tree.

Three rules make one tree safe to share:

- A published entry is immutable. `.deps-complete` is written last, only after
  a probe on that host loaded both addons. Nothing installs, rebuilds or resets
  into a published entry: every `npm install` is prefixed with a symlink detach,
  so a repair on one directory can never `rm -rf node_modules/node-pty` out from
  under a live relay sharing the tree.
- Publication elects one winner with `mkdir`, so no client-side lock is needed
  and two deploys never write one tree. A loser keeps its own copy.
- Every failure degrades to today's per-directory install.

GC follows remote-install-gc.ts' discipline: a listing that does not end in its
own OK token, an unreadable link, or a reference whose shape this client never
writes aborts the whole pass. Deletion is tombstone-rename, re-read references
under the rename, then remove — a deploy that linked between the listing and the
rename gets its tree moved back. It only runs for a connection that could
compute a key, and never removes a pinned one.

Migration: a first deploy after this ships seeds its tree from a sibling relay
directory whose manifest pins the same versions, so an existing host does not
recompile once more. The seed is not trusted — it is a plain private install
until the normal probe loads it, and only then is it published.

Windows keeps the per-directory install: node-pty ships win32 prebuilts, so
there is no compile to avoid, and the console-list agent patch mutates the
installed tree in place, which rule 1 forbids for a shared one.

The POSIX scripts are exercised against a real tree under both /bin/sh and
dash, not just asserted on as strings.
2026-09-01 22:51:22 -07:00
Neil f9587f74f5 fix(ssh): repair a rebuildable node-pty failure once, instead of asking the user to reconnect (#17907)
* fix(relay): diagnose why node-pty will not load instead of hedging

The relay could only say "terminals are unavailable" and then list three
remedies for four different faults, none of which the user could verify
(#17830). Two things were destroying the evidence:

- `loadPtyUncached` caught the load error into bare `catch {}` blocks
  (pty-handler.ts:539, :551) and returned null. The only cause anyone had
  was discarded on the spot.
- node-pty's own loader walks three directories and rethrows only the LAST
  failure, so even an uncaught error arrives as `Cannot find module
  '../prebuilds/...'` — the GLIBC/ABI/arch sentence is already gone.

The relay now keeps the load error, recovers the real dlopen message with an
out-of-process load of the file node-pty would have opened, reads what
node-gyp configured the binding for (`build/config.gypi`), captures the
host's Node ABI, arch and glibc, and probes the toolchain only when nothing
was compiled. Each fault gets its own message naming values the user can
check: toolchain_missing, dependency_missing, abi_mismatch, arch_mismatch,
libc_floor, shared_library_missing, load_crashed, and load_failed which
quotes the loader verbatim. A probe that did not answer stays `unverifiable`
and prescribes nothing.

The classification is now also structured data on the error, so a client can
repair the host instead of printing a paragraph: an additive, schema-validated
`data` field on an existing JSON-RPC error, with `repairable` true only for a
proved fault that recompiling on the host actually fixes.

Reuses orcad's loader-message parsers and out-of-process probe rather than
adding a second copy; `classifyLoaderMessage` moves to a shared module and
gains architecture and missing-shared-library cases, which the orcad boot
precondition picks up too.

* fix(ssh): repair a rebuildable node-pty failure once, instead of asking the user to reconnect
2026-09-01 22:48:12 -07:00
Neil f9db653e14 perf(worktrees): gate worktree metadata hygiene on evidence, not on every listing (#18034)
* perf(worktrees): gate worktree metadata hygiene on evidence, not on every listing

Dangling `worktreeMeta` pruning rode the detected-worktree listing, a polled read
path. Each pass captured a prune expectation over the repo's whole metadata table
(a JSON.stringify per row) and then stat'd every path-missing candidate. Both are
O(all rows), and most rows are refused anyway — pinned by a persisted session, or
structurally unremovable on this host — so the work repeated forever without
converging, pinning the main process in fs completion callbacks (#17775).

Three changes, no behavior lost:

- Probe only rows a delete could still accept. Session ownership and structural
  removability are pure functions of persisted state, so deciding them before the
  filesystem inverts the cheap and expensive halves. The filter is advisory; the
  authoritative checks are unchanged, so it can only shrink the stat fan-out.
- Extract `isLocallyRemovableWorktreeMetadataRow` so probe-avoidance and the
  delete share one definition of removability.
- Gate the metadata + lineage prune on evidence instead of the listing: a worktree
  lifecycle event, a mutation that can make a row more removable (session-owner
  release, metadata removal, SSH lease release, automation run finishing or
  deletion, repo deregistration), or a git listing that differs from the one the
  last pass ran against. With none of those the pass is a provable repeat and is
  skipped, so a quiescent app does no hygiene work at all.

The gate deliberately ignores metadata writes that only add or update a claim:
the listing path itself stamps metadata, so re-arming on those would restore the
storm. A missed signal leaves a row in place until the next one; nothing is
deleted that would not have been deleted anyway.

* refactor(worktrees): fold repo prune-gate teardown behind one call

Merging both import blocks during the rebase pushed the file past the
300-line budget. The two calls are one intention -- retire this repo's
gate state on a full removal, and re-arm the shared inputs either way --
so name that in the module that owns the gate.
2026-09-01 22:47:29 -07:00
Neil 4f4872c424 fix(ssh): treat a markerless native-deps probe answer as unverifiable (#18011)
The repair path read "the probe answered, but nothing in the answer names a
dep" as "both deps are missing". The POSIX probe is fenced with
`|| echo MISSING`, so the subshell always exits 0 and the unanswered-probe
catch added by #17979 never ran. A node that cannot start (invalid
NODE_OPTIONS, OOM kill, exit 127) therefore produced a bare `MISSING`, and
every reconnect rm -rf'd node_modules/node-pty and node_modules/@parcel/watcher
and burned a 240s npm install that failed the same way. `.install-complete`
from the original install survives, so the relay kept launching with no PTY
and no file watcher, permanently, per host.

Only a marker line that actually names deps is evidence about them; anything
else is `unverifiable` and launches as-is. Also drop `2>/dev/null` from the
POSIX probe and carry stderr into the warning via a new execCommand `onStderr`
hook, so the reason node failed survives. stderr stays its own stream — folded
into stdout it would match the probe's own token strings.

The Windows branch shared the same parser and is fixed with it.
2026-09-01 22:45:29 -07:00
Neil 266b2ea190 fix(agent-status): report a stale pane that still holds a PTY as unverifiable, not idle (#18012)
* fix(remote): stop one unlabelled inventory tombstoning a live worktree mirror

#11495 Step C. `buildMissingWebSessionTabsRemovals` synthesised a `removed: true`
tombstone -- emptying a worktree's entire mirror -- for any tracked worktree
absent from a single inventory frame, without ever consulting the host's own
authority label. `mirror-settle` already refuses to settle an *empty* inventory
that is not `authoritative` (#16414, #16546); the strictly more destructive
action was ungated.

An inventory the host labels `authoritative` carries a complete PTY census, so
one omission is host attestation and removal stays immediate. An unlabelled
inventory is a degraded or version-skewed census: `unverifiable`, not `exited`.
It must now repeat before it can destroy anything, reusing the two-observation
shape of `confirmSurfaceInventoryAbsence`. A legacy host that never negotiates
the capability still converges after two rounds, so ghost rows cannot outlive
the fence.

The 14 tests from #13621 that blocked this were all written before the
`authoritative` label existed (#13621 landed 2026-08-11; the capability landed
2026-08-26 in #16546). #13621's own summary says "Reconcile each resumed host
from an authoritative inventory, including removals", so their fixtures are
retargeted to say so explicitly rather than weakened.

Refs #11495

* fix(agent-status): stop a reconnect replay restamping the staleness clock

#15317 correctness half. `receivedAt` was doing two jobs: delivery order and
evidence age. A relay reconnect replays every cached row, and `receivedAt` must
restamp to clear the connection watermark that `clearStatusEntriesForConnection`
raises -- so a pane stuck at `working` had its 30-minute deadline pushed out by
another 30 minutes on every reconnect. The TTL was never reached, which is why
this read as a tuning question.

Two clocks, not one rewritten clock:

- `receivedAt` is untouched. The transient-clear watermark and the four `<`
  ordering drops (`agent-status-event-applicator`, `agent-status-live-entry-builder`,
  `agent-status-cleanup-actions`) keep working unchanged. Restamping a replay with
  its original time would have made it `<= watermark` and dropped it outright,
  leaving the pane with no row at all.
- `evidenceObservedAt` is new, optional, and read only by the staleness
  comparison (`isFreshNonDoneAgentStatus`, `isExplicitAgentStatusFresh`, the
  freshness scheduler). Main holds it per pane across the transport clear -- the
  clear deletes the row on purpose, but the *age* of evidence a later replay
  restates is not a claim about the pane. Absent means "no separate observation",
  and every consumer falls back to `receivedAt`/`updatedAt`, so old hosts and old
  rows behave exactly as today.

Behaviour: a genuinely active pane keeps stamping the observation clock from its
real events, so it stays `working` across a reconnect. A pane whose relay
restarted replays nothing and still falls through to title evidence. A torn-down
pane drops its remembered clock in `clearPaneState`, so a reused pane key cannot
inherit one.

`AGENT_STATUS_STALE_AFTER_MS` is deliberately unchanged -- the window length
remains a product decision.

Refs #15317

* fix(sidebar): stop a stale agent row claiming the pane is empty

A stale non-`done` entry decayed to `idle` whether or not Orca still held the
pane's PTY, so "we lost the reporting stream" and "nothing is running here" were
the same display class. Split the destination on evidence already computed: with
a live PTY the row is `unverifiable` and reports the observer's own fact — how
long the silence has run — so the user can apply context Orca has no way to know.
With no PTY it stays `idle`.

Smart sort gains class 4 for it, between working (3) and idle (now 5): still
plausibly the most important pane, never outranking one that is reporting, and
never a claim that the agent finished. `unverifiable` stays renderer-local; the
dashboard card projection publishes today's `idle` because that vocabulary is
validated against a fixed allowlist in main and read by older pop-outs.

AGENT_STATUS_STALE_AFTER_MS is unchanged.

* fix(agent-status): decay a mirrored remote row on the replica's own clock

A paired client mirrored a remote host's status rows verbatim, host wall clock
included, and the staleness gate then computed `rendererNow - hostStamp`. The
effective window was 30 minutes plus or minus the two machines' skew: a host
running fast held every remote row permanently fresh, a host running slow decayed
them on arrival. The constant was never the lever there — the subtraction
straddled two clocks.

The replica now stamps `mirroredEvidenceReceivedAt` from its own clock when the
authority's observation advances, carries it forward across an exact repaint (a
restated observation is not a new one), and decays against it. Both sides of the
subtraction come from one machine; locally observed rows carry no stamp and are
unchanged.

The alternative the type comment named — carrying the authority's freshness
verdict — was rejected: a verdict is computed at publish time and cannot age
between snapshots, so once the host goes quiet the replica would hold `fresh`
forever. That is precisely the loss-of-contact case the window exists for.
AGENT_STATUS_STALE_AFTER_MS is unchanged; the clock rules move to
agent-status-freshness.ts to keep agent-status-types.ts under its line budget.
2026-09-01 22:44:50 -07:00
Neil b4ba3e97ff perf(worktree): defer fork-PR remote creation from create-time to first use (#17922)
* perf(worktree): defer fork-PR remote creation from create-time to first use

Fork-PR review worktrees eagerly ran `git remote add` + `git fetch` for the
contributor's fork (and pinned branch.<x>.remote) at create time, even for a
read-only review. That grows remote count unboundedly with review volume and
pays a network fetch nobody asked for yet.

Defer prepareWorktreePushTarget(Ssh) and the --set-upstream-to configure step
at create time (local + SSH, IPC + runtime create paths); persist the
pushTarget metadata untouched. Materialize the remote on demand the first
time push/pull/fetch/fast-forward actually needs it, via two shared
functions (materializeWorktreePushTargetRemote(Ssh)) reused across the
legacy IPC handlers and the RPC runtime sync commands. A cheap
`remote get-url <name>` probe keeps steady-state calls down to one extra
subprocess once materialized, instead of repeating the O(remotes) scan.

Add repo-local `remote.<name>.orca-created` config provenance, written when
the remote is added, so cleanup can recognize ownership of a remote that was
lazily materialized (and therefore never round-tripped through the store's
`remoteCreated` flag).

Refs #17828

* perf(worktree): materialize a deferred fork-PR remote on terminal spawn

An agent running raw git in a freshly opened fork-PR review terminal has no
usable upstream until an Orca-driven sync happens -- "sync through Orca
first" isn't available mid-task, and git pull/log @{u}.. hard-fail without
one (verified against real git). Fire the same on-demand materialization
used by push/pull/fetch/fast-forward from the single terminal-spawn
resolver (resolveTerminalWorkspaceLaunchTarget), fire-and-forget, so a
newly opened terminal gets a working upstream without blocking spawn.

* fix(worktree): retest deferred fork-remote CI failures, fix SSH provenance-marker RPC

Rewrites the 5 CI failures on the deferred fork-remote change (#17828) as
evidence, not fixtures: the SSH relay-upgrade/rollback/sibling-ownership
tests move to materializeWorktreePushTargetRemoteSsh, where that
unchanged logic now actually runs (create defers it to first sync).

While writing a stricter test that routes its mock exec through the
relay's real validateGitExecArgs, found that the SSH provenance-marker
write (`git config remote.<name>.orca-created true`) was unconditionally
rejected by the relay's generic git.exec (it blocks all non-read-only
config writes) -- a real bug that would break every SSH fork-remote
materialization against a live relay. Fixes it with a narrow
git.markRemoteOrcaCreated RPC, mirroring renameCurrentBranch, with a
graceful no-op fallback for relays that predate it.

* fix(worktree): scope post-#17887 test assertions past narrow-refspec config calls

Rebasing onto #17887's narrow-refspec `remote add` broke two broad `['config']`
call-filters into false positives/negatives, and the local materialize test still
asserted the pre-#17887 wide `remote add`/fetch-refspec forms.

* fix(worktree): restructure upstream restore, persist provenance, widen short-circuit refspec (#17828 review)

- Move upstream restoration to the materializer level so it runs on both the
  remoteAlreadyMatchesUrl short-circuit and the full-prepare path, not just
  buried inside prepare*.
- Persist {remoteCreated, remoteName} to the store on materialize so #17842's
  orphan sweep can see a lazily-created remote, including via desktop IPC,
  terminal-spawn, and the RPC host-callback paths.
- Widen the refspec on the local short-circuit path too (SSH's bare `remote
  add` refspec gap remains a documented, pre-existing limitation).
- Fetch the branch's tracking ref before restoring upstream when the
  short-circuit widens onto a *new* branch on an already-existing remote --
  a bare refspec-config widen never itself imports anything, so
  `branch --set-upstream-to` was hard-failing for a sibling worktree's first
  materialize (found via a real-git fixture, not just mocked unit tests).
  Skipped when the ref already exists so the common repeat-call case stays a
  local-only probe with no network round-trip.

* fix(worktree): merge duplicate shared/worktree/types import

oxlint --deny-warnings flags the split import as no-duplicates; full pnpm lint
was failing on it after the #17828 review restructuring.

* fix(worktree): scope the deferred fetch timeout to fetch calls, retarget stale create-time assertions

CI on the previous push failed 3 shards, all argument-shape mismatches:

- worktrees-wsl-runtime-routing.test.ts: the "restructure upstream restore" commit
  wrapped every call `prepareWorktreePushTarget` makes (remote, remote add, config,
  fetch) with DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS, not just the network fetch. Local
  git subprocesses never need a timeout; scope it to `args[0] === 'fetch'` only,
  matching the short-circuit path's existing pattern. Updated the test to expect the
  timeout on the fetch call specifically (point 5 legitimately adds it there), while
  every other call stays untimed.

- worktrees-create-metadata-persistence.test.ts (2 tests): stale from before this
  session -- create no longer mints a fork remote at all (#17828 deferred that to
  first sync), so asserting `remote add`/`fetch`/`remoteCreated: true` at create time
  no longer matches reality. Retargeted both tests to assert the deferred contract
  (no remote add at create, pushTarget persisted unmaterialized); minting itself
  stays covered by worktree-remote-push-target-materialization.test.ts and
  worktree-push-target-setup.test.ts.

Re-verified all 5 fixture points (mint upstream, store persistence, single-flight,
short-circuit refspec widen + fetch-missing-ref for local and SSH, finite timeout)
against a real git fixture after this fix -- all still pass.

* fix(worktree): hook pty:spawn into deferred push-target materialization (#17828)

triggerTerminalSpawnPushTargetMaterialization only fired for agent/background/
mobile terminals; the desktop GUI's own pty:spawn path (new tab, split,
reattach) never materialized a deferred fork-PR remote before raw git
commands could run there. Add a small wrapper that resolves the worktree's
push target and owning repo from args.worktreeId via the store, and
fire-and-forget delegates to the existing materializer, wired as the first
statement of runPtyIpcSpawn. Degrades silently (optional chaining + catch)
so a partial/fake Store in existing spawn tests can't turn this into a
spawn-blocking throw.

* test(worktree): retarget stale editor-remote-branch assertions for worktreeId threading

runtime-git-sync-client's local-path fetch/pull/fastForward/push calls now
forward context.worktreeId (needed by the main-process handlers to key
deferred push-target materialization). Update the 17 call-site mocks across
15 tests in editor-remote-branch-actions.test.ts to expect worktreeId: 'wt-1',
matching the already-correct source behavior -- no assertion was loosened.

* fix(worktree): give a materialize joiner its own branch wiring

The materialize single flight is keyed on the remote, but everything after
the remote add is per-branch. A sibling worktree joining an in-flight mint
for a different branch received the minter's target and skipped its own
refspec widen, tracking-ref fetch, and upstream link, so its branch ended
with no upstream at all.

Wait for the remote, then run the per-branch work against the joiner's own
target -- the same path the already-exists short-circuit takes, now shared
rather than duplicated. Adopting a remote a sibling minted also stamps
ownership, so removing the minter cannot strand the survivor's metadata
outside the orphan sweep's reach.

* fix(worktree): stop a failed mint from leaving a config-only fork remote

Review of the joiner fix found it made things worse in three ways.

Swallowing the mint's rejection let a joiner adopt a remote the rollback
had already removed, writing remote.<name>.fetch with no URL. Verified on
real git: that ghost section breaks `git fetch --all`, forces every later
mint to a `-2` name, and cannot be removed by `git remote remove`.
Propagate instead; the in-flight map is already cleared, so a retry
re-mints.

The SSH twin still returned the minter's target to a joiner, so the
original per-branch bug survived there. It now adopts against its own
target through a twin helper.

The ownership stamp was unreachable: it required both a store and a repo
id, and no caller passes both. Derive the repo id from the worktree id.

Adopters also write remote config, and concurrent `git config --add` has
no lock retry -- 135 of 160 writes failed at 8-way concurrency, and equal
values duplicate the refspec. Chain adoptions per remote.
2026-09-01 22:44:05 -07:00
Neil 4e35e058fc fix(remote): stop unlabelled inventories and replayed rows authorising destruction (#17981)
* fix(remote): stop one unlabelled inventory tombstoning a live worktree mirror

#11495 Step C. `buildMissingWebSessionTabsRemovals` synthesised a `removed: true`
tombstone -- emptying a worktree's entire mirror -- for any tracked worktree
absent from a single inventory frame, without ever consulting the host's own
authority label. `mirror-settle` already refuses to settle an *empty* inventory
that is not `authoritative` (#16414, #16546); the strictly more destructive
action was ungated.

An inventory the host labels `authoritative` carries a complete PTY census, so
one omission is host attestation and removal stays immediate. An unlabelled
inventory is a degraded or version-skewed census: `unverifiable`, not `exited`.
It must now repeat before it can destroy anything, reusing the two-observation
shape of `confirmSurfaceInventoryAbsence`. A legacy host that never negotiates
the capability still converges after two rounds, so ghost rows cannot outlive
the fence.

The 14 tests from #13621 that blocked this were all written before the
`authoritative` label existed (#13621 landed 2026-08-11; the capability landed
2026-08-26 in #16546). #13621's own summary says "Reconcile each resumed host
from an authoritative inventory, including removals", so their fixtures are
retargeted to say so explicitly rather than weakened.

Refs #11495

* fix(agent-status): stop a reconnect replay restamping the staleness clock

#15317 correctness half. `receivedAt` was doing two jobs: delivery order and
evidence age. A relay reconnect replays every cached row, and `receivedAt` must
restamp to clear the connection watermark that `clearStatusEntriesForConnection`
raises -- so a pane stuck at `working` had its 30-minute deadline pushed out by
another 30 minutes on every reconnect. The TTL was never reached, which is why
this read as a tuning question.

Two clocks, not one rewritten clock:

- `receivedAt` is untouched. The transient-clear watermark and the four `<`
  ordering drops (`agent-status-event-applicator`, `agent-status-live-entry-builder`,
  `agent-status-cleanup-actions`) keep working unchanged. Restamping a replay with
  its original time would have made it `<= watermark` and dropped it outright,
  leaving the pane with no row at all.
- `evidenceObservedAt` is new, optional, and read only by the staleness
  comparison (`isFreshNonDoneAgentStatus`, `isExplicitAgentStatusFresh`, the
  freshness scheduler). Main holds it per pane across the transport clear -- the
  clear deletes the row on purpose, but the *age* of evidence a later replay
  restates is not a claim about the pane. Absent means "no separate observation",
  and every consumer falls back to `receivedAt`/`updatedAt`, so old hosts and old
  rows behave exactly as today.

Behaviour: a genuinely active pane keeps stamping the observation clock from its
real events, so it stays `working` across a reconnect. A pane whose relay
restarted replays nothing and still falls through to title evidence. A torn-down
pane drops its remembered clock in `clearPaneState`, so a reused pane key cannot
inherit one.

`AGENT_STATUS_STALE_AFTER_MS` is deliberately unchanged -- the window length
remains a product decision.

Refs #15317
2026-09-01 22:43:38 -07:00
Neil 7ba832c7ba fix(runtime): scope both reconcile call sites to the owning host uniformly (#18004)
* fix(ssh): reclaim a fenced agent-session spawn from host inventory

A pty.spawn whose response is lost leaves the relay holding a live agent
PTY it deliberately will not reap (the stale-spawn killer is skipped for
agentSessionCreateOperationId spawns), while the client memoizes the
rejection for 24h and never asks again — an agent burning tokens with no
way back.

The client already names what it launched: the deterministic
preAllocatedHandle is exported as ORCA_TERMINAL_HANDLE and published back
in pty.listProcesses. Retain that identity with the fenced operation and,
on replay, reuse reconcileRemoteTerminalCreate to adopt it. Adoption only:
never spawns, never kills, and any unverifiable or ambiguous inventory
replays the original failure unchanged.

Scope the reconcile listing to the owning host so an unreachable relay
throws instead of silently reading as absence.

Refs #17929

* fix(terminal): scope terminal.create reconcile inventory to the owning host

An SSH host that cannot answer is dropped silently from the aggregate PTY
listing, so a reconciling terminal.create retry read that as proof of absence
and spawned a duplicate shell over live remote work. Pass the workspace's
connectionId so an unreachable relay throws runtime_unavailable instead;
local and folder workspaces keep the aggregate listing.

* fix(runtime): scope both reconcile call sites to the owning host uniformly

Both create-dedupe and fenced-spawn reclaim now pass the workspace's own
connection (null for local/folder), so neither falls back to the aggregate
listing that silently drops a non-answering SSH provider.
2026-09-01 22:43:14 -07:00
Neil da1849c250 fix(runtime): scope create-dedupe inventory to the owning host (#17983)
* fix(ssh): reclaim a fenced agent-session spawn from host inventory

A pty.spawn whose response is lost leaves the relay holding a live agent
PTY it deliberately will not reap (the stale-spawn killer is skipped for
agentSessionCreateOperationId spawns), while the client memoizes the
rejection for 24h and never asks again — an agent burning tokens with no
way back.

The client already names what it launched: the deterministic
preAllocatedHandle is exported as ORCA_TERMINAL_HANDLE and published back
in pty.listProcesses. Retain that identity with the fenced operation and,
on replay, reuse reconcileRemoteTerminalCreate to adopt it. Adoption only:
never spawns, never kills, and any unverifiable or ambiguous inventory
replays the original failure unchanged.

Scope the reconcile listing to the owning host so an unreachable relay
throws instead of silently reading as absence.

Refs #17929

* fix(terminal): scope terminal.create reconcile inventory to the owning host

An SSH host that cannot answer is dropped silently from the aggregate PTY
listing, so a reconciling terminal.create retry read that as proof of absence
and spawned a duplicate shell over live remote work. Pass the workspace's
connectionId so an unreachable relay throws runtime_unavailable instead;
local and folder workspaces keep the aggregate listing.
2026-09-01 22:38:53 -07:00
Neil bda6751da8 fix(ssh): reclaim a fenced agent-session spawn from host inventory (#17976)
A pty.spawn whose response is lost leaves the relay holding a live agent
PTY it deliberately will not reap (the stale-spawn killer is skipped for
agentSessionCreateOperationId spawns), while the client memoizes the
rejection for 24h and never asks again — an agent burning tokens with no
way back.

The client already names what it launched: the deterministic
preAllocatedHandle is exported as ORCA_TERMINAL_HANDLE and published back
in pty.listProcesses. Retain that identity with the fenced operation and,
on replay, reuse reconcileRemoteTerminalCreate to adopt it. Adoption only:
never spawns, never kills, and any unverifiable or ambiguous inventory
replays the original failure unchanged.

Scope the reconcile listing to the owning host so an unreachable relay
throws instead of silently reading as absence.

Refs #17929
2026-09-01 22:38:29 -07:00
Neil b552bcb91f fix(relay): diagnose why node-pty will not load instead of hedging (#17891)
The relay could only say "terminals are unavailable" and then list three
remedies for four different faults, none of which the user could verify
(#17830). Two things were destroying the evidence:

- `loadPtyUncached` caught the load error into bare `catch {}` blocks
  (pty-handler.ts:539, :551) and returned null. The only cause anyone had
  was discarded on the spot.
- node-pty's own loader walks three directories and rethrows only the LAST
  failure, so even an uncaught error arrives as `Cannot find module
  '../prebuilds/...'` — the GLIBC/ABI/arch sentence is already gone.

The relay now keeps the load error, recovers the real dlopen message with an
out-of-process load of the file node-pty would have opened, reads what
node-gyp configured the binding for (`build/config.gypi`), captures the
host's Node ABI, arch and glibc, and probes the toolchain only when nothing
was compiled. Each fault gets its own message naming values the user can
check: toolchain_missing, dependency_missing, abi_mismatch, arch_mismatch,
libc_floor, shared_library_missing, load_crashed, and load_failed which
quotes the loader verbatim. A probe that did not answer stays `unverifiable`
and prescribes nothing.

The classification is now also structured data on the error, so a client can
repair the host instead of printing a paragraph: an additive, schema-validated
`data` field on an existing JSON-RPC error, with `repairable` true only for a
proved fault that recompiling on the host actually fixes.

Reuses orcad's loader-message parsers and out-of-process probe rather than
adding a second copy; `classifyLoaderMessage` moves to a shared module and
gains architecture and missing-shared-library cases, which the orcad boot
precondition picks up too.
2026-09-01 22:38:07 -07:00
Neil b8cfdb1702 test(ssh): ratchet the relay reattach-failure exit as unverified, not proven (#17963)
* fix(ssh): stop expiring relay-reset leases when the force-stop threw

A force-stop that rejected never observed the remote shells, so bulk-expiring
their leases in the finally block recorded a verdict Orca does not hold. Mirror
ssh:terminateSessions: only a fulfilled stop retires a lease. Local PTY handles
are still cleared, so nothing is stranded — the next connect reattaches the
survivors or expires them on host evidence.

* test(ssh): ratchet the relay reattach-failure exit as unverified, not proven

The relay answers pty.attach not-found both when it verified the pid is dead and
when its session map simply lacks the id — which is every id after a relay
restart. No behavior change: -1 already routes through isProvenProcessExit to the
renderer's unverified-loss path. This pins that contract and drops the comment
claiming the branch holds positive proof of death.
2026-09-01 22:37:30 -07:00
Neil 7dd2ff586a fix(ssh): stop expiring relay-reset leases when the force-stop threw (#17962)
A force-stop that rejected never observed the remote shells, so bulk-expiring
their leases in the finally block recorded a verdict Orca does not hold. Mirror
ssh:terminateSessions: only a fulfilled stop retires a lease. Local PTY handles
are still cleared, so nothing is stranded — the next connect reattaches the
survivors or expires them on host evidence.
2026-09-01 22:36:12 -07:00
Neil d838ca1419 fix(ssh): stop orphaning live relays when an endpoint is taken over (#17821)
A failed `--connect` was read as "the relay crashed": the client `rm -f`'d
the socket and launched a replacement at the same path. Unlinking a unix
socket does not close the listener the incumbent already holds, so an
alive-but-refusing relay — the RelayVersionMismatchError case — kept running
forever with its PTYs and agents (#8585).

Establish the incumbent with host evidence instead, in the fixed
live/unverifiable/exited vocabulary, and never unlink from the client: the
daemon's own RelaySocketOwnership already performs an identity-checked
takeover that is atomic with its bind. A live incumbent now raises a typed
terminal RelayEndpointHeldError naming its pid rather than being abandoned.

Also sweep sibling version directories for this target's socket after launch,
so the relay an app update supersedes (#13614, #13852) is visible and dealt
with deliberately. Only a relay proven to hold nothing — argv matched, single
socket holder, zero children re-checked on the host immediately before the
signal — is SIGTERMed, and `reaped` is claimed only from a post-signal
`kill -0` that failed. Anything unreachable stays `unverifiable` and untouched.
2026-09-01 22:35:44 -07:00
Neil b75de5fede fix(preload): type the ssh terminateSessions bridge result (#18079) 2026-09-01 22:35:09 -07:00
Neil 058e618bb4 fix(ssh): stop a failed worktree scan from publishing authoritative emptiness (#17833)
* fix(ssh): keep an unreadable worktree catalog from authorizing teardown

#14004: the relay's worktree-list fallback caught every failure and returned
`[]`, so `SshGitProvider.listWorktrees` resolved as a success with an empty
list. Downstream reconciliation treats a resolved listing as authoritative,
which reaches `teardownMissingWorktreeTerminalsBestEffort` and the
unregistered-worktree removal paths — a data-loss path from a failed scan.

- relay: the `-z`-unsupported fallback lane propagates its failure instead of
  swallowing it to `[]`.
- provider: an empty or malformed `git.listWorktrees` response is refused as
  `WorktreeCatalogUnavailableError`. A Git repo always lists its own checkout,
  so a zero-row listing can only be a scan that never answered — this is the
  mixed-version guard against relays that still swallow.
- `listRepoWorktrees`: an unreachable SSH host reports unavailable instead of
  an empty catalog.

#12661: `ssh:terminateSessions` now returns `{ terminated, unverifiable }`, so
an offline sweep that only tore down local transport cannot be mistaken for a
remote kill. The Manage-hosts toast warns instead of claiming success.

* chore(i18n): register the unreachable-terminal terminate message
2026-09-01 22:05:30 -07:00
Jinwoo Hong bed9734a9d Prevent deleted workspace browser snapshot resurrection (#17779)
* Prevent deleted workspace browser snapshot resurrection

* fix: tear down folder workspace browser tabs

* fix: fence pre-publication browser snapshots

* fix: route folder deletion through runtime cleanup

* chore: retrigger CI

* fix: sweep folder PTYs on runtime deletion

* fix: restore deletion fences after runtime refactor

* test: cover deleted renderer snapshot after recreation

* fix: avoid publishing ambiguous worktree snapshots

* fix: preserve optional worktree index state

* fix: fence paired PTYs on worktree removal

* fix: harden deletion fence and folder-delete teardown

- Folder-group delete no longer fails on a mixed-host group: an ambiguous
  connection skips the PTY sweep instead of rejecting the delete.
- Share one folder-workspace PTY teardown helper between the runtime
  removal path and the project-group controller.
- Simplify the mobile snapshot fence: identity-carrying frames are judged
  against the live catalog instanceId and clear the fence once the
  successor is accepted; identity-less frames are fenced by renderer
  generation. Drops the unbounded epoch bookkeeping.
- A fenced frame no longer triggers a resync request on every sync while
  the renderer still lists it as unchanged.
- Cross-host id collisions publish without an instanceId rather than
  blanking the mobile session for that workspace.
- Folder delete IPC always routes through the runtime; the store-only
  fallback and double notify are gone.
- Drop the redundant rescue-path tombstone check; ownership is purged at
  removal.
- Fence tests drive removeWorktreeMetadataAndHistory + syncWindowGraph
  instead of seeding the fence map, and add accept-after-recreate,
  no-resync, and ambiguous-host folder delete cases.
2026-09-02 00:52:24 -04:00
Jinwoo Hong ff1031186c ci(release): make Windows release gates deterministic (#18067)
* ci(release): keep Windows signing gate deterministic

* test(release): skip oversized Windows cache fixture

* ci(release): keep flaky Windows skill suite non-blocking
2026-09-02 00:42:13 -04:00
OrcaWinandOrca Worker ededec00ba fix(daemon): let a create wait out an in-flight session teardown (#18063)
Co-authored-by: Orca Worker <orca-worker@localhost>
2026-09-01 21:36:12 -07:00
Neil f801a8447a fix(session): stop two hosts sharing one workspace-session bucket (#17912)
* fix(session): stop two hosts sharing one workspace-session bucket

A worktree id is `repoId::path` with no host component, so a repo registered
on two execution hosts publishes the same id for two different workspaces
(STA-4343). buildHostIdByWorktreeId folded every such id into the 'local'
partition, so the two workspaces shared one tabsByWorktree bucket and the
host that wrote last erased the other's rows for good.

A contested id now resolves to one deterministic primary host (local when it
is a claimant, else the lowest host id — stable, so the primary does not move
as the user navigates). On hydration, entries other claimants hold in their
own partitions are parked in a shadow that never reaches renderer state, and
every write re-attaches them to their own partition, so a write for the
primary can no longer take a co-claimant's session down with it. A parked row
is dropped only when the catalog positively re-attributes the workspace.

Known gap, documented in workspace-session-host-contention.ts: the unified
renderer session still holds one bucket per bare id, so both workspaces
display the primary's tabs. Closing that needs host-qualified keys through
the tab store.

* fix(session): carry parked contested rows through full partition replaces

attachHostSessionShadow skipped a parked field when nothing else routed to
the co-claimant's slice. That is correct for the patch path (an omitted
field leaves the partition untouched) but wrong for persistWorkspaceSessionByHost
and the quit snapshots: setHostWorkspaceSession replaces the whole partition,
so the omitted field erased the very rows the shadow exists to protect.
The attach now takes the write mode and, on a full replace, seeds the
missing field with the parked rows.

* fix(session): decide a contested id's partition once, at read time

Review found the read and write paths deriving the primary from different
domains. The read picked it from which partitions held the key (SSH rows live
in the 'local' blob, so SSH reads as local); the write picked it from the
claims catalog, where SSH is `ssh:*`. For an ssh+runtime contest the claims
sort `runtime:` first, so the write sent the SSH workspace's rows into the
runtime partition and attachHostSessionShadow then skipped restoring the
runtime's own rows because the key was already present — a cross-host copy
worse than the shared bucket this branch set out to fix. The same disagreement
copied a row across partitions whenever only a co-claimant had it saved.

The read now records the partition every restored key came from and the
routing honours it, so rows go back where they live. A claims-derived owner is
only a fallback for keys the read never saw, and it is computed over distinct
PARTITIONS: 'local' and every ssh host share one blob, so a claimant set that
collapses to a single partition keeps its normal routing. A stale record loses
to a positive catalog re-attribution, so adoption still migrates a workspace.

Also: build the runtime owner map from the post-extraction slices, so a row
parked out of the renderer session no longer names its host as owner and
startup stops building runtime placeholders for the local row that was kept.
Drop the unused isContestedWorktreeId export.
2026-09-01 20:46:50 -07:00
Neil 6c8eea5ebe perf(worktree): fix the prepared-checkout hit rate and make misses visible (#17863) 2026-09-01 20:26:00 -07:00
Neil 7a69357856 fix(worktree): widen git-common watch on event-batch overflow (#17916) 2026-09-01 20:25:42 -07:00