Commit Graph
7827 Commits
Author SHA1 Message Date
Neil 411fda4098 fix(ssh): refuse to publish a native-deps tree whose cloexec patch did not take 2026-09-02 02:24:50 -07:00
Neil 34046c288c fix(ssh): patch the pty master before publishing the shared native-deps tree 2026-09-02 02:24:50 -07:00
Neil e54b961f6c 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.
2026-09-02 02:24:50 -07:00
Neil c0ec5a82d8 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.
2026-09-02 02:24:50 -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 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
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
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
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
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
Neil a7db6c336b perf(git): skip the sparse probe for worktree listings that never read it (#18050)
Three main-process call sites list a repo's worktrees to read `worktree.path`
and nothing else, but went through the annotated listing, so each one paid a
sparse-checkout probe per worktree and cached the result nobody consumed:

- `registered-worktree-roots-cache.ts` rebuilds the filesystem-auth authorized
  roots. `invalidateAuthorizedRootsCache()` fires on every worktree create and
  remove, plus repo add/clone/settings changes, so this reruns constantly.
- `filesystem-source-control-ai-targets.ts` checks whether a local repo owns a
  worktree path.
- `hosted-review.ts` verifies a worktree belongs to the repo before granting
  access.

The probe is an `fs.stat` of the per-worktree `info/sparse-checkout` plus, when
that file is non-empty, a git config read. On a WSL-hosted repo both cross 9p.
#17859 cached it and #17932 keyed that cache on the distro, which fixed a wrong
answer but also meant the distro-less callers above populate a second entry per
worktree — probed cold, revalidated on their own five-minute loop, and read by
nobody. Worktree create/remove clears the sparse cache and dirties the roots
cache together, so both variants go cold at once and the discarded half is
re-probed in full on the next auth check.

`listRepoWorktreeGraph` routes those callers to `listWorktreeGraph`, which
already existed as the annotation-free listing (#17655).

Doing only that would have cost a second `git worktree list`. The scan cache
keys in-flight scans on a `kind`, and graph and lenient were separate kinds, so
a roots rebuild overlapping a sidebar refresh would spawn its own subprocess
where the two previously coalesced. That is a real regression on macOS, Linux
and native Windows, where `getLocalProjectWorktreeGitOptions` returns `{}` and
both callers land on the identical key; on WSL they already differ by distro and
never shared.

So the annotated listing is now the graph listing plus annotation, rather than a
parallel scan of its own: `listWorktrees` awaits `listWorktreeGraph` and
annotates the rows it returns. Both soften a Git failure to `[]`, so they can
share one listing; strict keeps its own because it must be able to reject. The
two kinds ran Git twice before and now run it once, so the overlap case gets
strictly faster instead of paying for the opt-out.

An annotated scan holds two in-flight entries now (its own, plus the graph
listing it shares). Keeping its own entry matters: `detectSparseCheckoutCached`
dedupes revalidation but not the initial fill, so two concurrent badge readers
sharing only the graph scan would both probe.

Per-platform delta:
- macOS/Linux: fewer probes on the three call sites; one `git worktree list`
  instead of two when a graph and an annotated scan overlap.
- native Windows, no WSL: same, and the saved subprocess is the expensive half.
- Windows + WSL: the largest win. The discarded probes were 9p round-trips
  re-paid cold after every worktree create/remove.
- SSH/relay: none. `listRepoWorktreeGraph` returns through the same provider
  branch as `listRepoWorktrees` before reaching local Git.
- folder workspaces: none. Both return the same synthetic folder worktree.

Not in this change:
- The badge listing itself. It still probes, still annotates, and still keys on
  the distro exactly as #17932 left it.
- The remaining `listRepoWorktrees` callers. They read `isSparse`, or feed rows
  to something that does.
2026-09-01 20:19:56 -07:00
Neil 3777070eaf fix(worktree): restore the stale-cleanup signal after the module split (#18058)
* fix(worktree): restore the stale-cleanup signal after the module split

Moving stale-preparation cleanup into its own module took
`staleCleanupInFlight` with it, but `hasPendingWorktreeCreatePreparations`
still read it directly. Both sides were green in isolation — the reference
arrived on main while the split was in review — so the break only appeared
once they merged, and it fails typecheck for every branch built on main.

Expose the predicate from the module that owns the map, and cover the
signal with a test so the idle gate's "a create is imminent" answer cannot
silently regress again.

* test(worktree): anchor the pending-signal test on the scan, not on await depth
2026-09-01 20:08:09 -07:00
Brennan BensonandMerge Sim 7f6cf271ce fix(terminal): preserve panes when restored PTY owner is unverifiable (#17860)
* fix(terminal): preserve unverifiable restored pane bindings

* test(terminal): cover unverifiable restored pane identity

* fix(terminal): settle direct SSH retry on unverifiable owner

* fix(terminal): make owner warning actionable

* fix(terminal): harden owner warning recovery feedback

* test(terminal): consolidate fixture imports

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-01 19:53:11 -07:00
Jinwoo Hong a7fda48fe3 feat(telemetry): measure macOS stale-daemon adoption and cwd denials (#18043)
* feat(telemetry): measure macOS stale-daemon adoption and cwd denials

Adds two enum-only PostHog events so #17696 can be sized instead of guessed at:

- daemon_adopted: once per macOS launch that keeps a daemon an earlier app
  launch forked (invisible to daemon_lifecycle, which only sees replacements).
  Carries app-version match, spawner-path class (installed app / Squirrel
  ShipIt cache / other / missing), the existing TCC attribution verdict, and
  the bucketed live-session count.
- daemon_pty_cwd_denied: the symptom itself. The daemon probes the requested
  cwd in its own process (only its TCC context counts) and returns an additive
  cwdReadableByDaemon field; the app emits only when the daemon was denied AND
  the app can read the same path, so a missing or genuinely unreadable cwd
  never counts. Non-permission errors read as readable on purpose.

Both emitters swallow every failure; nothing here can delay or fail daemon
startup or a PTY spawn. Off macOS neither event fires. The new wire field is
optional, so older daemons and clients are unaffected.

* fix(telemetry): keep cwd-denial classification inside the swallow guard

Read the pid record at emit time (inside the try) rather than passing the
adapter's startup snapshot: a throwing app-environment read can no longer
escape spawn(), and a denial after a respawn is billed to the daemon that
actually spawned the PTY.
2026-09-01 22:33:39 -04:00
Jinjing 0352c239c2 Add Copy Session ID menu item to terminal tabs (#18039)
* Add Copy Session ID menu item to terminal tabs

Adds a menu item to copy the active pane's agent session ID when available.
The item only appears when the session is still live and has reported an ID.

* Add Copy Session ID i18n strings and e2e test

- Add localized strings for Session ID context menu item
- Add e2e test coverage for copying session ID from terminal tabs
- Fix dev build permissions when copying private Electron app bundles

* Drop the Electron dev-bundle fix from this branch

It landed on main as 519af49a58, which restores write permission inside
copyPrivateTree itself rather than at the dev runner's call site, so every
caller of the private-copy contract is covered and not just this one. That
commit also fixes the test that should have caught the crash: the wrapper ran
with stdio: 'ignore', so a hard failure presented as a bare timeout.

This branch predated that commit and carried a narrower duplicate, mixed into
an i18n/e2e commit where it did not belong.

* refactor: use dedicated i18n keys for copy session ID toasts

Replace auto-generated translation keys with specific, dedicated keys
for copy session ID success and error messages. This improves
maintainability and makes the strings easier to translate across all
supported languages.
2026-09-01 19:20:48 -07:00
Neil d7123591ce perf(git): pack the loose refs Orca's own fetches leave behind (#17857)
* perf(git): pack the loose refs Orca's own fetches leave behind

Orca strips git's auto-maintenance off every fetch it issues
(GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS) and never compensated, so
nothing in an Orca-driven checkout ever packs refs. One real machine
reached 36,574 loose refs, where `git show-ref -- main` costs 5.2s and
every worktree create pays for it.

Add an idle-time, per-repo `git pack-refs --all --prune`, armed by the
fetches that create the debt. It runs only after ten minutes of quiet on
that repo, only above 1000 loose refs (probed with a walk bounded by that
threshold, not by the backlog), one at a time across the whole app, at
the background admission tier, and never while an agent is working, a
create is prepared or in flight, a worktree removal is deleting refs, the
app is quitting, or the machine is on battery. A user who set
`maintenance.auto=false` or `gc.auto=0` has opted out.

Measured on a 36,001-loose-ref fixture (macOS/APFS, git 2.44):
`show-ref` 5.5-12.2s -> 30-49ms, `for-each-ref` 4.0-10.8s -> 43-48ms.

Also fixes a pre-existing bug the split exposed: `--path-format=absolute`
is ignored before git 2.31, and taking rev-parse's stdout raw collapsed
every repo on such a host onto one fetch-serialization key.

Refs #17828

* perf(git): make idle ref maintenance preemptible and cheaper to probe

The idle veto was one-directional: it stopped a pack from starting during
a create, removal, or agent work, but nothing stopped those from starting
during a pack. A user-clicked Fetch, a branch delete, or a worktree
removal that needed `packed-refs.lock` mid-rewrite could fail with
`unable to create packed-refs.lock` -- a git error with no visible cause.

Make the pack cancellable end to end. An AbortSignal now reaches the
`pack-refs` child and both pre-pack probes, and `pause()` aborts what is
running, waits for it to actually stop, and holds a suspension count so
nothing new starts until the caller releases. Every entry point that
deletes a ref takes that pause: gitFetch, gitPull, gitFastForward,
removeWorktree, forceDeleteLocalBranch, prepareWorktreeCreateCheckout,
addWorktree. Five more triggers close the rest of the window: battery
drop, window focus, quit, the attempt deadline, and any other git command
queueing for an admission slot.

Judge a pack by re-probing the backlog rather than by the child's exit
code. Measured in the field: another Orca session moved a branch
mid-pack, git reported `cannot lock ref`, skipped that ref and packed the
rest -- 36,688 loose refs down to 3. On a machine running several
sessions that is the normal case, and retrying it would be wrong.

Probe with one batched `readdir` per directory instead of streaming
`opendir`, which issues a thread-pool round trip every 32 entries: 177ms
-> 23ms on a real 36,600-ref repository, with half the event-loop lag.
The walk stays strictly sequential so it can never occupy more than one
of libuv's four filesystem threads.

`PackRefsLockOwnership` makes a lock left by SIGKILL attributable, and
only reclaims one when a marker exists, the lock is older than any
pack-refs could run for, and the recorded process is gone.

Refs #17828

* fix(git): wait out the packed-refs lock instead of killing the pack

Measured on Git 2.55/APFS with 37k loose refs: a full `pack-refs --all
--prune` takes 23-32s but holds `packed-refs.lock` for only 0.03-1.37s of
it. The other ~95% is the prune phase, during which a concurrent `fetch
--prune`, `branch -D` or `update-ref` succeeds every time -- per-ref locks
last microseconds and git retries for `core.filesRefLockTimeout`.

So the abort-on-everything design was strictly harmful. SIGTERM into the
prune loop strands an empty `refs/**/*.lock` about one time in five
(9/30, 5/40, 6/30 kills): `tempfile.c` opens the lock O_EXCL before
`activate_tempfile()` links it into the list the signal handler walks,
and a pack does ~36k lock cycles. Afterwards `update-ref -d` on that ref
fails with `cannot lock ref ... File exists`, permanently. On Windows
`taskkill /f` never runs git's handlers at all, so an abort inside the
rewrite strands `packed-refs.lock` every time.

Never signal the child. `packRefs` no longer takes an abort signal; it
polls `packed-refs.lock` and reports the window through a
`PackedRefsLockReporter`. `pause()` resolves when the lock is released --
bounded, and free during the prune -- while the suspension counter still
blocks new attempts. Battery and window-focus become do-not-start rather
than stop-what-is-running, and quit waits for the lock and lets the child
finish orphaned.

For strands that already exist, `PackRefsLockOwnership` now also reclaims
`refs/**/*.lock` under the same three conditions plus a 0-byte check, and
a lock carrying our own not-yet-reclaimable marker records `locked` with
a 30min retry instead of the 6h failure cooldown -- so a Windows strand
self-heals in half an hour rather than six.

Reverts the git admission-scheduler event bus, which existed only to
drive the abort this removes.

Refs #17828

* test(git): make the ref-maintenance waits survive a loaded runner

CI shard 4/8 failed on `restarts every armed countdown when the user does
ref work themselves`, which passes locally. The `until()` helper spun a
fixed 200 event-loop turns and then returned silently, so on a contended
runner the filesystem probe had not finished and the assertion that
followed failed with an unrelated message.

Bound the wait by wall clock instead and throw a named error, which
immediately exposed a second latent bug: the single-flight test's second
wait could never succeed, because the deferred repo's retry is on a faked
`setTimeout` that spinning the real loop never advances. It had been
passing only because the old helper gave up quietly. Add a timer-aware
variant for those, and have the countdown test await a signal the fake
pack resolves rather than polling at all.

Verified stable across five sequential runs and once under load average
32 with six concurrent suites.

Refs #17828
2026-09-01 19:06:44 -07:00
Neil fdfe354045 test(relay): bind test WebSocket servers to loopback
A control-handshake test that expects a timeout was instead getting
'Unexpected server response: 401' about once in fourteen runs. A slow machine
cannot turn a timeout into a 401 -- that needs a real HTTP response, so the
connection was reaching a different server.

new WebSocketServer({ port: 0 }) binds the wildcard address while the client
dials 127.0.0.1. On macOS those differ, and with SO_REUSEADDR a foreign process
can hold the more specific 127.0.0.1:P and win the connection. Caught live: a
wildcard bind took port 52584, which a running Orca app already held on
loopback, and Orca answered the probe. A listener that checks a token answers
401.

Ten constructions across seven files now pass host: '127.0.0.1', so the
reservation covers the address the client dials and a duplicate bind is refused.

Adds a ratchet, because this is not authors forgetting a convention: all 30+
.listen(0, ...) sites already pass '127.0.0.1', while 7 of 7 ws constructions
did not. ws accepts { port } alone and binds the wildcard silently, so nothing
told them. The guard pins the wildcard count, and pins separately at zero the
option shapes it cannot read -- spreads and variable option objects fail rather
than being exempted, and a recognized-construction floor catches the matcher
going blind, which otherwise reads exactly like a clean tree.

mobile/scripts/mock-server.ts stays on the wildcard deliberately: a phone
reaches it over the LAN.
2026-09-01 19:05:42 -07:00
Neil 8b7d778a2e perf(git-common): bound the fs-stat fan-out in the worktree pollers (#17839)
* perf(git-common): bound the fs-stat fan-out in the worktree pollers

snapshotGitCommon and snapshotBase issued one fs op per candidate via
Promise.all/a serial loop, unbounded by worktree count. At 973 live
worktrees this queued ~6,800 concurrent stat calls (measured peak 6000
in a 1000-entry synthetic benchmark) onto libuv's 4-thread default
pool, starving every other main-process fs operation for the scan's
duration (~1s). Bound both to concurrency 8 via the existing
forEachWithConcurrency helper, matching the precedent in
exact-ref-probe.ts and worktree-head-identity-reader.ts. Peak
concurrent stats dropped 6000 -> 48 in the benchmark; wall time was
essentially unchanged (495ms -> 541ms), since the real bottleneck was
never total scan time but pool starvation of unrelated work.

Also make the no-native-watch and crash-fuse polling fallbacks in
worktree-git-common-watch.ts / worktree-git-common-narrow-watch.ts
self-calibrate their cadence: on platforms/paths where this poller is
the sole change signal, a fixed 2s cadence at hundreds of worktrees
approaches a permanent scan loop. Stretch the interval so a scan stays
a bounded fraction (10%) of its own cadence, capped at 30s, floored at
the configured base interval. Left the reconciliation backstop (fixed
30s cadence, already accepted) and checkPendingMarkers (bounded by
concurrent-worktree-creation count, not total count) untouched.

Fixes #17828

* perf(git-common): split the tripwire from the per-entry sweep cadence

Review on #17839 found a real staleness trade-off: adaptiveCadence
gated ALL detection (worktree add/remove, HEAD, dirty refs, AND
per-entry commit signals) behind one stretched interval, so on the
crash-fuse polling fallback the reviewer measured cadence sitting at
5.4-10s sustained and hitting the 30s cap once a single scan reached
3s at 973 worktrees -- worse than the pre-#17828 fixed ~2s+250ms
baseline for signals users notice immediately (sidebar worktree list,
branch labels).

Split snapshotGitCommon into a cheap structural "tripwire" (readdir,
worktreesDir signature, primary-file signatures, newly-appeared
entries -- ~5-6 fs ops, O(1) in worktree count) that always runs on
the fixed pollIntervalMs, and the O(n) per-entry sweep (commit/dirty
detection) that alone is gated by the adaptive cadence via a
nextSweepDueAt deadline. Existing, unchanged entries are carried over
by reference on a tripwire-only tick (no re-stat), so diffing produces
no spurious events; genuinely new entries are still stat'd immediately
so worktree add remains real-time. This keeps everything on one
ticking-flag-guarded loop (no new concurrency/race surface) --
scheduling stays fixed at pollIntervalMs; only nextSweepDueAt stretches.

Also drop the adaptive-cadence seed heuristic entirely: nextSweepDueAt
starts at 0, so the first regular tick after bootstrap sweeps
unconditionally on its own schedule instead of guessing an initial
interval from the bootstrap snapshot's duration (which could stretch
the very first tick to 10-30s on a slow disk).

Documented that worktree-git-common-watch.ts's adaptiveCadence call
site is unreachable in production (Electron only ships
darwin/linux/win32, both covered by NARROW_WATCH_PLATFORMS) rather
than implying it protects real users. The reachable path is the
narrow-watch crash-fuse fallback in worktree-git-common-narrow-watch.ts.

Filed #17878 to track the real long-term fix: periodically retrying
the upgrade back to the narrow watch after a crash-fuse trip, so the
degraded/polling state doesn't need to be tuned at all once the
underlying failure clears.

* perf(git-common): gate per-entry structural stats on the entry-dir signature

Every real git write inside a worktree admin entry (HEAD, index,
config.worktree, locked) goes through a lock file + rename, which moves
the entry directory's own mtime/ctime/size signature. Only `gitdir`
(worktree move/repair) is rewritten in place, and that's already covered
by the periodic ungated backstop (INDEX_BACKSTOP_TICKS). The previous
comment claiming structural leaves "change in place every tick" was
wrong; verified against git 2.55 across checkout, commit, amend, reset,
ref updates, stash, worktree lock/unlock, config --worktree, and index
writes.

Gate all six per-entry stats behind the entry dir's own signature instead
of stat-ing every leaf unconditionally every tick: an unchanged entry now
costs one stat per tick instead of six, and a changed one still costs six
(bounded by change rate, not worktree count). This also fixes the actual
in-flight fan-out: forEachWithConcurrency(entries, 8) previously still
issued 6 stats per in-flight entry (48 real concurrent ops); with the
gate, warm ticks issue ~1 stat per entry, so true in-flight tracks the
concurrency limit directly.

This makes the follow-up adaptive-cadence machinery from the prior commit
unnecessary: the crash-fuse and no-narrow-watch polling fallbacks no
longer need to stretch their own cadence, since a warm sweep across
hundreds of worktrees is now cheap regardless of interval. Revert both
call sites to a fixed pollIntervalMs and delete the adaptive-cadence
option, the split tripwire/sweep cadence, and the seed heuristic — none
of it earns its complexity once the real per-entry cost is fixed at the
source. Per-entry staleness on the crash-fuse path returns to a fixed 2s
+ 250ms debounce instead of the previous 5.4-30s adaptive stretch.

Refs #17828
2026-09-01 19:04:03 -07:00
Neil e89321192a perf(worktree): batch remote conflict probes, re-arm the prepared checkout (#17829)
* perf(worktree): batch remote conflict probes, re-arm the prepared checkout

A repo with many remotes paid one `git show-ref --verify` subprocess per
remote on every branch-conflict check during create. Ask one
`git cat-file --batch-check` over stdin instead; it reports a missing ref
as data rather than a failed exit, so a batch stays as decidable as the
per-ref probe. Hosts that cannot feed stdin, and undecided batches, still
fall back to the per-ref path.

The prepared checkout was single-use, so the second create in a row paid
the full cold `git worktree add`. Re-arm it in the background after one is
consumed; the existing TTL and preparation limit still bound it.

The create timing recorder existed but its phases were never emitted and
did not cover preflight, leaving a multi-second gap in the trace with no
attribution. Add `resolve_name`/`prepare_push_target` phases and record the
breakdown, plus the unattributed remainder, on the create span.

* fix(worktree): format the conflicting review number eagerly for the create error

* perf(worktree): re-arm a prepared checkout only for a burst of creates

Re-arming after every consumed preparation spends a full checkout and
~200MB of disk on a user who created one worktree and stopped, then pays
an unexplained delete when the TTL expires five minutes later. Track when
each preparation key was last consumed and only replace it when a second
create lands inside the burst window, so the warm second create is still
free and an isolated create costs nothing.

* fix(worktree): address review findings on the create-path batching

Three findings from PR review:

The `batched.found` fallback in the remote-conflict probe was unreachable
— a present ref is decisive, so `found` never survives with `unknown`
set, and the guard above already returns that case.

`rearmPreparation` checked for an existing preparation before recording
the consume, so a prefetch that re-armed the key while create finalized
swallowed the timestamp and made the next create look isolated when it
was really mid-burst.

Create runs some phases concurrently, so summing phase durations
double-counted overlap and understated `unattributed_ms` — the one
number that matters when a create is slow for no visible reason. Measure
the union of the phase intervals instead.

* refactor(worktree): move stale-preparation cleanup into its own module

The preparation module crossed the 300-line budget. Crash recovery is a
separate concern from the pool itself — it discards preparations another
process left registered, single-flighted per repo and runtime so a burst
of arming calls shares one worktree listing.

* test(worktree): make the re-arm test able to fail

The burst test armed a preparation manually after the second consume, so
the third checkout appeared whether or not the re-arm produced it — the
assertion passed with re-arming disabled. Drop that arming call so the
third checkout can only come from the re-arm, and assert the consume
results rather than discarding them.
2026-09-01 19:01:59 -07:00