Commit Graph
8686 Commits
Author SHA1 Message Date
Brennan Benson 7ec2986fd1 fix(lint): merge the duplicate agent-status contract type imports (#20907)
main's tip fails audit:code-quality:native on import(no-duplicates), which
reds the static analysis and verify jobs of every open PR via the merge ref.
2026-09-15 17:36:08 -07:00
BAEK'spaceandJinjing 3520e8eb41 fix: highlight bash fences in Markdown source mode (#20592)
* fix: highlight bash fences in Markdown source mode

* refactor: trim shell fence alias registration

Drop the speculative exports and document the alias-resolution rationale in
one WHY comment; the idempotency guard stays.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-09-15 17:32:54 -07:00
Brennan Benson a9232e8db6 fix(claude): single-own turn identity so Stop reaches a provider-opened turn (#20794)
* fix(claude): single-own turn identity so Stop reaches a provider-opened turn

Stop silently failed on any Claude turn the provider opened on its own — a
background task reporting in wakes the agent — once the session had dispatched
at least once. The transcript read "The provider had already finished this
turn." while the model kept working.

Turn identity was minted twice from the same stream by two components that
never talked. The journal translator writes turnId into the durable turn row,
which is the id every client's Stop carries. settleWaiter separately wrote
session.activeTurnId, only ever on the dispatch-echo path, and nothing cleared
it. Cancel read the adapter's copy; prompt binding, status and both clients
read the journal's. They agreed only when a send echo opened the turn.

Turn identity is now single-owned. The open turn moves out of the translator's
closure into ClaudeOpenTurn, which holds the turn and publishes its lifecycle
row, so the id readers ask for is the id the row carries. activeTurnId and
activeTurnSequence are deleted rather than widened, so the second writer goes
with them instead of a second guard being added beside the first.

activeTurnSequence was never turn identity: it asked whether a send was still
awaiting its echo, which an interrupt would release as an unexpected turn. That
is now derived from the live dispatch waiters. Deriving it also retires a latch
— a retired waiter left the stored sequence permanently behind the dispatch
sequence, refusing every later Stop for the life of the session.

Also fixes the mirror defect the same hazard caused: a stale turn id was
accepted against a newer provider-opened turn, because activeTurnId was never
cleared when a turn ended.

The Claude adapter fixture now acquires with a journal sink, as production
does; without one it modelled a session that never ships.

* fix(claude): reject stale stop after turn settles

* fix(claude): preserve dispatch cancellation fence

* test(claude): cover provider-opened stop integration

* fix(claude): derive dispatch cancellation fence from journal

* fix(claude): honor journal dispatch status before local sends

* fix(native-chat): omit absent dispatch observation

* fix(claude): release unresolved stop fence after deadline

* fix(claude): bound and poll dispatch admission wait

* test(claude): cover dispatch admission fast path
2026-09-15 16:54:43 -07:00
Brennan BensonandMerge Sim 6da72383df feat(agent-launch): one executor for agent launches, exposed as agent.launch (#19849)
* refactor(agent-launch): make the launch-mode decision surface-neutral

`decideWorkerStartMode` was the only shared answer to "structured chat session
or terminal agent?", but it lived in an orchestration-named module and spoke
orchestration's vocabulary, so the other launch surfaces could not call it.
Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave
`orchestration-worker-start-mode` as the adapter that supplies the noun.

A worker is not a special kind of launch; it is the same launch with a dispatch
attached. Naming the receipt's subject is the only thing orchestration actually
contributed, so that is the only thing the adapter keeps: "worker" in both
sentences, plus the `--terminal` wording, which reads as nonsense anywhere a
`--terminal` flag does not exist. Both are pinned, because they are asserted.

No behavior change. The receipts are byte-identical for every reachable case,
proven by running the new pin against both implementations.

Also pins the wording, which nothing was holding. The existing suites assert
`toContain` fragments ('terminal agent', 'cannot create') and the CLI suite
asserts a receipt handed to it by a mock rather than one this code produced;
all six files stayed green against a deliberately corrupted vocabulary. A
dispatch receipt is the only place a structured-to-terminal downgrade explains
itself, so the whole sentence is the contract, not a fragment of it.

* feat(agent-launch): add the launch intent and the one executor that runs it

The sequencing around the launch decision was duplicated per surface, and the
duplicate is where the bug lives. A new worktree was created agent-first, so
its startup terminal WAS the agent and the structured branch below it could
never be reached — every new-worktree launch was a PTY regardless of the user's
default. Orchestration fixed that for itself in #19431; mobile and the CLI
still have it.

`executeAgentLaunch` inverts the order once, for everyone. When the preference
is structured the worktree is created with NO startup agent, the executing host
is then asked whether it can host a session for the workspace that now exists,
and only then is a surface created. The host verdict cannot be hoisted above
creation: `agentSession.createSupport` only answers for a workspace it can
resolve, which is why the decision stays in two halves.

Agent-first creation is deliberately preserved for PTY launches — it is what
sequences the agent's startup command behind the setup runner, so wait-for-setup
comes for free there.

What actually differs per surface is only how a surface is built (an
orchestration worker's session takes a dispatch hold and a mailbox a plain
launch must not take), so that is injected as a factory rather than branched on.

The intent also strips the reserved agent fields from a migrated create payload:
a caller moving off `worktree.create` passes its existing params, and a stale
`startupAgent` in there would re-create the very path this replaces.

Tests assert order and arguments, not just the resulting mode. Reintroducing
agent-first creation reddens 4 of 11.

* feat(agent-launch): expose the launch executor as the agent.launch RPC

Adds `agent.launch` — one host-side method that decides structured-vs-terminal and
creates the surface — wired to the real runtime factories: `createManagedWorktree`
for the workspace, forking on `startupAgent` exactly as the orchestration worker
path does; `createStructuredAgentSessionForWorktree` for a chat session; and
`createTerminal` for a PTY agent. Allowlisted for mobile, which is the surface the
routing gap was reported on.

`worktree.create` is untouched. Its `startupAgent` keeps meaning "spawn a PTY agent"
verbatim, because it answers with `agentTerminalHandle` only on that path: a host
that quietly routed it to a structured session would hand every older client a
response with no handle and no error. All new behaviour sits behind
`agent.launch.v1`, which the host now advertises and a remote client must negotiate,
so a client that does not gets today's behaviour unchanged.

* fix(agent-launch): drop the deleted draft-prompt blocker from the reason map

main removed the draft-prompt blocker in #19681 (a structured session now holds
an unsent draft), so the exhaustive Record no longer typechecks.

* chore(agent-launch): carry a SAFETY rationale on the agent placement cast

The type-assertion gate landed after this branch's base, so the new file's
copy of the worker-start cast is now a changed-code finding.

* chore(agent-launch): carry agent.launch through main's RPC typing and casting gates

The typed-method contract, the generated params catalog and the
`assertionStyle: never` casting scan all landed after this branch's base.

- AGENT_LAUNCH_METHODS kept an `RpcMethod[]` annotation, which widened its
  method name to `string` and broke assignability; every sibling infers instead.
- `agent.launch` binds a schema under src/main, so it joins the catalog's
  RPC_METHODS_WITHOUT_SHARED_PARAMS and the parity gate's hand-listed twin.
- The now-typed methods make most test casts unnecessary; the few that remain
  carry the line-specific SAFETY rationale the casting gate requires.

* docs(agent-launch): stop the receipt-wording comment claiming a migration

The decision was never moved out of orchestration-worker-start-mode; this PR
adds a second copy beside it. Say so, and name the unenforced agreement.

* docs(agent-launch): stop the executor comment claiming a migration that has not happened

The header asserted two things the tree does not support: that every launch
surface routes through the executor, and that the mode decision "already lived"
in `agent-launch-mode`. `agent.launch` is the executor's only consumer, and
`orchestration-worker-start-mode.ts` is byte-identical (blob 92dc5c644a, 217
lines) at the merge base and all three stack heads, still used by workers.ts.
Describe the two live copies and leave the cutover to later stack work.

* fix(agent-launch): preserve setup and refusal fallbacks

* fix(agent-launch): dedupe complete launch and cancel setup wait

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-15 16:35:32 -07:00
Neil 13ba649c22 fix(terminal): let a runtime-created Windows terminal BE the requested shell (#20825)
* fix(terminal): let a runtime-created Windows terminal BE the requested shell

`orca terminal create --environment <windows-host> --command 'cmd.exe'` never
created a cmd terminal. `--command` is text the provider TYPES into whatever
shell it spawned, so the PTY stayed the host's default shell with cmd running
inside it. Captured on `awin`, whose default is Git Bash:

    $ orca terminal create --environment awin --command 'cmd.exe' --json
    $ orca terminal send --environment awin --terminal term_10656cf7... \
        --text exit --enter
    $ orca terminal read --environment awin --terminal term_10656cf7... --screen
      neil@awin MINGW64 ~/orca/orca ((30f820708f...))
      $ cmd.exe
      Microsoft Windows [Version 10.0.26200.9445]
      C:\Users\neil\orca\orca>exit
      neil@awin MINGW64 ~/orca/orca ((30f820708f...))
      $

The handle is alive the whole time and `terminal list` shows one healthy
terminal, because the PTY never changed — so the only symptom is that the
caller's terminal is now a shell it never asked for, and every later `send` is
quoted for the wrong one. On `win-lowspec` (default pwsh) the same create lands
cmd inside PowerShell.

Root cause
----------
There are two spawn preflights and they are twins:

- `src/main/ipc/pty/ipc/spawn-preflight.ts` — renderer/IPC spawns, i.e. a
  terminal tab opened in the app.
- `src/main/ipc/pty/runtime/spawn-preflight.ts` — runtime spawns: the CLI's
  `terminal.create`, headless `orca serve`, and every paired remote
  environment.

Only the IPC twin read the caller's requested shell. The runtime twin passed a
literal `requestedShellOverride: undefined`, so a runtime-created terminal on
Windows could only ever be the host default. Everything downstream of that
point — `spawn-options`, the daemon, `resolvePtyShellOverride` in the relay,
`local-pty-launch-plan` — already honoured `shellOverride`; nothing upstream
could supply one.

Change
------
- Thread `shellOverride` through the runtime lane: `RuntimePtySpawnArgs` ->
  runtime `spawn-preflight` -> `RuntimePtyController.spawn` ->
  `TerminalCreateOptions` -> the `terminal.create` RPC's new `shell` param ->
  `orca terminal create --shell`.
- Thread it through the renderer-backed lane too (`createDesktopTerminal` ->
  `terminal:requestTabCreate` -> `store.createTab`), so `--shell --focus` is not
  silently dropped on a local Windows app.
- An agent launch quotes its startup command for the shell it will actually run
  in, so a requested shell now owns the startup-shell family instead of the
  global `terminalWindowsShell` setting.
- Lift the relay's `ALLOWED_WINDOWS_SHELL_OVERRIDES` into
  `isSupportedWindowsShellOverride` in `src/shared/windows-terminal-shell.ts`
  (membership unchanged) so the CLI, the zod param schema, and the relay refuse
  the same names. `--shell` therefore cannot carry a path or a command line into
  `pty.spawn`; only allowlisted bare shell names pass.
- Gate on `TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY`. An older host
  strips the unknown `shell` param and answers with a healthy terminal running
  its default shell — a reply indistinguishable from success — so the CLI
  refuses before creating anything rather than creating the wrong shell quietly.

`--shell` stays Windows-only; macOS and Linux hosts spawn the login shell and
the relay drops the value off win32 rather than honouring it half-way. A WSL
project runtime still outranks it, unchanged.

Tests
-----
- `pty-spawn-shell-override-parity.test.ts` pins both preflights against the
  exact drift that caused this (verified failing with the fix reverted).
- `createTerminal` passes `shellOverride` to `ptyController.spawn` with no
  startup command.
- CLI: sends `shell`, refuses a shell the host cannot spawn, and refuses a host
  without the capability — in both refusals without making the round trip.
- Allowlist and `terminal.create` schema accept/refuse cases, including paths
  and appended arguments.

* fix(terminal): refuse a requested shell the execution host cannot apply

The first commit made `--shell` reach the spawn, but only a LOCAL win32
execution host applies it: `spawn-options` gates the override on
`process.platform === 'win32' && !args.connectionId`. So `--shell cmd.exe`
against an SSH-routed worktree, or against a macOS/Linux host, still returned a
healthy terminal running that host's default shell — the same
indistinguishable-from-success reply the capability gate exists to prevent, one
layer down.

Refuse instead, before anything spawns. The check sits at the top of
`resolveAgentTerminalCreateOptions`, which every create lane funnels through, so
neither lane has to remember it; the desktop lane additionally refuses a
worktree-less create, which has no execution host to resolve a shell on.

An SSH host's platform and installed shells are not visible to this runtime, and
a POSIX host has no Windows shell to pick. Neither can honour the request, and
saying so is the whole point of the flag.

Docs and the CLI spec now say "refused", not "ignored".

* fix(terminal): refuse a shell that contradicts the project execution runtime

`resolveLocalWindowsTerminalRuntimeOptions` does not merely rank the project's
execution runtime above a per-terminal pick -- it REWRITES the pick, in both
directions, and says nothing:

- a WSL project forces `wsl.exe`, discarding `--shell cmd.exe`;
- a Windows-host project discards a WSL name and falls back to `COMSPEC`
  (`getHostShellForProjectRuntime`), so `--shell wsl.exe` spawns cmd. That is
  the common case, not an edge: `resolveProjectExecutionRuntime` resolves
  `windows-host` for every project that is not WSL, while a repo belonging to no
  project honours `wsl.exe` -- so the same flag behaved differently depending on
  whether the repo was in a project.

Either rewrite returns a healthy terminal running a shell the caller did not ask
for, which is the failure `--shell` exists to remove.

It also split an agent launch's quoting from the shell that receives it. The
previous commit made the startup-shell family follow the REQUESTED shell, so
`--shell wsl.exe --command codex` on a Windows-host project typed POSIX-quoted
launch args into cmd. Refusing the contradiction removes that case rather than
papering over it.

Refuse instead, alongside the SSH and non-Windows refusals, from the same
`resolveAgentTerminalCreateOptions` seam every create lane funnels through.

Also from review:
- the allowlist test looped the list against itself; spell the members out.
- the runtime spec case claimed to prove the pty's shell when it asserts the
  controller received the field; name it for what it checks.

Reported by an adversarial review of the branch.

* fix(terminal): canonicalize --shell and refuse a WSL-path rewrite

Review of the --shell create path turned up two ways the terminal could
still end up being a shell the caller never asked for -- the exact failure
--shell exists to remove.

Bare and mixed-case spellings passed the allowlist but reached consumers
that exact-match the canonical name: resolveWindowsShellStartupFamily
classified `cmd` as the PowerShell family, resolveWindowsShellLaunchArgs
fell through to empty shellArgs (no `chcp 65001`, no OSC 133 bootstrap that
Windows foreground status depends on), and resolveWindowsGitBashShellPath
compares case-sensitively so `Git-Bash` spawned a literal `Git-Bash`.
The allowlist is now one canonical-name map and terminal.create canonicalizes
on parse, so the spawn path only ever sees `.exe` spellings. `pwsh` and
`powershell` stay distinct binaries.

A `\\wsl$\<distro>\...` cwd made the providers force wsl.exe regardless of
the request, and terminalShellOverrideRefusal only inspected the project
runtime -- undefined for a folder workspace with no project. Refuse on the
resolved cwd and the workspace path, judging what the PTY actually gets.

Also: the capability gate reported an unreachable host as too old rather
than unavailable; the SSH CLI shim dropped capabilities from status, so
--shell there blamed the host version instead of naming SSH; and --shell
had no help entry, rendering bare in `orca terminal create --help`. Adding
that entry crossed help.ts's max-lines cap, so the flag table moved to
flag-help-text.ts rather than suppressing the rule.

Adds a behavioural test for the runtime preflight (the one-line fix was
pinned only by a source-text scan), plus coverage for the startup-command
quoting family, the no-workspace refusal, and the WSL-path refusal.

* fix(build): keep tests out of the RPC params catalog bundle

The catalog walk under methods/ already skips *.test.ts, but the contract
directory glob took every .ts. terminal-create-shell-param.test.ts is the
first test to live there, so the bundle pulled vitest into a CJS build and
the generator threw on require(). Same exclusion, same reason.
2026-09-15 16:34:16 -07:00
Brennan Benson 0325f1a22e feat(agent-status): add the canonical store and child-work contract (#20717)
* feat(agent-status): add canonical shared store contract

* fix(agent-status): harden canonical store invariants

* fix(agent-status): close canonical store race windows
2026-09-15 16:05:48 -07:00
Jinwoo Hong 52b6851b6e fix(worktree-create): prioritize creation Git and defer background preparation (#20722)
* fix(worktree-create): run create git commands at interactive tier, defer pool side jobs, bound queue wait by timeout

Creating a worktree on a busy machine stalled for minutes because the create's
own git competed for the same admission budget as everything else.

- The create path never set an admission tier, so it defaulted to 'status' and
  could never use the scheduler's headroom slots. It now tags the option objects
  that reach git directly: the add, the post-add listing, the base-ref probes and
  the prepared-checkout finalize. The speculative warm-up and the SSH path are
  unchanged.
- The prepared-pool re-arm is a full `reset --hard`; it ran mid-create and held a
  general slot. `consumePreparedWorktreeCreate` now returns it as a thunk the
  create runs after the startup terminal is spawned. Stale-preparation
  reclamation (`worktree unlock` / `worktree remove`) drops to 'background'.
- A command's timeout only armed once its child spawned, so a saturated queue
  could hold a 1s command indefinitely. Admission now takes the same deadline and
  raises GitCommandTimeoutError without spawning; a caller abort still reports as
  an abort.

The tier is kept off the `{ wslDistro }` routing objects: several callers test
those for emptiness to decide whether a repo has local git routing at all.

* fix(worktree-create): keep a bounded queue wait from reading as an absent base ref

The admission deadline added in the previous commit made every create-path probe's
15s/120s budget cover the queue wait. The default-base and worktree-base probes answer
`false`/`null` for any failure, so a saturated queue reported a repo that has origin/main
as having no default base and the create refused to start. Both probe families now let
`GitCommandTimeoutError` through, and the branch-name resolution loop, the push-target
configuration and the post-add listing run at the create's interactive tier so they reach
the headroom the rest of the create already uses.

Also: the deferred pool re-arm re-checks the pool inside the thunk, since `startPreparation`
replaces a map entry outright and would strand a prefetch's locked checkout with no owner;
the shared worktree scan keys on the tier so an interactive listing cannot inherit a queued
status scan's wait; and the deadline's microtask hop is gone, along with two fake-timer
`vi.waitFor` calls that jumped the clock past a 10ms budget before the grant settled.

* fix(worktree-create): preserve probe fallbacks and defer runtime replenishment

* fix(worktree-create): preserve interactive priority through prepared claims

* fix(worktree-create): prioritize CLI creation and preserve SHA probe timeouts

* fix(worktree-create): scope Git execution policy at creation boundaries

* fix(worktree-create): preserve inconclusive Git probe timeouts

* test(runtime): align creation fixtures with scoped Git execution

* test(native-chat): extract windowing layout fixture to satisfy file limit

* fix(git): restore execution-only timeouts while queued

* refactor(worktree-create): remove unrelated error-handling changes

* chore: narrow review scope and clarify preparation timing

* test(native-chat): restore fixture extraction to fix CI lint

* refactor(git): keep the admission scheduler in its original module

Reverts a move-only extraction. Inlines the single-use command-class
wrapper so the tier-resolution import fits the file's line budget.

* fix(worktrees): re-arm the prepared pool after CLI create launches terminals

The runtime create fired the pool re-arm right after materialization, so its
`reset --hard` competed with the startup agent's first git reads. Return the
thunk to the caller and fire it last, matching the desktop path.

* fix(worktrees): skip a preparation whose checkout is still running

An interactive create that claimed an in-flight preparation awaited a checkout
queued at background, so on a saturated budget it yielded to every arriving
status poller until aging promoted it. The create now misses with not_ready and
does its own add at interactive; the preparation stays armed for the next one.

Also drops the one-field policy object from the Git operation executor.

* fix(worktrees): report repo_mismatch before not_ready when selecting a preparation

The readiness filter ran before the same-repo check, so another repo's
in-flight preparation was labeled not_ready instead of repo_mismatch, hiding
the cap-thrash signal for multi-project users. The hit/miss decision is
unchanged.

* test(runtime): type the worktree-meta stub against WorktreeMeta

Main now rejects bare object parameters, and the merge picked that rule up.

* fix(worktrees): wait on in-flight preparations and re-arm the pool on failed creates

A create landing mid-checkout now claims the in-flight preparation and awaits it, as main
did. The `checkoutFinished` filter and its `not_ready` miss reason made the create skip a
prepared checkout that was seconds from done and pay a full cold add instead; on a 40k-file
repo that turned a 0.2-1.5s create into 2.4-4.3s. The preparation's own git also runs at
`status` again rather than `background`, so awaiting it does not park behind status pollers.
Only the stale reclaim stays `background`, which no create waits on.

The deferred pool re-arm now fires on every path, not just the success path. Main armed the
replacement synchronously inside the consume, so a later failure in include copy, push-target
setup, or terminal startup still left one warming. The thunk stays deferred until after
terminal startup for admission ordering, but a `finally` on the desktop create and matching
failure-path fires on the runtime create restore that guarantee. It fires exactly once.

* refactor(runtime): carry the pool re-arm in one holder

The runtime create used three mechanisms to guarantee the deferred pool re-arm fires: a
catch in the git create, a catch on materialization, and a holder fired in the managed
create's finally. The desktop create already used one holder for the same guarantee.

The holder now threads down through the create args, so the git create arms it at the point
it consumes a prepared checkout and nothing below has to handle the failure case. The thunk
already re-checks the pool before arming, so a single fire point in the outermost finally
covers every failure after the consume. Behavior is unchanged; both flipped failure-path
tests still assert exactly one fire, and each fails without the production change.
2026-09-15 19:02:50 -04:00
Neil 8edec28a55 fix(worktree): keep a WSL checkout case so delete cannot take the twin branch (#20273)
* fix(worktree): let a POSIX path keep its case on a Windows desktop

`canonicalWorktreePath` folded case whenever `process.platform` was win32,
without asking what the path itself was. A WSL or SSH checkout is spelled
`/home/alice/ws/feature` on a Windows desktop too, and ext4 is case-sensitive,
so `/home/alice/ws/Feature` and `/home/alice/ws/feature` — two real checkouts on
two real branches — collapsed into one row.

`removeWorktree` picks the row it is about to remove with that comparison and
reads the branch off it. Requesting `/home/alice/ws/feature` removed the right
directory (the path rides in argv) and then ran `git branch -d -- Feature`. The
same wrong row feeds `assertWorktreeUnlockedForRemoval`, so a locked twin blocks
an unlocked delete and an unlocked twin lets a locked one through.

Whose filesystem a path names is a property of the path, not of the desktop
reading it, so a POSIX-absolute path now takes POSIX rules at any platform and a
POSIX/Windows pair is never equal — `win32.resolve` would otherwise give the
POSIX path a drive root and manufacture the equality. Windows drive and UNC
paths, including WSL UNC aliases, keep folding case as before.

Two call sites already carried private copies of this rule
(`isSameCommonDirPath`, `ipc/worktree-path-comparison`); this is the same rule at
the source. The removal path is the one that never got one.

* fix(worktree): keep a WSL checkout's case through the UNC spelling too

The first commit gave POSIX-absolute paths POSIX case rules, which is right but
does not reach the WSL case it claimed. `listWorktreesStrict` runs every listed
path through `translateWorktreePath`, so git-in-the-distro's
`/home/alice/ws/Feature` arrives as `\\wsl.localhost\Ubuntu\home\alice\ws\Feature`
and the POSIX branch never sees it. The removal suite mocks
`translateWslOutputPaths` to identity, which is why the end-to-end test passed
without exercising the translation production always applies.

Driving the real translator, the original defect survived unchanged: a request
naming `...\ws\feature` ran `git worktree remove --force ...\ws\feature` and then
`git branch -d -- Feature`.

The filesystem behind `\\wsl.localhost` is ext4, so the UNC spelling is
case-sensitive for the same reason the Linux spelling is — except where Windows
genuinely folds: the `\\wsl$` share alias, the distro name, and a drvfs
`/mnt/<letter>` tail, which really is a Windows volume.
`foldWslUncPathCaseInsensitiveParts` already draws exactly that line and
`git-fetch-head-lock` already depends on it, so this reuses it rather than
writing a fourth copy of the rule. Windows drive paths keep folding whole.

The end-to-end case now drives the real translator instead of the mock, so the
translation cannot go missing again without the test noticing.
2026-09-15 16:01:32 -07:00
Lesley Murfin 946dacc65d fix(worktrees): route unstamped local worktrees local in the two states #16841 still fails closed (#16829)
* test(worktrees): cover local worktree owner routing with saved runtimes (#16733)

A local git worktree whose rows carry no host stamp fails every owner-routed
operation closed as soon as any runtime environment is saved, however unrelated.
resolveWorktreeOperationRouteResult establishes positive identity from the
worktree/repo catalogs, then discards it: with no runtime active the only exit is
the legacy-local gate, which demands an empty saved-runtime list. One saved
environment makes that false and the call returns { kind: 'missing' }.

These tests state the contract before the fix, so the claim that the fix is
purely additive can be checked rather than asserted. Committed red on purpose.

Observed at 5631aa00dd (vitest run, both files):

  Tests  7 failed | 52 passed (59)

The 7 failing are exactly the states that must become local, plus their two
consumers:

  - an unrelated runtime is saved (the reported bug)
  - several unrelated runtimes are saved
  - the repo is known before its worktree row is listed
  - the saved-runtime catalog has not hydrated
  - an unrelated runtime was removed
  - resolveTerminalWorktreeRoute on such a worktree (the gate in front of the
    "Terminal creation is unavailable" reply)
  - the folder/worktree parity state: identical store, folder local, worktree
    missing

The other 52 pass now and must keep passing: connection-owned and
runtime-stamped repos never route local, a stamped worktree row still outranks
its repo, contradictory repo rows stay ambiguous, an ambiguous or hydrating
runtime focus still fails closed, and a genuinely unknown id still fails closed.
That set is the additive-only guarantee.

* test(worktrees): re-aim two fail-closed cases at genuinely missing owners (#16733)

Two cases in src/renderer/src/lib/worktree-operation-route.test.ts assert the
behaviour #16733 reports as the bug, so they have to move:

  - :158 'fails a paired-client ownerless stale publication closed instead of
    routing it locally'
  - :171 'fails ownerless rows closed until the saved-runtime catalog is hydrated'

Both arrived with #9994 (41751dd90d, route HUB-owned SSH worktrees through their
owning runtime), whose stated goal was to fail closed for missing or stale
owners. That goal is right and is kept. The premise being rebutted is narrower:
neither fixture describes a missing or stale owner. Each carries a present repo
row that is merely unstamped -- repos: [{ id: 'repo-1' }] -- and three places in
this codebase already read exactly that row as locally owned:

  - shared/execution-host.ts getRepoExecutionHostId returns LOCAL_EXECUTION_HOST_ID
  - main/ipc/worktrees/listing/worktree-host-ownership.ts resolveRepoOwnershipEvidence
    falls back to LOCAL_EXECUTION_HOST_ID, and the listing and removal paths trust it
  - shared/repo-types.ts documents executionHostId as the field runtime-host repos
    need precisely because they otherwise look identical to local repos

attribute. It swept in the legacy-local case because at the time nothing in this
resolver consulted the repo index for an unstamped row.

So each case is re-aimed at the state it was actually defending, and neither is
deleted -- the fail-closed coverage is not reduced, it is pointed at a real
missing owner:

  - the first becomes 'fails a paired-client publication closed when no repo row
    can own it': same runtime state, repos: []. A worktree row alone is not host
    evidence, so this still returns missing, before and after the fix.
  - the second becomes 'fails ownerless rows closed mid-hydration while a saved
    runtime could own them': an active runtime with an ambiguous saved catalog
    during hydration. This returns missing from the active-runtime branch and is
    untouched by the fix. It is worded to stay distinct from the neighbouring
    case at :185, which already covers focus-is-not-ownership with an empty
    catalog, rather than duplicating it.

The states these two cases vacate are re-asserted with their corrected expected
result in the #16733 block added by the previous commit. Suite unchanged at
7 failed | 52 passed (59): the rewrites pass, the 7 reds are still the 7 states
the fix must convert.

* fix(worktrees): keep unstamped local worktrees routable when runtimes are saved (#16733)

resolveWorktreeOperationRouteResult establishes positive identity from the
worktree and repo catalogs, then discards it. With no runtime active the only
exit is the legacy-local gate, which requires an empty saved-runtime list, so one
saved runtime environment -- connected or not, related or not -- made it false
and the call returned { kind: 'missing' }. Every owner-routed operation on a
genuinely local git worktree then failed closed, and because
resolveTerminalWorktreeRoute is the sole gate in front of
terminal-request-ipc-bridge.ts, the user saw "Terminal creation is unavailable
because the worktree owner could not be resolved".

Folder workspaces hit the same gate and were carved out in #10251/#10269, whose
comment in this file states the principle and names this exact failure mode: a
found record is positive identity evidence, and the worktree legacy hydration
gates "would fail local folders closed whenever unrelated runtimes exist". Git
worktrees never got the equivalent. This adds it, in the same shape and the same
function.

The rule is not new. An unstamped repo row is read as locally owned by
getRepoExecutionHostId, by main's resolveRepoOwnershipEvidence, and by
Repo.executionHostId's own documentation; and the repo write path
(repoWithFetchedOwner) stamps runtime: and ssh: owners at fetch time, so an
unstamped row is a legacy row that predates owner projection -- local by
construction. The router now consults that evidence instead of contradicting it.
The sidebar already rendered these worktrees as Local; this removes the
disagreement rather than adding a heuristic.

Four properties this change holds to:

1. The branch sits after the active-runtime block, so an unambiguous active
   runtime still wins (routes runtime:<id>, not local) and an ambiguous or
   mid-hydration focus still returns missing. That ordering is structural, not
   incidental.
2. mayBeLegacyLocal is left byte-identical (verified: both 7-line hunks hash to
   f0ed1b4287c646cb). The new branch does take over the two states where a local
   repo row exists and no runtime is saved, but returns the identical local
   route, so no input changes its answer -- only which branch produced it.
3. The helper returns null on anything but unanimous local, so the branch can
   only ever convert missing into local. It never returns ambiguous: a
   contradiction between repo rows is already decided upstream by
   resolveExplicitWorktreeOperationRouteResult, and answering it here would be a
   second, divergent authority.
4. It reads neither runtimeEnvironmentCatalogHydrated nor
   removedRuntimeEnvironmentIds. That is sound rather than merely convenient,
   because it consults host evidence rather than runtime-environment inference: a
   runtime-owned repo row is stamped runtime:<id> at fetch time, so neither an
   unhydrated runtime catalog nor a removed environment can turn an unstamped row
   into a remote one.

Control only reaches this point after the explicit catalog resolver returned
missing, which means every worktree row and every repo row for this id is
unstamped -- any stamped row routes ssh: or runtime: earlier, and two disagreeing
rows return ambiguous earlier. There is no remote-owned state left here to leak.

Out of scope, deliberately: who wins when a runtime is focused (#11512), and
back-filling Worktree.hostId at creation time, which is a persistence migration
over worktreeMeta and does nothing for the users already carrying unstamped rows.

The 7 cases red in the two preceding commits now pass; the 52 that guard the
fail-closed contract are unchanged.

  Test Files  2 passed (2)
       Tests  59 passed (59)

* test(worktrees): defer repo-row-only routing to #16841's fail-closed rule (#16733)

Upstream #16841 (merged d3475957f3) landed its own fix for #16733 and drew the
positive-identity line one notch tighter than this branch did: its
'does not treat a repo row alone as positive local identity' case asserts that a
worktree id no row has ever listed stays `missing`, even when the repo row for
its repoId is local.

This branch's 'routes a known local repo before its worktree row has been listed'
asserted the opposite result for that identical state, so the two cannot both
hold. Main's rule is the safer reading — a repo row is repo identity, not
worktree identity — so the reconciled code gates
resolveUnstampedLocalWorktreeRoute on hasKnownWorktree and this case is dropped
rather than re-pinned. Every state that actually reproduces #16733 keeps a
worktree row (listed or detected), so the reported bug and both extra
fail-closed edge cases this branch fixes are unaffected.

* refactor(worktrees): drop the unreachable disagreement loop in resolveUnstampedLocalWorktreeRoute

resolveWorktreeOperationRouteResult only calls resolveUnstampedLocalWorktreeRoute after
resolveExplicitWorktreeOperationRouteResult has already returned 'missing' for this repoId.
That function (worktree-operation-catalog-route.ts) indexes every repo row carrying a
non-empty executionHostId or connectionId and resolves/ambiguous-es on any of them, so by
construction every row resolveUnstampedLocalWorktreeRoute ever sees is unstamped -- and
getRepoExecutionHostId's own fallback (shared/execution-host.ts) always resolves an unstamped
row to local. The per-row disagreement check could never actually return null; it was dead
defensive code describing a state the caller's short-circuit already rules out. Reduced to
an existence check with identical behavior (verified: same 66/66 tests, same mutation-proof
property -- reverting only this file still fails exactly the same 6 tests it did before).
Also harmonized a same-function 'local' string literal to the LOCAL_EXECUTION_HOST_ID constant
already in use one branch above it, and dropped a dangling getWorktreeExecutionHostId doc
reference the shipped code never actually calls.

* docs(routing): document the undocumented worktree operation route helpers
2026-09-15 16:01:29 -07:00
Neil 72a8c096a3 fix(ssh): corroborate an empty lsof answer before calling an endpoint free (#20585)
#18304 decided enumerability after `lsof` runs, keying on stderr, non-numeric
output, and an abnormal exit. One failure carries none of those signals: probing
as a uid that does not own the socket's holder, `lsof -t -a -U <path>` exits 1
with no stdout and no stderr. Measured on Debian 12 against #18304's own probe, a
live relay owned by root probed as `nobody`:

  probe    uid      path         marker        pids
  merged   nobody   held.sock    lsof          []      <- live relay holds it
  merged   nobody   stale.sock   lsof          []      <- genuinely nobody
  merged   root     held.sock    lsof          [10]
  merged   root     stale.sock   lsof          []

The first two rows are byte-identical, so nothing about lsof's answer can
separate them. The first reaches `verdict: exited / evidence: no-holder`, which
`classifySupersededRelay` maps to `stale-endpoint-removed` and `rm -f` on an
inode a live relay is still holding. `hidepid=2` produces the same shape.

A positive control does not solve this. Controlling on something the probe itself
holds passes precisely when we are blind: as `nobody`, `lsof -t -p $$` returns a
pid while the socket query returns nothing. Blindness is to *other* uids, and
another uid's process is not ours to manufacture.

/proc/net/unix is. It is world-readable and lists every bound unix socket
regardless of owner, so an entry for the path alongside no reported pid proves
lsof was blind rather than that the path is free. Only an otherwise-clean empty
answer is corroborated; a reported pid still stands on its own, and the check is
skipped when the answer was already unavailable. Same run, with this change:

  fixed    nobody   held.sock    unavailable   []      <- no longer reapable
  fixed    nobody   stale.sock   lsof          []      <- still reapable
  fixed    root     held.sock    lsof          [10]    <- unchanged
  fixed    root     stale.sock   lsof          []      <- unchanged

The marker can only ever move from `lsof` toward `unavailable`, so this never
authorises an unlink that #18304 refuses.

Off Linux there is no /proc/net/unix, the check returns false, and behaviour is
exactly as before -- deliberately, because defaulting to `unavailable` there
would stop every macOS host from reaping a stale endpoint and trade a rare
destructive bug for a universal accumulation one. The tests are Linux-gated for
the same reason, with an assertion that the evidence they depend on is actually
present so the block cannot pass vacuously.
2026-09-15 16:01:21 -07:00
Neil 981a4821da fix(cli,relay): stop reading an unsignalable pid as a dead one (+ unverifiable-collapse sweep result) (#20098)
* fix(cli): stop reporting an unsignalable Orca pid as a stale bootstrap

`orca status` falls back to a `kill(pid, 0)` probe when `status.get` cannot be
reached, and a bare catch read every refusal as absence. EPERM means the pid
exists under another uid -- an Orca reached via ORCA_USER_DATA_PATH, or one
started with sudo -- so a live app was reported `running: false`, `pid: null`,
`runtime.state: stale_bootstrap`, `graph.state: not_running`.

Only ESRCH proves the pid is gone, which is the rule every other liveness probe
in the repo already applies (`isProcessAlive` in relay/pty-shell-utils.ts,
pack-refs-lock-ownership.ts, runtime-metadata-ownership-watch.ts, and
agent-session-process-identity-probe.ts). See
docs/reference/ssh-execution-boundary.md.

* fix(relay): keep a revived pane whose pid only refuses the liveness probe

`revive` gated each serialized pane on a hand-rolled `process.kill(pid, 0)` in a
bare try/catch, so any refusal retired the pane. EPERM means the process exists
under another uid; only ESRCH is evidence of absence.

The file already imports `isProcessAlive`, whose ESRCH-only contract
`reapPtyProvenExited` documents 450 lines earlier -- this call site just did not
use it. Reuse it rather than keeping a second implementation of the same
concept. Malformed pids still skip, as before.

See docs/reference/ssh-execution-boundary.md.

* fix(lint): clear the casting gate on the pid-probe changes

main tightened typescript/consistent-type-assertions to assertionStyle:
never, which the rebase brings onto these added lines. The CLI probe
narrows instead of casting; the relay test keeps the file's serialize
idiom behind a SAFETY-annotated suppression.
2026-09-15 16:01:13 -07:00
Brennan Benson 1457d3966c fix(native-chat): release sessions after provider root exit (#20502)
* fix(native-chat): bound structured chat launch

* Fix post-merge test hygiene

* Make structured fallback settlement exhaustive

* fix(native-chat): release sessions after root exit

* chore(i18n): remove legacy fallback copy

* test(native-chat): remove terminal fallback census

* docs(native-chat): clarify root-exit lease proof

* chore(native-chat): drop unrelated formatting

* fix native chat launch visibility

* test(native-chat): split message rail windowing coverage

* fix(native-chat): keep transport gating render-pure

* fix(native-chat): coordinate launch prompt settlement

* test(native-chat): align unified close ownership

* fix(native-chat): correct lifecycle imports and test typing

* fix(native-chat): fence restored launch cancellations

* fix(native-chat): fence authoritative cancellation snapshots
2026-09-15 15:20:48 -07:00
Brennan Benson 22ca862f76 test(native-chat): widen real-timer waitFor budget in agent-session-wire handoff tests (#20880)
vi.waitFor defaults to a 1000ms/50ms real-clock budget on this suite (no
useFakeTimers), which is occasionally too tight for host.requestHandoff /
handoffStatus to settle under a loaded CI shard. Production behaviour is
unchanged; the assertions are correct, just sometimes slow to observe.

vi.waitFor's own poll loop always runs on the real clock (vitest resolves
its interval/timeout via getSafeTimers, which bypasses vi's faked globals),
so the lease-renewer test carries the same real-wall-clock exposure despite
calling vi.useFakeTimers() for the simulated renewal interval.

5000ms follows existing repo precedent for explicit vi.waitFor timeouts on
real-timer waits (e.g. ssh-relay-session-rejected-delivery.test.ts,
daemon/client.test.ts, pty-subprocess-io-failure-native.test.ts,
windows-msys-job.win32.test.ts), which range 1500-15000ms.
2026-09-15 14:52:12 -07:00
Brennan Benson 60d793956a fix(native-chat): replace the raw question tool row with an awaiting-input row (#20724)
* fix(native-chat): replace the raw question tool row with an awaiting-input row

A question tool call rendered as ordinary tool activity — "Running
AskUserQuestion" with a clipped JSON payload while live, then a "1x
AskUserQuestion {...}" run header once settled — so the one row the reader
actually has to act on read as machine output.

It now draws as "Awaiting user input: <question>", led by a comment-bubble
glyph, with the label pulsing while the answer is outstanding and reading
"Asked: <question>" once it lands. A grouped prompt names how many questions
it asks rather than quoting only the first, since one row stands for the whole
prompt. Question calls also leave the run header, so the count beside them
reports only the work that actually ran.

Codex journals only the question and never a call for it, and a pending
question was dropped from the transcript entirely — its chat log said nothing
while the agent sat blocked on the reader. Pending questions now project the
same row. Claude journals both the call and the question it raised, so the
call itself is suppressed and the one row is fed from one source.

* refactor(native-chat): derive the awaiting-input row from the question item

The first pass fabricated a synthetic `request_user_input` tool call inside the
shared journal projection so that one renderer could serve every lane. That made
a presentation choice on behalf of every consumer of that projection, including
archives and older RPC clients that never asked for it.

Question presentation is now client-local. The shared projection is restored
untouched, and the desktop transcript derives its own rows: a pending question
keeps a stable identity row through tool folding while its receipt draws the
awaiting line, and the duplicate AskUserQuestion call Claude journals beside the
question it raised is suppressed only when a matching question is open in the
same turn — so an unmatched call, or one from a lane that journals no question,
still reports itself.

Question calls now leave the run together with their paired result, which stops a
summarized ask from stranding its answer as an orphan Result row. A failed ask
keeps its error instead of being folded into the awaiting row, and an ask no
longer contends with a concurrently running tool for the active slot: both are
reported.

Adjacent pending questions — the shape Codex journals, one item per question —
group into a single awaiting row that narrows as each one is answered.

Also ships the three awaiting-row strings in the runtime-required English
catalog. Their call-site fallbacks are a shared constant rather than string
literals, so i18next cannot rebuild them from the call site and they have to be
present for the static-analysis gate to pass.

* fix(native-chat): preserve unmatched duplicate question calls

* fix(native-chat): avoid repeated grouped question text

* fix(native-chat): keep pending question text specific

* fix(native-chat): avoid repeating single question answers

* fix(native-chat): narrow question receipt subject

* fix(native-chat): preserve settled ask calls

* fix(native-chat): cover bridge ask rows

* fix(native-chat): fold settled ask receipts

* test(native-chat): cover settled ask receipt folding
2026-09-15 14:03:35 -07:00
OrcaWinandm4air 22857cd8a0 fix(crash-reporting): stop periodic emitters from evicting the crash trail (#20639)
* fix(crash-reporting): stop a once-a-minute sampler from evicting the crash trail

The breadcrumb ring is 30 entries and evicts oldest-first, so any emitter that
repeats outlasts the whole lifecycle trail. Across 293 field reports three
periodic emitters hold 77% of every slot ever shipped and 39% of reports arrive
with no lifecycle crumb at all — the "Recent activity" section cannot say what
the app was doing.

Charge the overflow to the most crowded name instead of the oldest event, so a
series is thinned from its oldest end and singletons survive. No allowlist, so a
new periodic emitter cannot reopen the hole.

* test(crash-reporting): pin coalesced-burst accounting under mid-ring eviction

* fix(crash-reporting): scope eviction per origin and spare live coalescing owners

Round-1 review found two ways the name-only policy was worse than plain FIFO:

- Counting ignored `origin` while the snapshot filters by it, so a busy popout's
  samples made the main window's singleton look redundant and deleted it.
- Names like `renderer_error` carry many independent coalesce keys, so the name
  became "crowded" out of genuinely distinct errors — and the entry taken was the
  oldest, i.e. a key still accumulating `suppressedSinceLast`. A crash report is
  the last snapshot, so an orphaned owner is never re-claimed and the burst count
  simply vanished.

Group by (name, origin), skip an entry a coalesce key still owns unless every
candidate is owned, and never consider the crumb that just arrived — its coalesce
state is linked after the push, so it would always look unowned.

* fix(crash-reporting): trim the report window by the same policy as eviction

Round 2 found the fix defeating itself. Fair-share eviction parks one-off crumbs
at the ring's HEAD and the repeating series at its tail — and the snapshot then
took a plain tail slice of `MAX_BREADCRUMBS - retained.length`, trimming exactly
what eviction had just protected. Measured on the previous commit: one retained
`renderer_memory_highwater` cost one lifecycle crumb, and three erased the
lifecycle trail from the report entirely. That lane fills under the same memory
pressure that produces the `renderer_memory` flood, so the two cancelled out
precisely when the trail matters most.

Trim with `evictionIndex` instead, and route `isCoalescedCrumbStillInEvidence`
through the same window — a predicate that disagrees with the snapshot would drop
an owner's handle and lose the burst count from the crumb the reader sees.

Also strengthens the uncoalesced-burst test, whose only remaining delta against
its coalesced twin was the slot count: it now asserts the pane population is
absent on the uncoalesced side, which is the signal coalescing exists to keep.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-15 16:21:36 -04:00
Brennan BensonandMerge Sim 2eb93206c8 refactor(agent-launch): make the launch-mode decision surface-neutral (#19848)
* refactor(agent-launch): make the launch-mode decision surface-neutral

`decideWorkerStartMode` was the only shared answer to "structured chat session
or terminal agent?", but it lived in an orchestration-named module and spoke
orchestration's vocabulary, so the other launch surfaces could not call it.
Move the decision to `main/agent-launch/agent-launch-mode` unchanged and leave
`orchestration-worker-start-mode` as the adapter that supplies the noun.

A worker is not a special kind of launch; it is the same launch with a dispatch
attached. Naming the receipt's subject is the only thing orchestration actually
contributed, so that is the only thing the adapter keeps: "worker" in both
sentences, plus the `--terminal` wording, which reads as nonsense anywhere a
`--terminal` flag does not exist. Both are pinned, because they are asserted.

No behavior change. The receipts are byte-identical for every reachable case,
proven by running the new pin against both implementations.

Also pins the wording, which nothing was holding. The existing suites assert
`toContain` fragments ('terminal agent', 'cannot create') and the CLI suite
asserts a receipt handed to it by a mock rather than one this code produced;
all six files stayed green against a deliberately corrupted vocabulary. A
dispatch receipt is the only place a structured-to-terminal downgrade explains
itself, so the whole sentence is the contract, not a fragment of it.

* fix(agent-launch): drop the deleted draft-prompt blocker from the reason map

main removed the draft-prompt blocker in #19681 (a structured session now holds
an unsent draft), so the exhaustive Record no longer typechecks.

* chore(agent-launch): carry a SAFETY rationale on the agent placement cast

The type-assertion gate landed after this branch's base, so the new file's
copy of the worker-start cast is now a changed-code finding.

* docs(agent-launch): stop the receipt-wording comment claiming a migration

The decision was never moved out of orchestration-worker-start-mode; this PR
adds a second copy beside it. Say so, and name the unenforced agreement.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-15 13:19:42 -07:00
Jinwoo Hong 62c5037cc3 fix(lint): avoid reflective status entry reads (#20872)
* fix(relay): resolve packaged node-pty from resources

* fix(lint): avoid reflective status entry reads
2026-09-15 15:20:31 -04:00
Brennan Benson 9ab0a18e82 refactor(agent-status): isolate legacy status ingress behind one admission point (#20716)
* refactor(agent-status): isolate legacy status ingress

* fix(agent-hooks): move advertised-capability source onto the ingest envelope

ingestRemote() gained a third positional argument in this PR
(advertisedAgentStatusCapabilities) to satisfy a new ratchet requiring
every legacy-ingress call site to name its capability source. Both
production callers pass the same constant every time, so the argument
carries zero runtime information — but Vitest's toHaveBeenCalledWith
matches argument count exactly, so the pre-existing SSH relay
integration test (which asserts a 2-argument call) started failing
even though nothing about the actual admission decision changed.

Capabilities are a property of the producing peer/connection, not an
orthogonal call parameter, so move the field onto the envelope object
instead of adding a third positional argument: ingestRemote reads
envelope.advertisedAgentStatusCapabilities (defaulting to the
unadvertised-legacy-peer set), and both call sites stamp the constant
onto their envelope literal. Call arity stays at two arguments, so the
pre-existing evidence test needs no change.

The envelope never crosses the wire in either caller: SSH rebuilds it
field-by-field from the RPC params, and the WSL path copies (never
mutates) the wire-deserialized notification before stamping the field
on, so this is purely an internal main-process shape change.

Also strengthens the ingress ratchet test that required this: it
previously only checked that the capability constant's name appeared
somewhere in each caller's source, which a stray unused import could
satisfy. It now asserts the actual
`advertisedAgentStatusCapabilities: AGENT_STATUS_LEGACY_UNADVERTISED_PEER_CAPABILITIES`
key:value binding is present.
2026-09-15 10:26:44 -07:00
Neil caa465d1da fix(automations): stop tick latency counting against the missed-run grace (#20819)
* fix(automations): stop tick latency counting against the missed-run grace

The scheduler compared wall-clock lateness straight against the grace budget,
but evaluation runs on a fixed 60s interval that is never aligned to an
occurrence. With grace 0, any tick arriving after the scheduled instant -- in
practice every tick -- recorded skipped_missed and told the user "Orca was
unavailable during the missed-run grace window" while Orca had been up the
whole time. A zero-grace automation effectively never ran.

Grace is a downtime catch-up budget. An occurrence that came due while the
scheduler was running was never missed; it is waiting for the next tick. The
service now tracks continuous availability and only charges lateness to grace
for occurrences that came due while it was stopped.

Downtime behaviour is unchanged, and the new test asserts that half too.

The missed-run branch moved to dispatch-refusal.ts, which already owns
non-dispatch outcomes, keeping service.ts under max-lines without a disable.

Fixes #11299

* fix(automations): use a tick-latency tolerance instead of process liveness

Review caught two real defects in the first cut:

- availableSince is process liveness, not continuous execution. A suspended
  process (system sleep) keeps its start time, so an occurrence that came due
  during a multi-hour sleep skipped the grace check entirely and replayed on
  wake -- exactly the downtime case grace exists for.
- The restart edge: an occurrence due after the last tick but before stop()
  was reclassified as downtime and skipped with zero grace.

Elapsed lateness cannot be faked by suspension and needs no restart
bookkeeping, so the budget is now grace + two tick intervals. Both edges
disappear rather than being special-cased.

Also fixes a hollow test: workspaceId 'wt1' has no worktree separator, so the
target refused and the run recorded skipped_unavailable -- a 'not
skipped_missed' assertion passed without ever dispatching. Tests now use a
valid id and assert 'dispatching' directly, and cover the sleep, tolerance
boundary and restart cases.

* fix(automations): scope to the verified tolerance and document the stall gap

Review found three defects, all real:

- The 'as never' cast failed the changed-code casting gate. AutomationRendererChannel
  is a Pick<> precisely so a test can pass the real shape; cast removed.
- The restart test never restarted: evaluateAt advanced 60s internally, so the
  first pass already dispatched and the second was a no-op. It now evaluates
  exactly once and asserts no run exists before the second pass.
- tickMs * 2 does not bound a pass that holds the re-entrancy guard across a slow
  serve-mode dispatch.

I tried a busy-window fix for the third and could not test it honestly -- the
case needs a genuinely slow in-pass dispatch, and both attempts passed with the
fix disabled. Rather than ship logic I cannot prove, the tolerance stays at the
verified shape and the gap is documented where the next reader will find it,
with the reason 'time since last pass' is the wrong bound (a suspended process
runs no passes either).

Not a regression: on main that automation never ran at all.

* fix(automations): name the check for what it does and correct its message

Two review points, both fair:

- missedDuringDowntime consulted nothing about availability once the liveness
  flag was removed; it is elapsed lateness against grace plus tolerance.
  Renamed missedBeyondGrace so callers read the real contract.
- The run error still claimed 'Orca was unavailable' -- the same false statement
  #11299 was filed about, now reachable for a genuinely late run rather than a
  merely tick-delayed one. It states what was actually observed instead.

Also documented the deliberate trade CodeRabbit raised: elapsed lateness cannot
tell a short outage from a late tick, so a zero-grace run due during an outage
shorter than the tolerance dispatches instead of skipping. The alternative got
the far worse case wrong -- a multi-hour sleep replayed on wake.
2026-09-15 02:50:00 -07:00
Neil 231e805b1e fix(lint): enable anti-slop/no-shape-in-symbol-names (#20785)
Flip `anti-slop/no-shape-in-symbol-names` from "off" to "error" and clear
every violation under src, config, tests and mobile.

What the rule bans
------------------
The case-insensitive substring "shape" in any JS/TS identifier: variables,
functions, parameters, types, type parameters, class members, private names,
object-literal keys and JSX identifiers. The one exemption is a statically
accessed member read owned by another value (`zodObject.shape` is fine), so
third-party APIs stay readable without a suppression.

"Shape" names a value's structure rather than its domain role. `UserShape`,
`validateArgShape` and `errorShape` all tell you the symbol is "an object
with some fields" -- which is already what a type says -- while saying
nothing about what the value is for or who owns it. The rule forces the
name to carry the domain instead.

Violations fixed
----------------
689 violations across 109 files at baseline (verified by re-running the
audit against the pre-change tree with the rule set to "error").

Fix pattern
-----------
Rename for the domain role, not the structure:

  -type FieldShape = 'list' | 'map' | 'whole'
  -const FIELD_SHAPES = { ... } satisfies Record<keyof Observation, FieldShape>
  +type FieldEncoding = 'list' | 'map' | 'whole'
  +const FIELD_ENCODINGS = { ... } satisfies Record<keyof Observation, FieldEncoding>

  -function assertGitPushTargetShape(target: unknown): void
  +function assertValidGitPushTarget(target: unknown): void

  -function describeReadDirPathShape(p: string): ReadDirPathKind
  +function classifyReadDirPath(p: string): ReadDirPathKind

Predicates became statements about the value (`isDeltaShapedProviderFrameKind`
-> `isDeltaProviderFrameKind`, `isDeleteShapedDiscardEntry` ->
`discardDeletesEntryFile`, `isSkillsCliAgentKeyShaped` ->
`isUsableSkillsCliAgentKey`). Type aliases dropped the suffix where the
remaining name was already unambiguous (`GhGraphqlErrorShape` ->
`GhGraphqlError`).

No wire-visible name was renamed: no IPC or RPC channel, stream opcode,
request/response param, persisted field, or i18n key. The `--shape=symlink|copy`
CLI flag read by .github/workflows/skill-update-roundtrip.yml is unchanged --
only the local variable holding it was renamed.

Exemptions
----------
They are file-scoped entries in config/oxlint-anti-slop.json, not inline
`oxlint-disable` comments. An inline directive naming an anti-slop rule reads
back as an UNUSED directive under the root lint scan, which does not load this
plugin -- the changed-code quality gate counts that warning, so the comment form
cannot be used for a rule that lives only in this config.

* src/renderer/src/components/browser-pane/annotate/**:
  in the screenshot annotator a "shape" is the drawn geometry -- pen, arrow,
  rect, ellipse, highlight. That is a genuine domain noun, and it pervades
  every symbol in the module.
* repo-icon.tsx, repo-header-project-actions.tsx, mobile MobileRepoIcon.tsx:
  lucide exports the icon component as `Shapes`. The name is theirs, and the
  matching REPO_LUCIDE_ICONS key is the persisted icon name shared with the
  desktop picker -- renaming it would orphan saved repo icons.
* src/shared/onboarding-state-types.ts, src/shared/constants.ts:
  `shapedSidebar` is a persisted onboarding-checklist field and a telemetry
  enum member; renaming it would orphan saved state.
* src/shared/rpc-contract/rpc-send-params.ts: matching zod's own literal `shape`
  property is what selects the ZodObject branch of the conditional type.

No exemption was added merely to avoid a rename. Eight symbols initially
suppressed as "a cross-module refactor outside this change" were proven to have
zero non-TypeScript references repo-wide and renamed instead.

Zod's `ZodRawShape` needed no exemption at all: `Readonly<Record<string,
z.ZodType>>` is its definition, so repo-update-params.ts and
ui-update-value-tolerance-params.ts spell it out instead. Likewise
telemetry-event-classification.ts now reads `.shape` through an `in` narrowing,
which also retires two pre-existing type assertions; three more assertions the
rename had dragged onto changed lines (two `JSON.parse` sites, one node:sqlite
row read) became annotations and an explicit row mapping.

Verified
--------
* Audit reports zero violations; confirmed the rule genuinely fires by
  planting a probe violation.
* node config/scripts/run-typecheck-projects-in-parallel.mjs exits 0.
* Vitest over src/shared, src/main/github/project-view, the annotate module,
  the repo-icon components and the Chromium SameSite electron spec: all green.
* All 66 removed "shape" identifiers grepped repo-wide across every file type;
  none survive.
* node config/scripts/generate-rpc-params-catalog.mjs --check exits 0.
* node --check on every changed .mjs; oxfmt clean on all changed files.
* `pnpm run check:code-quality:changed` reports 0 findings.

Not machine-verified: the 3 mobile/ files (its Vitest run cannot resolve
`expo/tsconfig.base.json` in this worktree), and the WSL- and Playwright-gated
specs. All are rename- or comment-only hunks, read in full.
2026-09-15 02:00:27 -07:00
Neil bfdec26352 fix(lint): enable anti-slop/no-object-parameters (#20781)
The rule rejects the broad `object` type on any function input (declarations,
expressions, arrows, methods, call/construct signatures, function types), plus
local aliases and unions that resolve to `object`. `object` accepts every
non-primitive while exposing no properties, so it documents nothing and pushes
callers into assertions at the boundary.

Fixes all 185 violations across src, config, tests and mobile, and flips the
rule from "off" to "error" in config/oxlint-anti-slop.json.

Approach: replace each `object` input with the type its owner already has.
Most sites took an existing domain type or a type-only import (36 added);
40 new aliases name shapes that had none. Where a value is genuinely only
compared by reference, it gets a named identity token instead of a shape --
`Record<string, never>`, the built-in `WeakKey`, or a `unique symbol` brand,
matching the branding already used in src/shared. Same treatment for WeakMap
and Map key parameters. Two `as unknown as` casts became unnecessary once the
parameter carried a real type and were removed; no new casts were added.

Suppressions added: none. No `oxlint-disable` for this rule anywhere, and no
max-lines disable or per-file bump.

Three files sat exactly at their max-lines cap, so the added type imports were
made line-neutral rather than suppressed:
- src/main/ipc/browser.ts exports the existing guest-registration args type
  (renamed BrowserGuestArgs) so browser.test.ts reuses it on one line.
- pane-scroll.ts takes TerminalScrollIntentTarget through the existing
  pane-manager-types import via a type-only re-export.
- direct-rpc-client.ts drops the identity parameter entirely: the session
  check moved into the sendProbe callback that owns the token.

Verified: anti-slop config reports zero violations over src config tests
mobile; run-typecheck-projects-in-parallel exits 0; 144 affected test files
pass (1749 tests); oxlint and oxfmt clean on all changed files. Mobile has no
runnable test/typecheck target in this worktree (expo is not installed), so
its 6 files were typechecked against a standalone config and diffed against
the base branch -- error sets are byte-identical, including test files.
2026-09-15 01:59:58 -07:00
Neil e4a9d24e0c fix(automations): repair cron step expansion and day restriction (#20202)
The semantic half of the cron repair. Both defects change what an already-saved
schedule does, so they ship together and behind a decision.

#15723: parseCronField set end = start for a bare numeric field even with a
slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen
minutes. N/step is the open-ended N-max/step sequence now.

#15896: day restriction came from expanded set cardinality, so 1-31 read as
unrestricted and */2 as restricted. Restriction is lexical now: a day field
restricts iff no term of it ranges over a star, matching vixie cron and
robfig/cron rather than crontab(5)'s prose. Verified differentially against
robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences.

The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser,
104 under #15723 alone, and 730 under both, because the old cardinality flags
react to the corrected expansion.

describeAutomationScheduleDrift reads a saved expression under both semantics
and reports the ones that moved, so neither direction is silent; the service
names them once at startup. No expression Orca's own presets generate drifts.

Fixes #15723
Fixes #15896
2026-09-15 01:28:23 -07:00
Neil c0fb04c8d2 fix(relay): open the real null device when detaching Windows stdio (#20808)
* fix(relay): open the real null device when detaching Windows stdio

`openSync('NUL')` does not reach the null device on Windows. node's fs runs
the path through `toNamespacedPath`, which resolves it against cwd and
prefixes `\\?\` — and that prefix turns off DOS device-name mapping, so
CreateFileW creates a regular file named `NUL` in the relay's install dir
and pins fds 0/1 to it instead of to a discard sink.

Verified on a Windows 11 host: `fs.openSync('NUL', 'w')` + a 5-byte write
produced a 5-byte file named `NUL` in cwd. `\\.\NUL` is passed through
`toNamespacedPath` verbatim; the same write discards and a read answers
EOF, with no file created.

It also escaped into shipped artifacts. release-cut.yml runs the relay
watcher fault harness with cwd = out/relay/win32-x64, so every Windows
installer since v1.4.169 carries `resources/relay/win32-x64/NUL`, which
NSIS extracts as `_NUL`.

* test(relay): prove the `\\?\` rewrite on a drive-letter path

`toNamespacedPath('NUL')` off Windows only resolves against a POSIX cwd and
stops; with no drive letter it never reaches the branch that adds `\\?\`. So
the assertion held for the wrong reason and did not demonstrate the rewrite
the comment describes. Assert it on an absolute drive path, which takes the
same branch on every host.
2026-09-15 01:25:41 -07:00
Neil f107499e44 fix(lint): enable anti-slop/no-reflect-get (#20786)
`anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The
reflective read bypasses ordinary property access and throws away the
type evidence the compiler would otherwise give you: the result is
`any`/`unknown` with no narrowing, so a typo in the key or a shape drift
in the source object is invisible until runtime. The rule's remedy is to
parse dynamic input into a named domain type (or narrow it with `in`)
and then read the field normally.

Baseline: 86 violations across 67 files. Now zero unsuppressed
violations under
`npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`.

Fix pattern
-----------
44 of the 86 were rewritten. The dominant shape was an `unknown` value
read through `Reflect.get` right after a `typeof === 'object'` guard;
those became `in`-narrowed property access, which TypeScript checks:

  - Reflect.get(value, 'agents')
  + 'agents' in value ? value.agents : null

Two further shapes:
- `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a
  small named reader that boxes once and indexes a
  `Record<string, unknown>` (`settingsField` in
  mobile/src/transport/settings-read-operations.ts).
- Tests reaching into private state moved to TypeScript's checked
  bracket-index escape hatch (`runtime['layoutQueues']`), or to a
  documented read-only accessor on the owning class
  (`SearchSubprocessLineAccumulator.retainedCapacityBytes()`,
  `CodexSubagentExecutions.retentionSizes()`).

No type assertion was added anywhere: the diff contains zero net-new
`as` casts, `as any`, `as unknown as`, `@ts-ignore`, or
`@ts-expect-error`, so nothing was laundered into the sibling
assertion rules.

Suppressions
------------
42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38
files. Every one is the default-forward branch of a `Proxy` `get` trap:

    get(target, property, receiver) {
      ...
      return Reflect.get(target, property, receiver)
    }

`Reflect.get(target, property, receiver)` is the only construct that
forwards with correct `receiver` semantics; `target[property]` invokes
an accessor with the wrong `this` and silently breaks getters that read
sibling state. There is no typed alternative, so these are suppressed
rather than rewritten.

3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
-- declaration merging requires interface` in
tests/e2e/github-url-smart-input-transition.spec.ts,
tests/e2e/linear-url-workspace-entry.spec.ts, and
tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing
`Reflect.get(window, 'x')` with typed `window.x` requires a
`declare global { interface Window }` block, and `interface` is
mandatory for declaration merging. Matches the existing convention at
tests/e2e/helpers/runtime-types.ts:63.

1x `// eslint-disable-next-line no-var -- main-process gate handle for
this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for
the same reason a `var` global is needed to type the handle. Matches
tests/e2e/agent-session-log-tail-stability.spec.ts:24.

Also updates two source-text anchors in mobile's rpc-recording mutation
harness (mobile/src/test-support/rpc-recording/operation-mutations.ts
and recording-runner.test.ts), which pin the exact text of the rewritten
line in settings-read-operations.ts and would otherwise fail with
"Mutant anchor matched 0 sites, expected 1".
2026-09-15 01:24:30 -07:00
Neil f7b2736d6d fix(worktree): block removal when the archive hook fails (#20153)
* fix(worktree): block removal when the archive hook fails

A repo's orca.yaml archive hook is the user's last chance to save work off a
checkout Orca is about to delete. A failed hook was logged as advisory and
stepped over, so the removal went ahead with nothing archived — and the caller
could still be told it succeeded.

The hook is now a blocking precondition, evaluated while the checkout, its Git
registration, its agents and Orca's ownership evidence are all still intact: it
sits ahead of the registration re-read, the lock/dirty preflights, stopPtys()
and removeWorktree in every orchestrator that runs it.

Failure is typed (worktree_archive_hook_failed) and carries the worktree path,
outcome, exit code where one was observed, and the hook's output. unverifiable
stays distinct from exited, so loss of contact is never read as a pass. The
waiver rides its own field at every layer and is never implied by --force, which
already carries the PTY-stop waiver; when used, the waived failure comes back on
result.archiveHookOverride rather than being swallowed.

worktree.archive-failure-blocking.v1 is advertised so an integration can tell
"accepts --run-hooks" from "safely propagates a failing hook" without risking the
data loss to find out. The runtime's SSH path cannot run a hook at all, so rather
than delete with the archive step silently skipped it refuses — waivable like
every other refusal here. #18563 retires that gate by making the path run the
hook for real.

Stacked on #20559, which makes a timed-out hook report honestly; without it a
hook that traps SIGTERM and exits 0 would defeat this gate.

Fixes #19334

* fix(worktree): close the skip-confirm dead end and the client/hook timeout gap

Four review findings on the gate.

A retry from the failure toast could fail for a DIFFERENT reason than the one
the user had just answered, and that second failure got a bare toast with no
buttons. With skipDeleteWorktreeConfirm set, the delete helpers pass no force, so
waiving a failed archive hook on a dirty checkout landed on the dirty preflight
and stopped there. Retry failures now re-enter the same failure toast, so every
retry stays as actionable as the first attempt. Third instance of this class.

The renderer gave worktree.rm a 60s budget while an archive hook may run for
120s. A hook that took 90s and succeeded timed the client out and reported
failure while the host went on to delete — telling the user their delete failed
and their checkout was gone. The budget is now derived from the hook's, and only
when a hook can run.

The SSH fail-open is logged rather than silent, and the capability's doc comment
scopes what it claims: a hook that RUNS and fails cannot delete the checkout; it
is not a promise the hook was found.

The SSH owner-resolution test now reads a real remote orca.yaml through a stubbed
provider and asserts the returned script is the remote one. It previously stopped
at the lookup key, which is the coverage that let this path break twice. It fails
against the row-only resolution.

* fix(worktree): name a signalled hook exit, and state why prunable cleanup skips the gate

Two things the rebase onto #20617 and #20576 surfaced, both found by rerunning
the real-repo harness rather than by reading the diff.

- #20617 added a registration-cleanup branch that returns before the archive
  gate. That ordering is correct — both of its arms describe a row with no
  checkout behind it, so there is nothing to archive and running the hook would
  fail on the missing cwd — but the gate's ordering invariant is documented, so
  the exception should be too.
- A signalled hook reported `Command failed with exit code null.`, which reads
  as a reporting glitch rather than the `unverifiable` verdict it is about to
  produce. It now says the command was terminated without reporting an exit
  code. Introduced by #20576; the withheld `exitCode` itself was always right.

Fixes #19334
2026-09-15 01:19:32 -07:00
Jinjing 6fe140ded8 Report clipboard and composer drop failures (#20795)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.

* fix(source-control): surface a failed notes copy instead of swallowing it

* Simplify diff comment notes copy error message

Replace parameterized translation template with a direct string. Add
explicit type annotations in tests to improve type safety.

* Sanitize clipboard write error messages for user display

- Only user-friendly messages for recognized errors
- Native failures logged but not exposed to UI
- Prevents information disclosure (CWE-209)
2026-09-15 00:57:23 -07:00
Jinjing 0569ca4cdc Improve microphone permission errors and drop failure reporting (#20801)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.

* fix(settings): say when the microphone is blocked and where to grant it

* Use generic stream for microphone permission requests

- Request generic audio stream instead of saved device to handle stale
  device IDs (unplugged microphones). This ensures the initial permission
  grant succeeds even if the previously saved device is no longer
  available.
- Refactor error handling to not require instanceof checks, supporting
  errors thrown as plain objects and improving robustness across browsers.
- Simplify tests with proper typing and add coverage for stale device and
  permission error edge cases.

* fix type check

* minor type fix
2026-09-15 00:55:28 -07:00
Neil 22ce8d69a1 fix(lint): enable anti-slop/no-module-mocking (#20783)
The rule rejects `vi.mock` / `vi.doMock` / `vi.unstable_mockModule` and the
`jest` equivalents, on the argument that a test which rewrites the module graph
asserts against a stand-in the production code never sees. It is already off for
`**/*.test.{ts,tsx}`, `**/*.spec.{ts,tsx}`, `tests/**` and `**/__mocks__/**` via
the existing override in config/oxlint-anti-slop.json; that override is
unchanged here. What the rule actually catches is module mocking that has drifted
out of a spec and into a first-party `.ts` support module, where nothing marks it
as test-only.

73 violations at baseline, all of them in test-support code. 9 were relocated
back into spec files the override already exempts; the remaining 64 sit in 10
files that are test-only but do not match the override globs, and carry a
file-level disable naming the rule and the reason.

Relocated:
- terminal-hydration-store-test-bootstrap.ts: the sonner / sync-runtime-graph /
  pty-transport `vi.mock` calls moved into the two specs that import it
  (terminals-hydration-canonical-rows, terminals-hydration-canonical-pty-overlap).
  Vitest hoists `vi.mock` inside a test file, so registration is strictly earlier
  than the previous module-eval-time call; the bootstrap keeps only the preload
  API proxy. Both importers were updated.
- ipc-events-ssh-authority-test-fixtures.ts: the 6 direct-ssh `vi.doMock` calls
  moved into useIpcEvents-agent-status-ssh-authority.test.ts as a local
  `stubDirectSshModules()` helper, which also de-duplicates the three copies the
  spec already had inline. The fixture now returns the store state and coordinator
  doubles it builds, typed via the exported DirectSshReconnectCoordinatorDouble.

Suppressed, with justification (each is `/* oxlint-disable
anti-slop/no-module-mocking -- ... */`, rule named, no blanket disable):
- config/scripts/headless-serve-shutdown-matrix.test.mjs (1) - a genuine Vitest
  spec that the override misses only because its globs say {ts,tsx}. The script
  under test is a top-level CLI module; the alternative is spawning real docker.
- src/main/codex-accounts/runtime-home-service-test-harness.ts (1) - stubs one
  probe predicate in ../pty/shell-startup-env, imported directly by several
  main-process readers; 17 specs share it.
- src/main/computer/desktop-script-provider-test-harness.ts (2) - stubs
  child_process/fs-promises for a provider that shells out; 8 specs share it.
- src/main/github/work-item-search-test-harness.ts (4) - one consumer lives in
  tests/e2e, where the relative mock ids resolve differently, so moving the calls
  into the specs would silently stop mocking there.
- src/renderer/src/components/automations/automations-page-test-harness.tsx (14)
  - the mount rig for 10 AutomationsPage specs.
- src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts
  (1) - stubs refreshWebRuntimeSessionTabsSnapshot, imported directly by several
  renderer runtime modules; 18 specs share it.
- src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts (7) -
  stubReactSyncEffect/stubAuxiliaryModules, shared by 11 specs.
- src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts (11) - stubs
  and hook invocation are one unit; 4 specs share it.
- src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts (13) - its
  only spec is at 799 of an 800 max-lines budget.
- src/renderer/src/hooks/ipc-events-test-harness.ts (10) - shared by 8 specs.

No violation was converted to real dependency injection, and no max-lines disable
was added.

Verified: the audit command exits 0 with no output (and reports errors on a
planted probe, so the rule is live); node config/scripts/run-typecheck-projects-in-parallel.mjs
exits 0; 354 spec files / 2506 tests covering every importer of every touched
file pass. No mobile/ file was touched.

The changed-code quality gate's root Oxlint scan runs without --config so it never
loads the anti-slop JS plugin, which made all 10 of those file-level suppressions
read as "Unused oxlint-disable directive". check-changed-code-quality.mjs now
exempts directives naming an anti-slop rule from that unused-directive warning,
the same carve-out isCastingDirectiveUnusedWarning already makes for the casting
suppressions the casting config enforces. Such a directive can never suppress a
root-config rule, so nothing the root scan would otherwise report is hidden;
audit:anti-slop remains the scan that enforces the rule.
2026-09-15 00:41:17 -07:00
Jinjing 775a932651 fix(git): distinguish binary absence from missing cwd on spawn ENOENT (#20798)
* fix(repos): preserve unknown Git availability

* fix(git): distinguish binary absence from missing cwd on spawn ENOENT

Node reports ENOENT for both a missing git binary and a missing working directory
during spawn. The fix checks specifically for spawn syscall, then verifies the cwd
exists to disambiguate. This prevents reporting "no Git" when the error is actually
a missing working directory. Centralizes probe logic in a reusable function; other
failures cause rejection so callers preserve the unknown status instead of collapsing
to false.
2026-09-15 00:22:15 -07:00
Neil 49e5fa597a refactor(lint): enable anti-slop/no-reflect-apply (#20782)
`anti-slop/no-reflect-apply` rejects `Reflect.apply(fn, thisArg, argsArray)`.
It defeats the call-signature checks TypeScript applies to an ordinary call:
the args array is checked as an array, not positionally against the callee's
parameters, so arity and type errors pass silently. Dynamic dispatch belongs
behind a named interface, not behind a reflective call.

Flipped the rule from "off" to "error" and cleared all 17 baseline violations
across `src config tests mobile` (16 sites; one file had two).

Fix pattern: `Reflect.apply(fn, recv, args)` becomes `fn.call(recv, ...args)`,
or a direct method call when the implicit receiver is already the right object.
The receiver is preserved at every site.

Where the callee is a captured built-in whose overloads split on an argument's
shape (`String.prototype.split`, `JSON.stringify`), a call-signature capture no
longer compiles once the args are passed positionally. Those three sites capture
the function through a method-shaped type
(`{ split(separator: unknown, limit?: number): string[] }['split']`), which keeps
the forwarding call checked rather than asserted.

Behaviour notes:
- `diff-section-layout.test.ts` drops a `limit === undefined ? [sep] : [sep, limit]`
  conditional. Equivalent: `String.prototype.split` maps an undefined limit to
  2^32-1, and the `Symbol.split` path forwards undefined either way.
- `workspace-space-compaction.test.ts` forwards `reduce`'s two arguments unchanged,
  so the `arguments.length >= 2` initial-value branch is unaffected.
- `agent-session-history-byte-accounting.test.ts` is the one site where the receiver
  is not literally preserved (`JSON` -> undefined). `JSON.stringify` never reads
  `this` per spec, and restoring `.call(JSON, ...)` would reintroduce the overload
  failure under strictBindCallApply.

No suppression comments added — the rule has zero `oxlint-disable` sites.

`Reflect.apply` still appears at electron.vite.config.ts:159, inside a template
literal of generated bootstrap source. That is string content, not lintable code.
2026-09-15 00:10:11 -07:00
Neil c9ae17fe3d fix(lint): enable anti-slop/no-unknown-type-aliases (#20784)
Flips anti-slop/no-unknown-type-aliases from "off" to "error" and fixes the
3 baseline violations.

The rule rejects a named type alias whose resolved type is `unknown` (directly,
through another alias, through parentheses, or as a member of a union). Such an
alias is strictly worse than writing `unknown`: it reads like a real domain type
at every use site while accepting anything, so the compiler stops helping and
readers are actively misled. `unknown` is fine, but it must stay visible at the
boundary that actually parses it.

Violations fixed (3 at baseline, 5 source files touched):

- src/main/runtime/workspace-session-failed-write-rollback.ts
  `type RollbackValue = unknown` -> a real recursive JSON-shaped union
  `RollbackSlot` (primitives | null | undefined | typeof MISSING |
  readonly RollbackSlot[] | RollbackRecord), with a named
  `type RollbackRecord = { readonly [key: string]: RollbackSlot }`.
  The record is a named alias rather than an inline index signature because
  inline violates typescript/consistent-indexed-object-style, `interface`
  violates consistent-type-definitions, and `Readonly<Record<..>>` trips
  TS2456 circular-reference. The named alias satisfies all three.

- src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts
  `type DirectSshReconnectTimer = unknown` -> `ReturnType<typeof setTimeout>`,
  the handle that actually flows. `DirectSshReconnectTargetState.timer` is
  widened to `DirectSshReconnectTimer | null` to match the state machine, which
  initializes to null and resets to null in the scheduled callback.

- src/renderer/src/hooks/direct-ssh-host-hydration.ts
  `type HostReadTimer = unknown` -> `ReturnType<typeof setTimeout>`.

Fix pattern throughout: replace the alias with the type that already flows
through the code, never with `any` and never with a relabelled `unknown`.
Because the timer aliases are now honest, two pre-existing
`as ReturnType<typeof setTimeout>` casts at the clearTimeout boundaries could be
deleted, a net win under the repo's type-assertion policy.

Suppressions added: none. No eslint-disable, oxlint-disable, `any`, or `as`
cast was introduced anywhere in this change.

The diff is type-annotation-only; no runtime statement changed.
2026-09-15 00:02:04 -07:00
Jinjing 3ec6193e0f fix(pty): preserve child-process inspection uncertainty (#20756)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.

* fix(pty): handle retired masters and pane instance swaps

Detect when node-pty retires the master fd (fd == -1) and return
unverifiable instead of misreading the spawn file as an idle shell.
Guard inspectProcess against PTY replacement mid-read to avoid pairing
old foreground with replacement's children.

* fix test

* fix tests
2026-09-14 23:53:05 -07:00
Brennan Benson ab6b86dd5c fix(orchestration): require registered structured worker pane key (#20664) 2026-09-14 23:00:35 -07:00
Neil 4a5b0583b2 fix(runtime): keep listed handles when graph sync learns a PTY incarnation (#20779)
reconcilePtyIncarnationHandles compared a null retained incarnation against the learned one and staled the handle. Daemon-hosted PTYs are recorded from first output before the spawn commit reports an incarnation, so on Windows `orca terminal create` returned a handle that was stale by the next graph publish. Treat null-to-known as un-fenced like every other site; keep the known-to-different and preallocated-handle invalidations.
2026-09-14 22:43:38 -07:00
Neil ef39f32d4f test(native-chat): split the windowing test harness out of the suite (#20773)
#20719 grew NativeChatMessageList.windowing.test.tsx to 897 effective lines,
past the 800 ceiling for test files, so oxlint fails on main.

Moves the shared layout/ResizeObserver stubs into
native-chat-windowing-test-harness.tsx. No test was changed, split or dropped:
still 5 describes and 23 it() blocks, 29 assertions passing. The stubs' mutable
knobs become one exported `layout` object because an imported binding cannot be
reassigned across modules.

AGENTS.md forbids a max-lines disable, so extraction is the fix.
2026-09-14 21:35:48 -07:00
Jinjing 99062ed80b fix(worktrees): preserve unverifiable disk witness (#20713)
* fix(worktrees): preserve unverifiable disk witness

* fix(worktrees): follow gitdir/commondir markers in disk witness

The disk witness validates created worktrees by reading the repo's common directory from disk. Previously it only checked for a direct .git directory and returned a status object that conflated different failure modes.

Now it properly follows .gitdir and commondir pointer files to locate the true common directory, fixing detection on repos with linked git directories (worktrees, submodules) and WSL scenarios. Error handling is simplified: definitive absence returns undefined, other read failures throw with proper cause chains, eliminating the ambiguous "unverifiable" state that would mask real errors.

* fix: validate gitdir marker targets are directories

When a .git marker points to a missing or non-directory path, that's
unverifiable—not the same as an absent .git file (bare repo). Validate
accessibility before reading commondir to catch these errors clearly.
2026-09-14 21:03:51 -07:00
Brennan Benson 438603f9e7 feat(native-chat): add a message rail for jumping between your prompts (#20719)
* feat(native-chat): add a message rail for jumping between your prompts

A vertical rail down the right edge of the transcript, one bar per user
message, with the bar for the turn you are reading highlighted once
scrolling settles. Hovering the rail opens a panel that previews every
prompt and jumps to it on click.

Bars are capped at 20 and sampled evenly across the thread, always
keeping both ends and the active bar, so the rail stays readable at a
glance on a long conversation.

The active bar is resolved from virtualizer offsets rather than by
scanning rendered rows: the transcript is windowed, so an off-window row
has no element to measure. The row at the scroll fold resolves to its
owning prompt through turnKey, which is what keeps your own message lit
while you read a long reply instead of going dark.

Jumps reuse the existing reveal/pin path and scrollMessageToTop, which
releases the bottom pin. Scrolling through the virtualizer directly would
leave a reader snapped back down by the next streamed token.

Ticks cover loaded history only; older prompts gain a bar once "Load
earlier messages" pages them in.

* fix(native-chat): service a rail jump once and give its pin back

The rail borrowed the diff reveal's pin to reach a row the window had left
behind, but copied only its state shape, not its consumption. The request
was never cleared and the effect depended on `slots`, which is rebuilt on
every render, so three things went wrong at once:

- every later render re-scrolled to the jumped message, dragging a reader
  back there for the rest of the pane's life, and forcing the bottom pin
  off each time;
- the standing request outranked `revealedDiff` in the shared pin, so
  revealing a diff outside the window silently stopped mounting its row;
- the pinned row stayed mounted and measured indefinitely.

The request now carries a monotonic id, is serviced once, and is released
as soon as the scroll is issued, which hands the pin back.

The rail's scroll listener had the same churn: it listed `items` in its
deps, so a streaming turn tore the listener down and cancelled the pending
idle timer on every frame and the highlight never settled. It now
subscribes once and re-reads on a key built from the prompt ids.

Also: the hover trigger is a real button, because `asChild` discards the
primitive's focusable trigger and the panel is the only way to reach these
messages; the wheel forwarder honours line and page delta modes rather
than treating every delta as pixels; and the e2e panel assertion is exact,
since a loose bound passed at 20 rows against 20 ticks.

* fix(native-chat): make prompt rail accessible and reuse previews

* fix(native-chat): supersede prior navigation when selecting a prompt
2026-09-14 18:31:40 -07:00
Brennan Benson ff5b1a5a05 fix(native-chat): preserve detached transcript position during growth (#20710)
* fix(native-chat): stop the transcript following an end it measured short

The virtualizer compensates a row's measured size change by moving scrollTop
whenever it believes the view was already at the end. It decides that from the
spacer's own height minus a container-absolute offset, so the distance it
computes is short by everything in the document outside the spacer: the
transcript's top gutter, the "load earlier" block while older history is still
pageable, and the trailing chrome. A reader sitting ~100px above the bottom
therefore measured as "at the end", and every row that settled below them
dragged them down to it.

Measured in the windowing harness with a 92px gutter and 24px of trailing
chrome: a reader parked 96px above the end is pulled to the end on the first
growth frame, scrollTop 9261 to 9357.

The same option gates following an append, but that path measures the true
document distance, so it was never wrong, only redundant. The transcript
already decides whether to follow the end from the scroll container's real
geometry, and it re-pins once the growth is in the document rather than before
it, where the library's own write is clamped. Both library end behaviours are
retired by a threshold no finite distance can meet; the prepend anchoring that
shares the option is kept.

overflow-anchor:none is restated as structural: the engine's anchoring writes
never pass through the scrollToFn adapter that attributes this pane's own
scrolls, so they would arrive unmarked and read as the reader leaving.

* fix(native-chat): preserve visible rows on first measurement
2026-09-14 18:24:04 -07:00
Neil b61a2347b9 feat(design-system): gate renderer UI with @shadcn/lint (#20731)
* feat(design-system): gate renderer UI with @shadcn/lint

Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already
ratchets: the changed-lines PR gate for rules the renderer can't satisfy
today, and `pnpm lint` for the one that is already at zero.

- config/oxlint-design-system.json: no-restyle (layout allowed),
  no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx,
  run over added lines only. Measured at 10 findings across the last 60
  commits (771 changed files), so it holds the line without a migration.
- config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the
  renderer's plain-CSS hook namespaces allow-listed. Now at zero.
- no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why.

Fixes the three live bugs the linter found:

- `--editor-surface` never reached `@theme inline`, so `bg-editor-surface`
  generated no CSS -- 12 editor/artifact/notebook panes fell through to the
  page background instead of #1e1e1e in dark mode.
- `scrollbar-none` is not a Tailwind utility and was declared nowhere, so
  the remote file browser breadcrumbs showed the scrollbar they meant to
  hide. Declared as a real `@utility`.
- Notebook markdown cells used `markdown-preview-body`, which no stylesheet
  defines; the styled class is `markdown-body`. They rendered unstyled.

* ci: run the dead-class gate in PR CI

`pnpm lint` gained check:dead-classes, and pr-workflow-lint-parity requires
every `pnpm lint` step to have a matching step in pr.yml.

* fix(notebook): keep markdown theme selectors working
2026-09-14 17:52:21 -07:00
Brennan Benson db09a7bd50 fix(native-chat): let a reader park just above the latest message (#20709)
* fix(native-chat): let a reader park just above the latest message

A reader who scrolled up by less than the bottom threshold was still
classified as being at the end, so follow stayed armed and the next chunk
of stream carried them back down. One constant was answering two
different questions: how close to the end still counts as pinned, and
whether a reader's own scroll meant to stay there.

The first wants slack, because a streaming last message jitters in height
by tens of pixels. The second wants almost none, because it is a
statement of intent. Give it its own, far stricter band, and move the
choice of band into the decision rather than leaving it to the call site,
which is where the two got conflated.

Re-arming follow now requires the reader to be within 4px of the end:
enough for fractional-pixel and zoom rounding, well inside one line of
prose. The pin and the jump-to-latest affordance keep their 48px band.

* fix(native-chat): make transcript intent own end following
2026-09-14 17:47:19 -07:00
Jinjing ffc331212c Fix PTY child process verdict to preserve unverifiable state (#20729)
* fix(pty): preserve unverifiable local child reads

* fix(pty): make child-process inspection synchronous

Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.
2026-09-14 17:13:25 -07:00
Jinjing 4bcdc67369 Distinguish pane load failures from empty states (#20735)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(settings): tell a failed load apart from a genuinely empty pane

* refactor: consolidate import types and simplify failure handling

- Move filesystem import types to shared for renderer use
- Add compactIpcErrorMessage for single-line error display
- Consolidate entry failure toasts to single global slot
- Simplify account tracking and discard retry logic

* fix type

* fix: clear stale state when pane loads fail

Credential reads, account fetches, and skill scans can fail, leaving stale
data on screen. This change clears previous state when a load fails,
distinguishing load failures from genuinely empty results, and prevents
stale controls from appearing after failed re-checks.

Use readIpcErrorMessage for consistent error handling and track runtime
targets to invalidate results from old targets.

* fix(settings): show credential action when bitbucket status read fails

When the credential-read operation fails, allow users to retry by showing
"Add or replace credentials" button. Initialize the credentials dialog with
the current (confirmed) connection state instead of stale data from a failed
read, preventing outdated information from pre-populating the form.
2026-09-14 16:44:30 -07:00
6cb5643241 fix(deps): migrate Tiptap security updates with Markdown compatibility guards (#19376)
* chore(deps): evaluate coordinated Tiptap security migration

* fix(editor): adapt link ranking and initialization for Tiptap 3.31

* fix(editor): preserve literal Markdown through Tiptap serialization

* test(editor): cover literal saves in local folder and paired workspaces

* test(editor): reselect folder after closing its final tab

* perf(editor): avoid repeated inline source-marker lookahead scans

* refactor(editor): inline redundant HTML match wrapper

* test(chat): await Tiptap React skill-pill rendering

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-14 16:36:06 -07:00
Jinjing b8554f1c59 fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
2026-09-14 15:22:05 -07:00
Neil 767b7c14f1 fix(ai-vault): expand nested OMP session history (#20663)
Expand saved OMP descendants lazily while preserving exact child targets for Resume and View Log. Retain expanded branches across virtual scrolling and reject late responses/cycles. Includes the independently reviewed child-workspace correction from #20629.

61 combined target/map/nesting tests and actual OMP child/grandchild storage/CLI smoke pass. Earlier hidden Electron proof covers eight generations and narrow sidebar layout. Folder-only unresolved child targets remain disabled. No live delegation or full terminal-launch proof claimed.

Addresses #12885 Scope 2.
2026-09-14 15:17:36 -07:00
Neil 742a7ad842 fix(omp): resume independent child sessions from history (#20629)
Add Resume to eligible local OMP child history rows. Resolve lazy child targets from their own cwd and host, never an unrelated active workspace. Unresolved folder-only targets stay disabled; copy-command remains available.

Verified production map/resume resolver regression before/after; 50 focused tests and independent 40-test review, web types and code quality passed. Actual OMP storage/CLI smoke confirms distinct child/grandchild sessions. No native Windows or live SSH launch claim.

Addresses #12885 Scope 1.
2026-09-14 15:05:36 -07:00
Brennan BensonandMerge Sim f55b7ba680 fix(native-chat): cancel pending prompts precisely (#20601)
* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

* fix(native-chat): cancel pending prompts precisely

* fix(native-chat): consolidate capability imports

* fix(native-chat): harden precise prompt cancellation

* fix claude cancellation teardown races

* retry claude prompt lifecycle admission

* bound claude prompt cancellation retry work

* fix(codex): bound prompt turn identity on registration

* fix(native-chat): route rejected late dispatch settlements

* fix(codex): retain exact cancellable prompt turn ids

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-14 14:59:03 -07:00
Neil dd85e5fc81 fix: keep OMP terminals when folder workspaces become Git repos (#20653)
Preserve the original folder locator through Git upgrade and subsequent listing, persistence, and removal decisions after proving it still names the same checkout.

Independently reviewed with 60 focused persistence/listing/removal tests and six native Windows real-Git/NTFS cases covering case/slashes, junction retention and retargeting, remote-host isolation and unrelated checkout preservation. Prior source-connected native OMP proof confirms process survival. Full PR CI passed; no rebuilt full-app after-proof claimed.
2026-09-14 14:54:53 -07:00
Neil 41e42beab4 fix(worktrees): safely remove prunable git-file registrations (#20617)
Preserve checkout files and the named branch when removing a positively attested malformed Git-file registration. Reject file/symlink targets in deferred directory deletion.

Verified exact head with 75 focused tests including actual Git malformation, preserved marker/file bytes and branch HEAD. Independent review and complete product CI passed. WSL routing is covered by unit tests; direct SSH fails safely without local recovery.

Fixes #17316
2026-09-14 14:52:45 -07:00
Neil bac96b212e fix(hooks): actually terminate a timed-out hook's process tree (#20576)
Repairs #20559, whose termination was a no-op: `detached` is a spawn-only option and `exec` ignored it, so the shell never became a group leader. Verified against real processes.

Refs #19334
2026-09-14 14:52:35 -07:00