Commit Graph
9883 Commits
Author SHA1 Message Date
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
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 42d9ac1767 chore(deps): resolve 81 of 83 Dependabot alerts in docs/site and mobile (#18061)
docs/site: bump next 16.2.1 -> 16.3.4 (with eslint-config-next) and vercel
50.37.0 -> 59.11.1, then refresh transitives. The 16.3.x jump is required:
16.2.x hard-pins the vulnerable postcss@8.4.31 and sharp@^0.34.5, while
16.3.x pins postcss@8.5.23 and sharp@^0.35.4.

Five packages are exact-pinned by vercel's own subpackages, so they get
scoped overrides. Scoped rather than blanket because a bare undici override
would drag the 6.x/7.x consumers in the tree down to 5.x.

mobile: bump browserslist 4.28.2 -> 4.28.8.

Two alerts stay open, both in mobile:

- decode-uri-component@0.2.2 (#285). An override to 0.5.0 breaks the tree:
  0.5.0 is ESM-only with a default export, but query-string@7.1.3 is CJS and
  does `require('decode-uri-component')`, so parse() throws
  "decodeComponent is not a function" and takes URL parsing in expo-router
  and @react-navigation/core with it. Both pin query-string@^7.1.3; the fix
  has to come from upstream moving to query-string 8+.
- image-size@1.2.1 (#179, #180) via metro. No patched version exists on any
  release line, so there is nothing to override to.

Verified: docs/site build, tests, lint, tsc and frozen install; mobile
typecheck, 3985 tests and frozen install.
2026-09-01 20:17:41 -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
Neilandkaluli123123 3f5c54332d fix(github-project): sort and group empty field values last in both directions
compareSort early-returned 1 for a missing value — before the trailing
DESC flip — but expressed the same idea as `cmp = 1` for an empty
users/labels list, which that line then negated. Descending order
therefore scattered empty cells across both ends of the table.
getFieldValueForGrouping had the matching defect: an empty list fell
through to deriveStringValue and produced a blank-label group that the
header renders as the literal "All".

Both paths now share one predicate, which also covers `text: ''` and
`date: ''` — reachable because the view normalizer maps a null GitHub
text/date to the empty string.

Co-authored-by: kaluli123123 <295758798+kaluli123123@users.noreply.github.com>
2026-09-01 19:01:45 -07:00
Jinwoo Hong fd33f9b0f9 fix(review-notes): classify send failures and mirrored tabs (#18023)
* fix(review-notes): classify send failures

* fix(review-notes): honor structured runtime error codes

* test(review-notes): use full runtime error envelope

* chore: remove unrelated merge formatting

* refactor(review-notes): share runtime failure codes

* fix(review-notes): classify structured runtime timeouts
2026-09-01 21:52:36 -04:00
Neil 401664298f fix(preload): make a dropped bridge key a compile error
The split silently dropped jira.searchUsers and
runtimeEnvironments.retryControlConnection. Neither failed typecheck: the bridge
modules carried no satisfies annotation and the composed api object was
unannotated, so a missing key was only a runtime TypeError in the renderer.

Annotates each module against PreloadApi, the type window.api is already
declared as, so the contract supplies the shape rather than a parallel copy.
Deleting jira.searchUsers now fails with TS2741 naming the key.

Turning this on surfaced 106 places where a bridge locally annotated
Promise<unknown> or unknown[] over a contract that declares concrete types --
the bridge was erasing types the renderer relied on. Those annotations are gone.

Also exposes app.awaitBeforeUnloadCheckpoint, which was declared and called but
never actually on the bridge, so the lazy-chunk recovery reload optional-chained
to a no-op and navigated without joining the checkpoint. The missing key was
caught by the new annotation rather than by hand.
2026-09-01 18:24:49 -07:00
Neil 894c5fe36a test(orchestration): fail loudly on an unexpected second detection call
The mock overwrote resolveDetection on every call, so a second invocation would
strand the first promise and hang to a 30s timeout instead of naming what
changed. A test that hangs rather than fails is how a real bug gets mistaken for
infrastructure noise.
2026-09-01 18:22:59 -07:00
Neil 4efc86a33c feat(app): open Markdown files from the OS in the floating workspace (#17906)
* feat(app): open Markdown files from the OS in the floating workspace

Registers Orca as a Markdown handler on macOS, Windows and Linux, and opens
an OS-handed .md/.markdown/.mdx file as a floating-workspace editor tab —
the one editor surface that needs no project. Works cold-start and when
Orca is already running.

Main buffers the paths and both pushes to a live renderer and answers a
pull on renderer mount, mirroring SkillShareDeepLinkState. The buffer is
only released once delivery is possible: the renderer's pull is what proves
its ui:openMarkdownFiles listener is attached, because a push into a window
whose renderer has not subscribed is dropped by Electron with no error. Both
the push and the pull restore an undelivered batch, and a renderer reload
clears the latch so the fresh renderer re-proves itself. Paths are stat'd and
proven to be files before authorizeExternalPath sees them.

Windows association is registered by hand in the NSIS include rather than
through electron-builder's `fileAssociations`: app-builder-lib emits
APP_ASSOCIATE, whose first line overwrites Software\Classes\.md's default
value with no backup — silently taking .md from whichever editor owns it,
for every existing user on their next update — and APP_UNASSOCIATE never
restores it. The hand-rolled registration is additive (ProgID +
OpenWithProgids + SupportedTypes) and leaves the user's default alone;
verified end to end on a real Windows 11 host.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>
Co-authored-by: Jaydev <java-jaydev@users.noreply.github.com>

Closes #10138

* fix(os-open): register the new listener in the IPC inventory, and guard a non-array payload

CI caught two things the local run did not.

useIpcEvents-lifecycle.test.ts is an inventory of every App-lifetime IPC
listener and the exact order they register in; ui.onOpenMarkdownFiles now
appears there, positioned after the workspace-shortcut bridge's last
listener, which is where it actually registers.

Chasing that failure surfaced a real gap: the pending-open payload crosses
the preload boundary, so a stale or mismatched preload can resolve with
something that is not an array, and reading .length off it threw inside the
promise chain instead of failing at the boundary. Array.isArray now gates it,
with a regression test.
2026-09-01 17:35:38 -07:00
Neil d48ab96144 test: stop two suites failing for reasons unrelated to their subject
The zsh wrapper test relocated into a fixed-name directory in shared temp, so a
single killed run left it behind and every later run on that machine failed with
ENOTEMPTY, permanently. Makes the name unique while keeping the non-ASCII
component the test exists for.

The palette budget asserted a helper named percentile95 that returns
sorted[floor(n * 0.95)] -- the maximum of the batch. Asserting worst-case
wall-clock under a parallel runner measures scheduler preemption: the asserted
quantity ranged 123-343ms across 20 saturated windows and blew the 220ms budget
in 6 of them, while the fastest sample of those same batches held at 19-32ms.
Asserts the fastest sample instead and adds a deterministic fan-out ceiling, so
the guard counts work rather than time. Budgets are unchanged.
2026-09-01 17:21:10 -07:00
Neil f2fa4a7754 fix(worktrees): drop an unreachable runtime arm from the retirement gate
`findExactRepoOwner` already refuses a repo carrying both a runtime
`executionHostId` and a `connectionId` -- `resolveRepoOwnershipEvidence` calls
that pair contradictory, and one non-owned candidate voids the whole lookup.
There is also no way for a `connectionId` to yield a `runtime:` host id, since
`toSshExecutionHostId` always emits `ssh:`. The runtime arm of
`connectionMatchesHost` could therefore never decide anything, and the test
meant to pin it was passing through the contradiction gate instead.

Keep the SSH arm, which does gate, and record where the runtime refusal
actually comes from. Unreachable code on a destructive path reads as a
guarantee it is not making.

Refs #17776
2026-09-01 17:20:17 -07:00
Neil 398aeccdfe fix(worktrees): retire runtime-host metadata a scan proved gone
A paired client's WorktreeMeta for a runtime host is exempt from
gcStaleWorktreeMeta -- that GC skips any row that is not local on both the
repo and the meta's hostId -- so a scan-proven removal is the only thing that
ever retires one. Both halves of that path were gated to `ssh:`, so the client
kept a row for every remote worktree it had ever seen and dropped none.

The renderer already computed the removals for runtime hosts and purged its
own in-memory state with them; only the persisted half bailed. Widen it, and
the matching main-side handler, to runtime hosts. `OffHostExecutionHostId`
names the set precisely: the hosts the local-only GC skips.

Also require `source === 'git'` before retiring anything. `session-fallback`
reports `authoritative: true` but is the truncated, visibility-filtered
`worktree.list` reply from a host too old for `worktree.detectedList`; its
omissions are no evidence a checkout is gone. That guard did not matter while
this only ran the in-memory purge, and does now that it deletes rows.

A repo that reaches its checkouts over a connection is still never condemned
under a runtime host id -- the host that executes owns that verdict.

Refs #17776
2026-09-01 17:20:17 -07:00
Neil 05a7d39058 test(runtime): make the off-host sweep case a real control
The row was stamped `ssh:build-box`, which
`captureNativeLocalWorktreeMetadataScanExpectation` filters out before the
prune runs -- so it survived whether or not any host gate existed and pinned
nothing.

Stamp it `local` so it is a genuine prune candidate whose directory really is
missing, and make the fixture identical to the first case apart from
`connectionId`. That pairing is what proves the behavior: the same fixture
without a connection loses the row. Deleting any single gate would not show
it, since four independent checks derive from `connectionId` on this path.

Refs #17776
2026-09-01 17:20:17 -07:00
Neil ff8b4d08ab fix(runtime): sweep missing local worktree metadata on the host that owns it
`pruneMetadataMissingFromAuthoritativeLocalScan` had exactly one caller:
`ipcMain.handle('worktrees:listAll')`. A headless runtime host has no
renderer, so it never ran, and that host's `worktreeMeta` grew without bound
even for its own local repos -- 129 of 139 rows dangling on the profile in
#17776.

Run it from the runtime's own detected listing instead. That is the same
trigger on the same evidence: `listDetected` already prunes lineage on an
authoritative scan, and a paired client refreshing a remote repo calls
`worktree.detectedList`, so the host now sweeps exactly when the desktop
would have.

The expectation is captured before the scan, because listing can mutate
metadata synchronously before its first await. WSL-routed repos are excluded
for the reason the desktop listing excludes them: the listing runs in the
distro and reports Linux paths while metadata can hold UNC ones, and v1
cannot prove those aliases equivalent. A runtime needing repair throws rather
than resolving routing, which is likewise no basis for deleting rows.

The prune's own gates still apply, so an SSH- or otherwise off-host repo is
never swept from a local stat -- the execution host owns that verdict.

Refs #17776
2026-09-01 17:20:17 -07:00
Neil 1a11f82fcc test(persistence): cover each session scalar as an orphan's only residue
`activeWorktreeId`, `activeWorkspaceKey` and `activeWorktreeIdsOnShutdown` are
pruned by bespoke rules rather than by owner key, so no owner-key loop reaches
them and each has to be able to seed the sweep alone. The sweep already handles
all three -- the census seeds from them and `removeRepoFromWorkspaceSession`
clears them -- but nothing pinned it, and dropping that seeding turns all three
cases red.

The `activeWorkspaceKey` case uses the canonical `worktree:<id>` form, so it
also covers unwrapping the workspace key before the repo id is visible.

Refs #17776
2026-09-01 17:20:17 -07:00