Review proved a coverage gap by mutation: removing LIVENESS_PROBE_TIMEOUT_MS
from the getSize request left the whole suite green, so nothing stopped that leg
regressing to the client's 30s default. Only the listSessions leg was pinned.
The assertion now fails under exactly that mutation.
Also moves isUnknownRequestTypeError below isDaemonGoneError. It had been
inserted between isDaemonGoneError and the comment describing it, so that
comment read as documenting the wrong function.
probePtyLiveness passed no timeout, so each request fell back to the client's
30s REQUEST_TIMEOUT_MS. A wedged daemon holds its socket open rather than
refusing, so it answers neither quickly nor at all — and probePtyOwners awaits
every adapter before it can conclude anything. One hung daemon therefore stalled
each restoring pane for up to 30s before the pane reported that it could not
reopen.
Nearly all local terminals are daemon-backed, so this is the common
configuration, not an edge case.
Bound both probe requests to 2s. Answering "unknown" quickly is strictly better
here than answering slowly: unknown never authorizes retirement, it only defers
it, so the only thing a shorter budget can cost is an earlier retry. The tests
now pin the bound at the call rather than leaving it implicit.
Review found the guard had no positive-direction coverage: every existing retire
test uses a provider with no probePtyLiveness, so the guard is skipped there.
Mutating `!== false` into `!== undefined` — which turns the guard into a
permanent veto, so no daemon-backed pane could ever recover from a genuinely
dead owner — left the whole suite green. Pinned with a case that fails under
exactly that mutation.
Also corrects the fallback's Why-comment. It claimed listProcesses "swallows
errors into an empty list"; it does not — DaemonPtyAdapter.listProcesses
rethrows and the router fails closed. The error-swallowing lives in
discoverLegacySessions / discoverDegradedDaemonSessions. Requesting listSessions
directly is still right, but because a liveness probe should not publish
inventory audit observations as a side effect — not for the stated reason.
The version gate was not a sound boundary. `getSize` landed in 22b00a7cd2 while
PROTOCOL_VERSION was already 18 and that commit did not bump it, so version 18
means two different things: daemons built before it lack the request, daemons
built after it have it. Both report 18.
A daemon from that window therefore passed the `>= 18` gate, was sent `getSize`,
error-replied "Unknown request type", and answered `null` — and one `null` makes
the owner fan-out permanently unprovable, so a genuinely dead pane could never
retire. That is the same wedge the gate was added to close, just narrowed to one
build window.
Ask the daemon what it supports rather than trusting its number: attempt
`getSize`, and fall back to `listSessions` only when the daemon itself replies
that the request type is unknown. The predicate is deliberately narrow so a
transient failure stays unproven instead of being mistaken for a missing
capability, and the rejection is remembered so later probes skip a round trip
that cannot work. This mirrors the capability-probe rule AGENTS.md already
requires for Git, for the same reason: version numbers overstate capabilities.
Unreachable still never means absent.
Reverts aa27eb5274 and leaves only the original guard: retire a stable pane's
owner solely when a provider authoritatively answers absent
(probePtyLiveness === false). `true` and `null` still refuse to authorize
destruction, and providers without a probe keep their refusal authoritative, so
their behavior is unchanged.
The follow-up bought too much surface for a P0, and two of its pieces did not
hold up:
- The `hasPty` owner scan added to DaemonPtyRouter.spawn and the degraded
fresh-spawn router is inert for the case it targeted. `hasPty` reads
`activeSessionIds`, which only spawn/attach populate — `listProcesses`
(daemon-pty-adapter.ts) never does. After a failed discovery the scan
therefore cannot find the owner it was written to find.
- Its two routing tests could not fail: the mocks backed `hasPty` and
`listProcesses` with one shared array, giving the fake adapter knowledge the
real DaemonPtyAdapter provably lacks.
Also dropped: the bounded 6s retry loop and its constants, the sentinel symbol,
the exported user-facing message, the `opts.deadlineMs` widening of
probePtyLiveness and its threading through the probe/routers, and the
ensureConnected change in DaemonPtyAdapter.
Kept from the follow-up: the flushOrThrow revert. flushPendingOrThrowAsync is
not a like-for-like twin (drain-to-stable-generation loop, sidecar writes,
per-iteration serialize, rejects when writes are frozen), so
retirePersistedStablePaneOwner stays synchronous.
One gap the strict one-shot gate does open is closed here. `getSize` landed in
daemon protocol v18, and legacy adapters are created for every version in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS (1..30). A pre-v18 daemon error-replies
"Unknown request type", probePtyLiveness catches it as `null`, and one `null`
makes probePtyOwners unprovable forever — so a genuinely dead pane could never
retire. probePtyLiveness now version-gates getSize and asks such a daemon for
`listSessions` instead, the same inventory legacy discovery already routes by,
requested directly rather than through listProcesses (which swallows errors into
an empty list) so an unreachable socket still answers `null`. Unreachable never
means absent; only an owner that answered does.
Review found the proven-absence gate correct in direction but terminal in
effect: "probe says alive" and "probe says unknown" both became a hard spawn
failure with no consumer, no retry, and a raw internal token in a toast.
- Graceful teardown (should-fix): a session being killed reports alive
(getAppliedSize answers while isAlive, and isTerminating does not flip
_state) while createOrAttach already throws SessionNotFoundError, so for up
to KILL_TIMEOUT_MS the pane failed instead of retiring the doomed binding.
The veto now re-proves on a bounded 6s budget: the moment any owner answers
"absent" the pane retires and spawns fresh exactly as before.
- Unprovable owners (should-fix): DaemonPtyAdapter.probePtyLiveness never
called ensureConnected, so a merely disconnected adapter answered null
forever and one null poisons probePtyOwners; it also took no deadline, so a
wedged daemon burned the client's 30s request timeout per attach. It now
connects first (like listProcesses) and threads an absolute deadline through
the fan-out. The thrown error is user-legible English instead of
terminal_pane_owner_unverified.
- Durability barrier (should-fix): flushPendingOrThrowAsync is not a twin of
flushOrThrow — it defaults to the drain-to-stable-generation loop the
sibling best-effort caller deliberately avoids, adds active-view and GitHub
sidecar writes, re-serializes state per iteration, and rejects when writes
are frozen where the sync path no-ops. Reverted to flushOrThrow, matching
the sibling retire paths in orca-runtime.
- Misrouting root cause (nit): DegradedDaemonFreshSpawnRouter.spawn sent an
unmapped sessionId to the local fallback and DaemonPtyRouter.spawn sent it to
the current daemon, both faking "Session not found" for a live session. Both
now resolve an existing owner first (the same resolution every non-spawn path
already uses); minted ids still route to the fallback/current daemon. The
pane reattaches instead of only surviving.
Tests: retry-until-proven, teardown-window retire, misroute-then-reattach,
probe deadline threading, unconnected-adapter probe, and both routers' owner
resolution. Each fails with only the source reverted.
"Session not found" only proves the provider we asked has no such PTY. A
degraded router answers unmapped session ids from the local fallback, which
never owned a daemon session, so a live agent's PTY produced the same error a
dead one does — and the pane was then given a synthetic exit, its durable
pane->PTY binding deleted, and a duplicate spawned. For a single-pane tab the
retirement also drops the tab from persisted state, which the renderer cannot
add back.
Gate the retirement on probePtyLiveness, which polls every possible owner and
answers false only when all of them agree the session is absent. `null` (nobody
could answer) is not absence. Providers without a probe are their own sole
owner, so their refusal stays authoritative and their behavior is unchanged.
Also move the retirement's durability barrier off writeToDiskSync: it fsyncs
the whole multi-MB state from the main thread, and a restore that retired N
dead panes paid that stall N times.
* fix(ui): always show Onboarding in the Help menu
Users could not find "Show onboarding again" because it required holding Alt. Surface it on the default Help menu so replay is discoverable without reinstalling.
* fix(ui): always show Restart Orca in the Help menu
Surface Restart next to Check for Updates by default and drop the Alt-only admin gate, which no longer hid anything else.
The per-worktree rebuild memo from #12207 refused to skip any worktree
with a registered TerminalPane, because builders read live PaneManager/DOM
state the memo could not witness. Hidden worktrees stay mounted (8 parked
+ 12 retained by default, unbounded with terminalHiddenViewParking off),
so that set rebuilt on every publication — on the always-on hosts the memo
targeted, ~20 worktrees paid the full content build plus the new inputs
build and deep compare every tick.
Capture the live reads instead: snapshot pane leaf ids, the live active
leaf, the serialized pane tree, and per-pane numeric/pty bindings once per
publication into MountedTerminalSurfaceCapture, compare captures by value
in the memo, and have the builders read only the capture. Live state
outside the capture is now unrepresentable in a builder, so the skip stays
provably complete — and a mounted worktree whose panes did not change
reuses its snapshot like any other.
The sync before-unload checkpoint staged renderer state and then queued
store.flushPendingAsync() fire-and-forget, so reload/restart/update paths
navigated while the staged session, scrollback and UI state were still
only in memory. Quit is covered by the will-quit flush barrier; those
paths were not.
Keep staging synchronous (no sync durable writes), but record the flush
outcome and expose it on app:await-before-unload-checkpoint. Restart,
updater install and lazy-chunk recovery reload now join that write before
navigating and abort the attempt when it fails or outlives a 20s deadline.
Destructive worktree removal swept PTYs by worktree id alone. Worktree ids are
`repoId::path` and the store keeps one per host, so deleting an SSH worktree
could stop a same-id local (or other-connection) workspace's terminals — or fail
outright with `selector_ambiguous` when two hosts owned the id.
Every destructive teardown now names its owner (resolvedWorktreeId plus the
connection/runtime environment), matching the already-hardened forget-local path:
- IPC `worktrees:remove` (git + folder workspaces)
- runtime `removeManagedWorktree` (CLI/mobile `worktree.rm`, git + folder)
- missing-worktree terminal reconciliation, including its no-provider fallback
The #11960 allowUnverifiedStop force-delete gate is untouched.
* perf(agent-status): coalesce live status bursts into one render pass (STA-3328)
Each live agent-status event arrives as its own IPC task, so a multi-agent
burst paid one full React render pass per event (200-488ms main-thread tasks
under heavy orchestration). Same-task store commits batch to a single render
pass, so buffer a burst for one 33ms window and apply it in one task. The
leading event still applies synchronously (zero added latency for single
events), only an applied event arms the window (dropped/pending events stay
immediate), and both clear paths purge queued sets so a coalesced event
cannot resurrect a removed status.
* fix(agent-status): preserve queued completion on teardown
Two robustness follow-ups from the #12194 safety-review loop (pass 3),
staged by the reviewer before its session ended; both fail toward extra
retention only:
- recency bookkeeping now runs while the retention kill switch is off, so
re-enabling evicts in true LRU order instead of worktree-list order
- interrupted downloads release the eviction veto instead of pinning the
worktree as downloading forever
The adhoc workflow checked out any requested ref and ran its scripts and
electron-builder config with MAC_CERTS, the notary password, and the adhoc
publisher token in reach — including refs/pull/* fork code a maintainer
could dispatch in one innocuous-looking click.
Vet the ref before checkout: PR refs are refused, branches/tags resolve in
a bare tree:0 scratch fetch, raw SHAs must be reachable from a repo branch
or tag (a partial clone lazily serves PR-only commits by SHA, so name
resolution alone is not a trust test), and checkout pins the vetted SHA so
a race push cannot swap the commit. Also reference an adhoc-mac-build
environment so the secrets can later be fenced off from stale workflow
copies via repo settings.
* perf(runtime): withhold unchanged mobile snapshots from the graph payload
Every graph sync structured-cloned all 222 worktree snapshots to main even when
none had changed: 374 KB and ~5 ms per clone, paid twice because Electron clones
on serialize and again on deserialize. That transport cost — not the renderer
rebuild — is the bulk of a publication.
The renderer now sends only the snapshots main has not acknowledged and names
the rest in unchangedMobileSessionWorktrees. Detection is object identity, not a
deep compare: an unchanged worktree already returns its cached snapshot object.
Main seeds nextWorktrees from that list so its prune keeps withheld worktrees
live instead of removing them.
The call itself is unconditional. syncWindowGraph is not a one-way publish — its
return value is the only channel carrying agentOrchestrationByPaneKey to the
renderer, and the handler adopts pre-allocated handles, merges detached leaves,
refreshes writable flags, and drains graph-sync callbacks on every sync. Skipping
it would starve all of that.
Two failure modes are closed explicitly. The memo advances only after main
acknowledges, so a publication that throws is resent in full rather than
silently withheld forever. And a worktree main dropped on its own — worktree
metadata removal — comes back in mobileSessionResyncWorktrees, which also clears
the accepted-revision record so the republish is not rejected as a no-op.
Unchanged republish at 222 worktrees / 787 tabs: 374 KB to 3.4 KB, 5.08 ms to
0.02 ms per clone. One changed worktree: 5.3 KB.
* fix(runtime): resync stale withheld mobile snapshots
* fix(runtime): align accepted mobile snapshot membership
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
main's version only moves on `release:` commits, and stable patches are cut
from release branches that never merge back. On 2026-08-03 main read
1.4.165-rc.0 for twenty hours while 1.4.165, 1.4.166 and 1.4.167 all shipped,
so every hourly built in that window was stamped 1.4.165-hourly.* while
carrying code newer than 1.4.167 — and sorted below the stable its user was
already running.
Resolve the base from the main repo's published tags instead, taking the patch
above the highest shipped stable. package.json stays a floor for the case where
main leads the tags.
Co-authored-by: Orca <help@stably.ai>
gh repo create leaves the repo empty, and publishing a release creates a
git tag that needs a commit to point at. The adhoc build got all the way
through notarization and upload before failing on "Repository is empty".
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): bound terminal viewport resubscribe loop with backoff (STA-3337)
An empty scrollback frame with absent host dims was coerced to 80x24, which
never equals a phone viewport, arming a zero-delay unsubscribe/resubscribe
loop (~25/s) that broke long-press gestures and drained battery.
- Absent host dims now hold the stream instead of resubscribing.
- Fit resubscribes are budgeted per handle (3 attempts, escalating backoff)
with an absence-gated refill mirroring the chat-side rearm bound; on
exhaustion the view degrades visibly via toast instead of hot-looping.
- A fresh post-measure match counts as convergence instead of resubscribing.
- setTerminalModes keeps the Map identity when the mode is unchanged, so
same-mode frames no longer re-render the session route.
- Host emits the subscriber viewport as scrollback dims when the snapshot
and PTY size are both unavailable, so current hosts converge immediately.
* fix(mobile): cancel stale viewport retries after convergence
* feat(ssh): add SSH config host picker for add-host form
Users can now click 'Fill from ~/.ssh/config…' to browse available SSH
config hosts in a picker, select one, and have the form automatically
prefill with resolved connection details (hostname, port, username, auth).
Previously, an 'import' button provided bulk sync on this form—confusing
and unhelpful when everything was already synced. That action is now
available as a secondary 'Add all' option in the picker.
* fix(ssh): import filter preservation and label fallback
- Reuse search loader on import completion to preserve active filter inside generation guard
- Fall back to hostname when manual host has no label, not empty string
- Make alias duplicate detection case-insensitive to match config picker behavior
- Validate host availability when restoring project group selection
- Add aria-selected attribute to picker options for accessibility
* fix(ssh): harden config picker import, alias folding, and host targeting
Review findings on the ~/.ssh/config picker + bulk add:
- Guard config-host resolution with a generation counter so a late resolve
cannot overwrite a later pick or a form the user backed out of; freeze the
other rows while a pick resolves.
- Stop "Add all N" from re-adopting deleted hosts — it now imports without
reAdopt, matching the new-host count it advertises. Settings → Import keeps
the explicit re-adopt path.
- Fold SSH aliases through a shared normalizeSshConfigAlias for import
ownership, delete tombstones, reclaim, picker search, and the save-time
duplicate check, which now occupies configHost *and* label like the picker.
- Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not
when `ssh -G` merely echoes the /etc/ssh system default.
- Fail closed with unavailable/setup-not-found when an explicit
projectHostSetupId names a non-actionable host instead of silently creating
the workspace on a sibling host.
- Cache the parsed config for the picker session (refresh on open/retry) so
filter keystrokes no longer reparse and Include-expand the file, keep the
filter usable during loads, add a Retry on load errors, explain an empty
Identity file after a config fill, and drop the always-false aria-selected.
* refactor(ssh): centralize host result limit and extract folder group val
Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message
cannot drift from the host's query limit. Extract findActionableFolderProjectGroup
to avoid repeating the folder-host-availability check across the composer hook.
* fix(ssh): pass -F to ssh -G when HOME differs from passwd home
In E2E tests and sandboxes, isolated HOME can differ from the system
passwd home. OpenSSH resolves the default config via getpwuid (passwd),
while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F
to explicitly specify the config path when they diverge, so ssh -G and
the picker resolve the same file.
* fix(ssh): verify config host exists before resolving with ssh -G
When a user edits ~/.ssh/config and removes a host, the import picker
should not fall back to ssh -G's echoed response (which treats any alias
as valid). Check the reloaded config file before resolving.
- Force reload config on each resolve to catch user edits post-open
- Reject aliases not in the current config before calling ssh -G
- Add test for deleted alias edge case
- Fix workspace-target fallback to honor explicit host selection
* fix(ssh): let tombstoned aliases be re-picked in the config picker
Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
* fix(agent-status): restore hydrated nonterminal statuses as unconfirmed
A hook transition that fires while Electron is down has no receiver and is
discarded, so last-status.json can restore a stale 'working' as confirmed
truth for up to the 7-day hydrate TTL. Stamp hydrated nonterminal rows with
restoredUnconfirmed, carry it through both IPC paths, and treat such rows as
never-fresh in the shared and renderer freshness gates so the sidebar,
worktree.ps, and the raw snapshot all present the same degraded semantics.
Terminal states restore as-is; any accepted live event clears the flag; the
flag itself is never persisted. Interrupt/question inference refuses to
fabricate transitions onto unconfirmed rows.
* fix(agent-status): shed unconfirmed marker when the liveness sweep verifies done
The restored-subagent reaper's reconciled entry spread carried
restoredUnconfirmed onto a process-probe-verified 'done', making freshness
gates suppress a legitimate completion. Keep the marker only while the
reconciled state stays nonterminal.
* fix(agent-status): let live evidence replace hydrated rows
* fix(agent-status): keep restored rows degraded
Sort accepted live evidence after hydrated rows even across wall-clock rollback. Let unconfirmed rows own their preserved pane titles without asserting live state, while retaining independently live sibling evidence.
* fix(agent-status): suppress unmapped restored titles
Treat a single runtime title as covered by the single restored hook row while layout identity is unavailable. Preserve ordinary age-stale fallback and mapped sibling-pane evidence.
* fix(renderer): drive agent working spinner on the compositor (STA-3328)
The shared 12Hz JS clock wrote el.style.transform on every mounted spinner
(41 live = ~490 main-thread style writes/s), keeping style perpetually dirty
and delaying keystroke dispatch (measured typing inputDelay p90 363ms; 19ms
with the writes stopped). Replace it with a steps(12) CSS transform animation
so rotation runs on the compositor; one mount-time animation-delay write
preserves the shared-epoch phase sync.
* fix(renderer): lock agent spinner phases to document time
* fix(renderer): tolerate missing spinner animation API
* fix(ai-vault): support session scanning in SSH worktrees
Add relay-native aiVault.listSessions scanning that discovers agent
sessions on SSH hosts. Includes fallback to filesystem crawl for
legacy relays, full cancellation support, result validation, and
scan coalescing to reduce redundant work.
* fix(ai-vault): scan sessions in SSH worktrees with coordinated cancellat
- Extract batching logic to `mapRemoteScanBatches` for reuse and proper cancellation checkpoints
- Move `AiVaultScanCoordinator` from relay to main to handle concurrent same-key requests with individual cancellation signals
- Report scope path truncation consistently across relay and SSH fallback paths
- Gracefully degrade relay handler on unsupported platforms instead of aborting startup
- Refactor issue display to separate blocking errors, scope notices, and skipped transcript counts
* fix(ai-vault): stabilize SSH session scan CI
Swallow async WSL relay stdin EPIPE so the live hook-relay shard no longer
fails after all tests pass. Merge main, resolve scan/relay conflicts, and
align cancellation/host-issue reporting with IPC expectations.
* fix(ai-vault): harden session scan cancellation, relay timeouts, and preemption
Thread the abort signal through every scan and parse path so superseded or
cancelled scans stop promptly instead of parsing every remaining transcript
for a caller that already left. Replace the fragile message-text relay
timeout check with a typed error code so unrelated errors carrying the
phrase "timed out after" no longer suppress the filesystem fallback. Fix
scan coordinator preemption so a forced Refresh in one window no longer
re-enters as a spurious cancellation in another. Add a host-leg cache for
the all-hosts view and cap filesystem concurrency so a single slow remote
home cannot stall the whole merge.
Co-authored-by: Orca <help@stably.ai>
* fix(ai-vault): use stable React keys for scan issue banners
Drop array-index keys so react-doctor/no-array-index-as-key passes.
Uniqueness comes from host, kind, agent, path, and message.
* fix(ai-vault): SSH session scanning with configurable depth limits
Implement depth-aware caching and proper scan boundaries to make SSH session
scanning reliable in worktrees. Users can now select between faster (250
sessions) and comprehensive (unlimited) history scans. The scanner:
- Deduplicates scans across relay, host leg, runtime, and renderer layers
- Reuses larger scans to serve smaller depth requests
- Properly bounds in-scope discovery per-limit
- Fixes timeout enforcement when SSH providers ignore abort signals
* Move sessionLimit ref update to useLayoutEffect
Keep render pure for React Doctor by deferring ref updates to
a layout effect, which still executes before render-dependent
effects that consume the ref.
* fix(adhoc): stamp version prefix from main, not the feature branch
Adhoc builds check out arbitrary refs whose package.json often lags
version bumps (e.g. 1.4.165-rc.0 while main is 1.4.168-rc.1). Hourly
always builds main so it already tracks the product line; adhoc now
resolves the base version from origin/main (or ORCA_ADHOC_BASE_VERSION)
so branch builds share that prefix.
* Revert "fix(adhoc): stamp version prefix from main, not the feature branch"
This reverts commit a26a18eb3fd83f7e7d2db9a6a7c3e02e0f79089a.
* fix(ai-vault): fix scoped backfill and coordinator race conditions
Resolve race where the last waiter leaving could abort an already-settled scan (add `settled` flag). Redesign scoped session backfill to keep searching through newer files until the scope reaches its requested session quota instead of stopping at the candidate limit; out-of-scope files no longer consume the scope budget. Centralize scan limit normalization and fix error classification for cancelled scans using the proper helper instead of checking Error.name. Disambiguate cache keys using JSON and add cancellation check after scope discovery phase.
---------
Co-authored-by: Orca <help@stably.ai>
* Post fixing replies after launching PR comment resolution agents
Orca now automatically replies to selected comments after launching an
agent to resolve them. Review-thread comments get nested replies; review
summaries and issue comments get top-level @-replies. Payloads are
snapshotted at queue time and posted only after prompt delivery
succeeds. Bounded concurrency (4) prevents SSH slowdown.
* Batch conversation replies instead of posting per-comment
Combine N unresolvable comments into a single PR conversation reply to avoid timeline spam. Review threads still get one nested reply each. Refactor helper functions to pr-comment-fixing-reply-body.ts and change replyAsConversation(comment, body) → replyAsConversation(body).
* Handle bot account mentions in PR conversation replies
Use buildPRCommentConversationReplyBody to properly handle GitHub App
bot accounts, which don't resolve as @-mentions. Enforce delivery semantics
with type-safe PendingPRCommentAiAck payloads and improve error handling
around agent launch to prevent stranded state.
* Fix React Doctor ref-mutated-during-render errors in ChecksPanel
Move comment-resolution payload ref clears and latest-callback mirrors
into effects so render stays pure for the static analysis gate.
* Optimize worktree parent picker: conditional mount, virtualization, lazy
- Mount WorktreeParentPickerPopover only when open to avoid hundreds of
unmounted instances subscribing to lineage and worktree store updates.
- Virtualize the candidate list and resolve activity statuses only for
visible rows, eliminating redundant status subscriptions.
- Extract filtering, placement calculations, and row rendering into
separate modules for testability and clarity.
* Memoize worktree parent picker search handler
Wrap search state update in useCallback to stabilize the handler
across re-renders. Reduces unnecessary effect runs and enables better
memoization of child components.
* Optimize worktree parent picker: defer unmount, memoize IDs
- Defer unmount until exit animation completes (200ms) to prevent premature teardown
- Memoize visibleWorktreeIds to prevent status hook from rebuilding its selector on every render
createExpandCollapseActions minted five fresh closures per TerminalPane
render; four are deps of useTerminalKeyboardShortcuts, so its seven
window listeners tore down and re-registered on every render and all
effect-owned IME chord/modifier state silently reset. Memoize the
actions via useExpandCollapseActions and pin registration stability
with a render-churn regression test.
Fixes#12269
The cell stamps invite expiry at exactly now+10min from its own clock while
the desktop rejected anything past now+10min from the local clock with zero
tolerance, so any cell clock ahead of the machine by more than network
transit made every Relay pairing code fail with an opaque toast. Same
defect class as the host-proof freshness incident; same 30s leeway.
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
The runtime RPC WebSocket listener bound to 0.0.0.0:6769 at startup, so a
desktop with no paired device was reachable from the whole LAN before the
user opted in. Default the bind to 127.0.0.1 and widen to all interfaces
only on an explicit opt-in:
- createMobilePairingOffer / getRuntimePairingUrl widen (ensureNetworkExposure)
before advertising a LAN endpoint; the rebind reuses the resolved port so an
already-issued offer stays valid, and concurrent offers share one rebind.
- orca serve and E2E set exposeNetworkByDefault to bind wide at startup.
- A previously-connected device (lastSeenAt > 0) rebinds wide at startup so
reconnect after restart keeps working; a pending/never-connected offer does
not persist exposure across a restart.
The advertised pairing endpoint still resolves to a concrete interface address,
never the 0.0.0.0 bind host.
* fix(sidebar): route local folder adds to the intended host, not the global runtime
Adding a local folder while connected to a remote runtime failed with
"<path> was checked on <host>, but that host did not report a usable folder"
because addRepoPath decides local-vs-remote purely from the global
settings.activeRuntimeEnvironmentId when no explicit host is passed.
Two local-add flows relied on that global fallback and got misrouted:
- useAddRepoLocalFolderFlow (native picker / drag-drop): the Add Project
host selector can display "Local" (selectedRuntimeEnvironmentId = null, so
the guard passes) while the global still points at an unavailable runtime.
Native-picked/dropped paths are always local, so force local routing.
- AddProjectFromFolderDialog ("Add folder as project" on a subfolder): a
subfolder lives on the active repo's host, so carry that host through the
modal data and route by it — local for local projects, the owning runtime
for runtime projects — instead of the globally-active runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sidebar): route runtime server-path adds by selected runtime, not global
The Add Project "server path" step (reached only when a runtime host is
selected) called addRepoPath(path, kind) with no explicit host, so it
inherited the global settings.activeRuntimeEnvironmentId. When that global
diverged from the dialog's selected runtime, the add was misrouted off the
host the user picked — the same root cause as #9541, opposite direction.
Route the server-path add by the dialog's selected runtime explicitly.
Co-locate selectedRuntimeEnvironmentId in useAddRepoHostSelection next to
selectedSshTargetId so the dialog reads it from one place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): route the pre-add git server-path scan by the selected runtime
For kind === 'git', scanNestedRepos runs before addRepoPath and can
early-exit the flow into the nested-repo review, but it still routed by the
global active runtime — so it could scan the wrong host even after the add
itself was correctly routed to the selected runtime (CodeRabbit).
- scanNestedRepos accepts an optional runtimeEnvironmentId in its controls;
when present it routes by that host, else falls back to the global
(existing callers unchanged).
- useAddRepoServerPathFlow passes the selected runtime into the scan and
derives runtimeKind/streaming support from it instead of the global-reading
getNestedRepoRuntimeKind(null), so telemetry and the nested review target
the same host as the add.
Adds renderer- and store-level regression tests covering scan routing to the
selected runtime, the null-override local case, and the nested-review handoff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): make nested-scan cancellation route by the scan's owning host
Follow-up to CodeRabbit review of the scan-routing change:
1. scanNestedRepos treated `{ runtimeEnvironmentId: undefined }` as an explicit
local override via the `in` check. Only null or a string is now explicit;
undefined falls back to the global (matches getAddRepoPathRouteSettings).
2. scanNestedRepos gained a routing override but cancelNestedRepoScan still
routed by the global — an asymmetric contract where an override-routed scan
could be un-cancellable if the global diverged mid-scan. cancelNestedRepoScan
now takes the same override, and useAddRepoNestedReviewState remembers each
scan's owning host by scanId (set when the scan is registered) so both stop
and reset cancel on the host the scan actually ran on. The local folder flow
routes its scan explicitly local so scan, cancel, and add all agree.
Adds store-level regression tests (explicit override wins, undefined falls back
to global, cancel routes by override) and a new useAddRepoNestedReviewState test
covering cancel-by-owning-runtime for stop and reset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sidebar): keep subfolder adds on their owning host
* fix(onboarding): keep completion on captured host
* chore(review): drop unreachable onboarding recovery
* fix(sidebar): preserve paired runtime checkout ownership
* fix(runtime): index paired worktrees by logical owner
* fix: fail closed on worktree owner alias collisions
* docs(sidebar): clarify host-routing intent flagged in review
Two Greptile P2 notes, addressed as comments (no behavior change):
- project-added-default-checkout.ts: the runtime branch's `hostId === executionHostId`
is NOT unreachable — a colliding repo id can carry a runtime-qualified hostId with
no runtimeOwnerEnvironmentId (see the "repo IDs collide" test). Documented why the
comparison is reachable and load-bearing rather than replacing it.
- AddProjectFromFolderDialog.tsx: note that omitting the runtimeEnvironmentId spread
intentionally signals local (NonGitFolderDialog coerces absence to null).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(activity): control portal readiness observer delivery
---------
Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* test(runtime): prove mobile session publication rebuilds every worktree
buildMobileSessionTabSnapshots consults its per-worktree cache after building
the content, so a republish saves the fanout but none of the work. With 300
worktrees, an unchanged republish still does 601 units of per-worktree work,
and a single changed worktree does 602.
Publication is keyed on agentStatusByPaneKey/agentStatusEpoch, so this runs on
every agent status tick. On a multi-client runtime host with 381 worktrees this
allocated ~350 MB/min and rode the renderer into repeated 4 GB OOMs.
Tests are marked it.fails so the branch stays green; drop .fails when the build
loop skips worktrees whose inputs are unchanged.
* refactor(runtime): make mobile session snapshot inputs explicit per worktree
Every per-worktree builder in buildMobileSessionTabSnapshots took the whole
AppState, so a worktree's real input set was the transitive closure of seven
helpers and could not be memoized safely. Introduce MobileSessionWorktreeInputs
— built once per worktree — and thread it through the group projection and the
terminal/markdown/file/browser tab builders so the compiler proves the input
set. Tab- and pane-keyed slices are narrowed to this worktree's tab ids, file
ids, browser workspace/page ids, and pane keys; agent statuses are bucketed per
worktree once per publication via a tab-id index.
No behavior change. Dropping AppState from the projection path also removes the
second per-worktree read of browserTabsByWorktree, so the publication-cost
counter falls from 601 to 1 per publication and its two cases now pass.
* fix(runtime): skip unchanged worktrees before building mobile session content
buildMobileSessionTabSnapshots consulted its per-worktree cache only after
building that worktree's three Maps, group projection, and full tab array, so
the cache suppressed the fanout but none of the computation. Every agent-status
tick therefore rebuilt every worktree, which drove sustained 4 GB renderer
working sets on a host holding 381 worktrees.
Cache MobileSessionWorktreeInputs alongside each snapshot and reuse the snapshot
when every input field is reference-equal, before any intermediate structure is
allocated. Worktrees with a mounted TerminalPane always rebuild: their live
DOM/PaneManager state is invisible to store references. Absent per-worktree
slices now resolve to shared empty values so an empty worktree can compare equal
to its last publication. jsonContentEquals stays as the backstop on the rebuild
path for inputs that churn by reference without changing output.
With 300 worktrees, per-worktree content builds go from 300 to 0 on an unchanged
republish and from 300 to 1 when one worktree changes.
* test(runtime): cover agent status publication cost
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>