mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
dc3aa36ad349f0da4e43bed9ddd8221aa60bda33
1887
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dc3aa36ad3 |
fix(ssh): measure pane idleness in the unit the sweep's kill operates on
The orphan-relay-PTY sweep authorizes `pty.shutdown { immediate: true }`, which
runs `forceKillPosixPtyProcessGroups`: collect every process group on the pane's
tty, then `killpg` each one. The blast radius is therefore (groups on the tty) x
(members of those groups, wherever they are). The idleness evidence measured only
the first factor, so three shapes read as idle and were SIGKILLed:
- with job control off (`set +m`) a background job keeps the SHELL's pgid, so the
tty carries exactly one process group and that group is running the user's build;
- a child that drops the controlling terminal (`ioctl(TIOCNOTTY)` without `setsid`)
keeps the pgid, reports `tpgid == -1`, and never appears in `ps -t <tty>`;
- a double-forked grandchild keeps the pgid and tty but reparents to pid 1, so the
`ppid` walk cannot reach it and the named-process backstop never fires.
`shellOwnsEveryTtyProcessGroup` now also requires the shell's own process group to
hold no other member anywhere in the table, indexed in the same single pass. A
pids-per-tty set would catch the first and third but not the second, which is why
the count is pgid-wide rather than tty-scoped. The wire field keeps its tty-shaped
name: the value only ever became stricter, so an old client skips more, never less.
Second, unrelated-in-mechanism but same file family: `foregroundSkipReason` summed
`capturedAgeMs + evidenceAgeSinceListingMs` without validating either. A non-numeric
`capturedAgeMs` makes the sum `NaN`, and `NaN > 5000` is false, so a malformed record
PASSED the freshness gate and proceeded toward the stop — the one place in the file
that defaulted toward kill. Nothing validated it on this path
(`mapSshPtyProcessList` checks the ownership fields and spreads the rest through;
`PtyProcessListAdmission` is not on the sweep path). It now runs
`isForegroundProcessEvidence` and fails closed.
Verified on real Linux, not only in mocks: a container drives `bash -i` on a real
pty, builds each construction, runs the real publisher and planner, and then calls
the real `forceKillPosixPtyProcessGroups`. Before, all three published
`shellOwnsEveryTtyProcessGroup: true`, planned SWEEP, and the planted pid was gone
after the signal. After, all three skip and survive, and an idle shell is still
reclaimed.
Residuals are written down at the predicate and in ssh-execution-boundary.md: the
capture is a snapshot (bounded by the evidence-age budget, not removed), and a
process the host's own `ps` cannot enumerate stays unobservable while `killpg`
still reaches it.
|
||
|
|
7b530f1eb5 |
fix(crash-reporting): record Orca-initiated tree kills so a killed renderer is decidable (#18367)
* fix(windows): refuse tree-kills of Orca's own Chromium pids and record the rest G2 is 20 field reports that share only a symptom. It is at least four fingerprints: ~15 Windows `reason=killed exitCode=1`, 3 POSIX SIGKILL under memory pressure (G4-oom), 2 duplicate reports of one macOS V8 Proxy Resolver SIGKILL, and 1 `0x80000003` install-dir ACL crash (G1; #17740 ships in v1.4.196 only, not 1.4.195). Nothing here claims to fix all of them. Two changes: 1. Behaviour. `classifyWindowsTreeKillTarget` returns `own` for any direct child of the main process — which our renderer, GPU and network-service utility all are — so PTY teardown could `taskkill /T /F` Orca's own UI (#10680). Both that classifier and `terminateWindowsProcessTree` now refuse any pid Electron is currently accounting for in `getAppMetrics()`. 2. Diagnosis. An Orca-issued kill and an external one are byte-identical in every field the crash report records today, so the cluster is undecidable. Every main-process force-kill choke point now records a durable `self_tree_kill` breadcrumb, and `process_gone` reports carry `selfInitiatedTreeKills` naming the pid and its offset from the death. A refused kill records `self_tree_kill_refused_own_chromium`, which is falsifiable: if it ever shows up in the field, we were the killer. * fix(crash-reporting): coalesce self-kill breadcrumbs and scope the discriminator Round-1 review remediation. Three blocking findings, all accepted. 1. Breadcrumb flood (accepted). recordSelfInitiatedTreeKill wrote an uncoalesced durable crumb from two routine teardown paths, and the reviewer reproduced 12 terminal closes x 3 process groups completely evicting the 30-slot ring — including this PR's own refusal crumb — plus a forced writeSync per killed group. It now uses the existing recordCoalescedDurableCrashBreadcrumb (5s window for pid-addressed taskkills, 60s for routine group/job teardown), so a burst costs one ring slot and one flush. The refusal crumb is coalesced per victim pid, so a retry loop cannot flood while a distinct pid always gets its own crumb. Regression test replays the reviewer's exact 12x3 reproduction and asserts the refusal crumb and a pre-existing gpu_process_crashed both survive. 2. Undifferentiated count (accepted). posix-process-group and win-pty-job are structurally incapable of reaching a Chromium process, and scope was absent from the persisted string. Scope is now in every entry (`<scope>/<site>/pid<N> +Nms`), and the count is split: selfInitiatedTreeKillCount now counts only pid-addressed taskkills — the kills that can land on a recycled pid that is now our renderer — with pty-scoped sweeps in selfInitiatedGroupKillCount. The list is renamed selfInitiatedKills because it carries both, and sorts pid-addressed kills first so truncation never drops the discriminating ones for teardown noise. The reviewer's repro (routine macOS terminal close + unrelated exit-133 crash) now yields selfInitiatedTreeKillCount undefined. 3. Recording gaps and a false comment (accepted). New admitSelfInitiatedTreeKill gate: it refuses own-Chromium pids and records the rest, and all three main-process taskkill families now go through it — terminateWindowsProcessTree plus codex-accounts/service.ts and claude-accounts (which keep their own spawn lifetimes). The false "single taskkill choke point" comment is gone. The runProcess choke point the investigation asked for is instrumented via a setProcessTreeKillObserver seam in src/shared/child-process — shared code runs in the CLI and relay so it cannot import the main breadcrumb store — registered in main preflight. The codex app-server POSIX group teardowns and the claude POSIX branch record too. The module doc no longer claims absence is discriminating: it enumerates what is instrumented and names the direct process.kill(-pid) sites that are not. Non-blocking, also fixed: - Breadcrumb calls moved out of the try blocks whose catch is the ESRCH contract (posix-pty-process-groups, codex teardown, claude POSIX), so a throw from the diagnostic path can never be reported as a failed kill. - Detail truncation now bounds the first entry too, matching its comment. - own-chromium-tree-kill-refusal.test.ts renamed to own-chromium-tree-kill-guard.test.ts, colocated with the module it tests. Not changed, with reasons: - Date.now() vs performance.now(): kept. Offsets are computed against goneAt = Date.now() in process-gone-recorder; a monotonic clock here would make the offsets meaningless. The reviewer verified this and agreed it is not a defect. - app.getAppMetrics() per force-kill remains unbenchmarked. It reads in-process browser state rather than enumerating the OS process table, and a TTL cache would let a recycled pid slip past the refusal, so it stays uncached. - The ~15 remaining direct process.kill(-pid) sites (browser routes, notebooks, automation prechecks, ephemeral VM recipes) are not instrumented. Rather than claim coverage this PR does not have, the module doc names them. claude-command-process.ts crossed the 300-line cap, so terminateClaudeProcess moved to claude-login-process-termination.ts. No max-lines suppression added. * fix(crash-reporting): scope the self-kill guard to its real host topology Round-2 review findings on the own-Chromium tree-kill guard. BLOCKING 1 — "the own-Chromium refusal is a no-op in the process that issues the pty-descendant-sweep taskkill". Correct on the mechanism, wrong on the consequence; REBUTTED in part and documented in full. Confirmed: the only non-test `setAppEnvironment` installs are main-process-preflight.ts:177 (Electron) and orcad-entry.ts:84 (Node, whose `getAppMetrics()` is `[]`); daemon-init-fresh-import.ts is a test harness. So in the standalone daemon `readOrcaChromiumProcessPids()` is empty and `admitSelfInitiatedTreeKill` always admits. But that is not a live hazard. `killWithDescendantSweep` reaches `terminateWindowsProcessTree` only when `verifyWindowsTreeKillTarget` returns `own`, and that walks ancestry back to `deps.ownerPid ?? process.pid` — the KILLING process's pid. In the daemon that is the daemon's pid. Orca's Chromium processes are children of Electron main, a sibling of the daemon, so their chain never reaches it: hop 0 lands on main, and within MAX_ANCESTOR_HOPS the walk dead-ends and returns `foreign`. The reviewer's probe passes `ownerPid: 1000` with the renderer as a direct child of 1000 — that is the Electron-main topology, where the AppEnvironment IS installed and the guard DOES fire, not the daemon's. On an orcad/SSH host there is no Chromium on the box at all, so `[]` is accurate rather than degraded. Locked in as tests rather than prose (own-chromium-tree-kill-guard.test.ts): a renderer classifies `foreign` from a daemon ownerPid with an empty pid set, and `own` from main's ownerPid with an empty set — the falsifiable pair showing the pid set is load-bearing in main and nowhere else. Documented the host coverage in orca-chromium-process-pids.ts and own-chromium-tree-kill-guard.ts. One genuine hole the finding exposes: `signalProcessTree`'s `taskkillTree` is a fourth pid-addressed taskkill family (non-blocking item 2), it runs in the daemon/relay/CLI where the guard cannot run, and it guarded only on `!child.pid`. Reusing the predicate the codex login teardown already uses, the win32 branch now refuses a reaped child and falls back to `killRoot` — the same shape as the existing `!child.pid` branch. That closes the reaped-then-recycled pid path in every host. BLOCKING 2 — module doc overstates coverage. Rewritten: the ring is per-process and its only reader lives in Electron main, so a count on a `render-process-gone` covers main-issued kills only. Sites are now split into main-only, main-and- other-hosts (runProcess choke point, POSIX PTY group sweep, Windows Job Object — which record into a ring nothing reads when they run in the daemon or relay), and never-instrumented, with the note that a daemon/relay omission is a diagnostics gap, not a missed suspect, per the topology argument above. BLOCKING 3 — the three out-of-main instrumentation sites were untested. Added regression coverage: the runProcess seam on both branches plus the reaped-child refusal (process-tree-termination.test.ts), the group sweep recording only groups it actually signalled and skipping an ESRCH group (posix-pty-process-groups.test.ts), and the Job Object recording the shell pid only on `terminated` (windows-pty-job.test.ts). Verified red: reverting the three production files to origin/main fails 7 of the new tests. BLOCKING 4 — the Windows evidence validates a single-process model. Accepted. The main2.js arms exercise `pty-descendant-sweep` inside one Electron process; that models the in-process/degraded daemon and the local PTY provider, not the standalone daemon. Arm C's "the 449351d6 shape is not producible with the guard" holds for main-issued kills only. In the daemon the shape is blocked one layer earlier, by the ancestry check, which the arms do not exercise. NON-BLOCKING taken: `recordSelfInitiatedTreeKill` moved outside the native `terminateJob` try in windows-pty-job.ts, so a diagnostics throw can no longer downgrade a real termination to `unavailable` and escalate callers to a broader kill; covered by a test. The "all three families" parenthetical is gone with the doc rewrite. `pnpm build:relay` run: exit 0, all seven targets built. NON-BLOCKING declined: codex-accounts/service.ts records before the spawn because a refusal must prevent the spawn — the crumb means "we were about to kill this pid", which is the artifact worth having; the existing comment already says so. `app.getAppMetrics()` perf is unbenchmarked and unchanged by this round. Verification: pnpm tc clean; oxlint clean on touched paths; check:code-quality:changed 0 new findings; oxfmt applied. 730 tests pass across shared/child-process, main/crash-reporting, main/pty, main/windows and the guard and descendant-sweep suites. The 4 failures in providers/git/codex-integration reproduce on HEAD without these changes. * fix(crash-reporting): keep the reaped-pid skip from flipping the termination barrier The win32 hasExited short-circuit correctly avoids taskkill on a pid Windows may have reissued, but it resolved `true` — verified tree termination. A taskkill against a reaped pid already resolved `false`, and run-process turns `true` into barrierTerminationVerified + terminationReporter.report(), which releases the git admission grant on root exit instead of on `close`. That admits the next git command while a descendant holding the inherited pipes is still writing the repo. Resolve `false` so the skip changes only which process we refuse to signal, not what the barrier claims. |
||
|
|
860ee73a11 |
fix(git): parse sparse and cquoted paths on the SSH relay (#18389)
The relay carried its own copies of the worktree-list and unmerged-entry porcelain parsers, and both had drifted from the desktop originals: the relay copy had no `sparse` branch, so SSH sparse checkouts were never marked, and it never C-quote-decoded a conflict path, so a conflicted file with a space or non-ASCII byte was published under its raw quoted name and probed as missing. Move both parsers into src/shared and delete the relay copies, so there is one implementation each. Type the relay's worktree-list plumbing on GitWorktreeInfo instead of Record<string, unknown> so a field-copying step can no longer silently drop a newly parsed field. `isSparse` is a new optional field on the git.listWorktrees result (remote-wire-compatibility Rule 1); Git <2.28 omits the porcelain line and the field stays absent. No new git subcommand or option. Closes #18280 |
||
|
|
a9f2fbb684 | chore(workspaces): drop the dead workspaceCleanup:hasKillableLocalProcesses IPC (#18386) | ||
|
|
573537ecd4 |
feat(cli): make terminal close the canonical workspace teardown (#18073)
* fix(runtime): recover stale session owners and await retirement * fix(runtime): preserve session hydration and smoke compatibility * test(runtime): cover empty and unindexed session owners * feat(cli): make terminal close the canonical workspace teardown * fix(preload): align ssh termination result type * test(runtime): assert folder hydration owner * fix(runtime): fence legacy terminal stop by worktree host * fix(preload): reconcile ssh result import with main * fix(runtime): keep same-id sibling hosts out of workspace close The stale-owner fallback in the session controller re-routed any worktree whose catalog partition had no tabs to whichever other partition held tabs. Only `runtime:` environment ids rotate across relay restarts; `repoId::path` legitimately repeats across hosts, so an SSH workspace close could retire the local copy's tabs and resume records, or flip owners mid-close and strand the SSH PTY. Restrict the fallback to runtime hosts, and pin the session partition once per workspace close so record clearing targets the partition that owned the tabs. * test(runtime): give the cross-host close fixture a real resume record * fix(preload): take main's ssh-bridge import order so the merge stays duplicate-free |
||
|
|
968dbd905f |
perf(renderer): take the English catalog and the xterm WebGL addon off the boot graph (#18326)
* perf(renderer): take the English catalog, xterm WebGL addon and emoji data off the boot graph The renderer's boot graph — the entry chunk plus its 331 modulepreload links, all fetched and evaluated before first paint — carried three payloads nothing needs at that moment. `en.json` (644 KB) was an eager i18next resource, but every renderer string goes through `translate(key, fallback)` and `en` resolves that inline default, so most of the catalog was dead weight. The renderer now bundles a generated `en-runtime-required.json` holding only the 2,583 of 13,828 entries a default cannot reproduce: plural-suffixed keys, keys whose catalog value differs from a call site's default, and keys no call site references with a literal default. `en.json` stays the translator source and the input to the four lazy catalogs. `@xterm/addon-webgl` (243.6 KB) and `emojibase-data` (170 KB) are now primed right after the React root renders instead of statically imported. The load stays eager and `attachWebgl` stays synchronous — it reads the resolved constructor — so no terminal ever falls back to the DOM renderer for a frame. `isPluginPanelTabKey`/`isQualifiedPluginKey` move to schema-free sibling modules, re-exported from `plugin-manifest.ts`. This evicts the plugin manifest schema graph from the boot chunk but measures ~0 KB, because six other shared modules still put zod on the boot path. Boot graph: 332 chunks / 5107.2 KB -> 336 chunks / 4161.5 KB (-945.7 KB, -18.5%). A new ratchet parses the built index.html and fails if `en.json`, `@xterm/addon-webgl` or `emojibase-data` is preloaded again; it runs at the end of every `build:electron-vite`. * chore(i18n): pin the generated English subset to LF and mark it generated * fix(i18n): make the runtime-catalog gate merge-robust and prime emoji data in tests CI builds the merge of a PR with main, so a byte-for-byte comparison against a committed generated file fails the moment any unrelated PR adds a translate() call — which is what happened here. The check now asserts the property that actually matters instead of byte equality: every runtime-required entry is shipped, and nothing shipped disagrees with en.json. Entries that stopped being required are dead weight, never a wrong string, so they are reported and tolerated. Failures now name the offending keys rather than saying "stale". The generator itself was already deterministic (plain code-unit sort, no locale collation, order-independent set construction); a test now pins that a reversed call-site walk produces byte-identical output. Test fixes for the catalog prune and the deferred emoji load: - browser-search / NativeChatSupportedAgents asserted key presence on the renderer's runtime resource. The durable contract is en.json — the renderer deliberately no longer bundles entries a call site default reproduces — so they assert against the translator catalog. - Four emoji tests typed a shortcode in the same tick as mount, before the catalog the hook primes on mount resolves. Not reachable by a human; the tests now await the prime. * revert(renderer): keep the emoji shortcode catalog statically imported Deferring emojibase-data introduced a window that did not exist before: until the dynamic import settled, getPrimedEmojiShortcodeEntries returned [], so exactShortcodeIndex built an empty map and replaceCompletedWorkspaceEmojiShortcode returned null — leaving a typed `:wink:` in the field literally, and persisting it as the workspace display name. Pre-change the shared catalog was statically imported, so the first call at any tick returned full data. The window is reachable by anything that dispatches input in the same task as the field's mount effect — Playwright/CDP in the e2e suite and agent automation both do, and the WorktreeMetaDialog test failure was exactly that, producing 'Feature 😉' instead of 'Feature 😉'. Nothing that resolves a shortcode can be async without that race, and a wrong persisted name is not an acceptable trade for 166.7 KB, so the deferral is reverted rather than papered over in the tests. The boot-graph ratchet drops its emojibase-data probe and records why. Boot graph: 5108.9 KB -> 4329.9 KB (-779.0 KB, -15.2%), down from -945.7 KB. * fix(terminal): make the deferred WebGL addon load recoverable and refit on late attach Two defects the deferral introduced, neither possible with a static import. A failed load latched the DOM renderer for the whole session. `.then(onOk, onError)` settles fulfilled, so the memoized promise was cached forever with a null constructor: attachWebgl's re-prime got the cached promise back, and resetTerminalWebglSuggestion — the documented "GPU setting changed, retry" path — could not clear it either. The rejection path now clears the memo, latches the queued panes the way a failed construction does so they retry at a recovery boundary rather than every frame, and caps attempts so a genuinely missing chunk is not re-fetched forever. The recovery boundary re-arms it. The queued-attach drain skipped the refit. Every other late-attach path pairs attach with a refit because the grid was measured under DOM cell metrics and WebGL floors the device cell width. Post-deferral, openTerminal's attachWebgl queued and returned, the initial fit rAF then measured DOM metrics and sized the PTY from them, and the addon attached with no refit — a persistently narrow PTY and an unpainted right gutter, not a one-frame flicker. Both paths now go through one attachWebglAndRefit pairing so they cannot diverge again. Regression tests cover both, and each was verified to fail without its fix. The addon-load state machine moves to terminal-webgl-addon-loader.ts and the viewport presentation helpers to pane-viewport-present.ts, keeping pane-webgl-renderer.ts under the 300-line budget without a suppression. |
||
|
|
d5803bdbc4 |
feat(ssh): host-stamped remote foreground identity (#18078)
* docs: add SSH agent identity implementation plan * feat(ssh): host-stamped remote foreground identity * fix(runtime): preserve unfenced inspect call shape * perf(ssh): traverse foreground descendants linearly * fix(ssh): bound retired PTY evidence records * test(ssh): cover retired incarnation retention * fix(ssh): make remote process inspection total * Split SSH identity build hot spots * Fix process table snapshot module split * test(ssh): update process inspection expectations * docs: drop the SSH identity plan from the PR The design doc does not belong in the product repo; it stays out of the shipped tree while the implementation carries its own comments. --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
df420285b0 |
perf(persistence): stop rewriting redundant bytes in the profile store (#18317)
Two kinds of byte in orca-data.json were provably redundant. Both are paid on every debounced save (full re-serialize) and every launch (full re-parse). 1. The renderer's host split handed one global-field template to EVERY host, so local's browserUrlHistory/workspaceDocHistory were copied verbatim into each non-local partition. That is a write-side regression undoing #18161's load-time drop: the load path removed the replicas, the next full snapshot write put them back. Non-local slices now get a template without the fields the merge only ever reads off 'local', and both the host write and the serializer strip the residue. 2. mergeWorktreeMetaForWrite materializes all ten linked* slots plus isArchived/isPinned on every metadata row, so a 1,200-workspace store carried ~534 KB of "field":null pairs across worktreeMeta and worktreeMetaByIdentity. The serializer omits slots still at their default and normalizeWorktreeLinkedItemMetadata re-fills them at load, so in-memory state is unchanged. Measured on a fixture sized like the reporting install (10 hosts, 1,200 metadata rows, 200 history entries): 1,445,276 -> 643,238 bytes per save (-55.5%), 164,250 -> 3,411 bytes structured-cloned per persistWorkspaceSessionByHost across 9 non-local hosts, launch JSON.parse 2.00 ms -> 1.37 ms. |
||
|
|
08c7152ab6 |
fix(ssh): compare a lease's relay pty id against the pane's app id (#17969)
`getRecentExpiredSshLease` compared the stored lease ptyId (relay form, written through `toStoredPtyId` -> `toRelaySshPtyId`) raw against the runtime's app-form `pty.ptyId`, so `'pty-3' === 'ssh:target@@pty-3'` never held and `recoverTerminalPane` refused every real SSH pane. Normalize with the same tolerant helper the binding reader already uses, now shared as `toComparableRelaySshPtyId`. Switching the path on is only safe on top of #17957 (respawn gated on the runtime liveness verdict), #17965 (`expired` no longer withdraws bindings) and #17966 (supersession and id recycling carry their own marks). `recoverTerminalPane` additionally refuses a lease those marks disqualify, so it acts only on an `expired` lease that means "reattach gave up". The path's outcome is a reattach, not a respawn: `createTerminal` calls `adoptStablePane` first, which attaches attach-only to the retained binding and only falls through to a fresh shell once the host itself answers that the PTY is absent. |
||
|
|
f2e95e7860 |
fix(ssh): separate a superseded lease from an orphan so reattach can tell them apart (#17966)
`expired` was one word doing two unrelated jobs — "a newer lease won this pane" and "reattach lost contact" — so `reattachKnownPtys` had to exclude all of them. That kept the 2 -> 19 -> 20 fan-out fixed at the cost of never bulk-reattaching a genuine orphan; those recovered only through the slower `adoptStablePane` path. The blocker cited in #17965 does not apply. The STA-3077 note guards `upsertSshRemotePtyLease`'s match against a RECYCLED `pty-N` after a relay restart. `supersedeSiblingLeasesForPane` is a different path and already holds `winner.ptyId` when it expires a predecessor, so recording which lease won needs no relay-start identity. `SshRemotePtyLease` gains two optional marks, each meaning exactly one thing: - `supersededBy` — the winner's stored-form ptyId, written only by supersession. - `relayIdRecycled` — written only by the pending-stop replay's `relay-id-recycled` retirement. That retirement wrote `expired` *purely* to keep the lease out of the reattach that runs one step later ("hands the user's old pane to whatever process now holds the recycled id"), and the reattach fences on paneKey/tabId, never on incarnation. Relaxing the filter without this would have silently reopened that hole. Bulk reattach now skips a lease carrying either mark and re-adopts the rest, via one shared `sshRemotePtyLeaseAllowsReattach`. `terminated` is untouched. Recycled-id safety: both marks are dropped whenever the id is re-upserted `attached`/`detached`, so a relay that renumbered onto a new shell cannot inherit its predecessor's mark. Supersession also stamps an ALREADY-expired predecessor for the same pane — same evidence, and it is what bounds the reattach set, since otherwise every past orphan for that pane would stay reattachable forever. Its `updatedAt` deliberately stays put: bumping it would make a stale lease look recent to `getRecentExpiredSshLease`. Persistence: the lease loader is a strict whitelist, so both fields are named in `normalizeSshRemotePtyLease` or they would be stripped on every launch. Absence reads as "orphan", which is the only thing an older build could have meant, and an older build ignores keys it has never heard of (remote-wire Rule 1). |
||
|
|
cc66d6e900 |
fix(remote): stop a colliding path key, a dead conflict state, and a live-PTY removal from losing tabs (#17948)
* fix(ssh): retain remote sessions across late catalogs, path collisions, and PTY rotation Three losses in the "remote session state never reconciled" cluster, one rule: absence from a client-side set, or a stale client-side expectation, is `unverifiable` by construction and can never authorise removal. #12902 / #15484 — a direct-SSH snapshot whose host paths the local worktree catalog cannot place yet leaves the target in `conflict`, which suppresses uploads and holds terminal authority at `unverifiable`. Nothing re-pulled once the catalog landed, so the tabs stayed missing and the host ledger stayed stale until a reconnect. The apply now reports the paths it dropped and target-sync watches the catalog for them, re-pulling a fresh host snapshot when they become placeable. #15484 — exportRemoteWorkspaceSession keys the host projection by worktree path, which drops the repoId, so two local rows for one remote checkout collapsed and the last one won outright. An empty duplicate row published an empty tab list for a workspace with live panes, and the upload is a wholesale replace-session. Union by tab id instead, matching the `Math.max` its sibling recency map already applied to the same collision. #11495 — orphan recovery retired a leaf whenever a `terminal.list` with `requireFreshPtyLiveness: true` named a different PTY behind a handle than the snapshot frame's pending row did. That is the host attesting the handle is live under a replacement PTY, which is what a host relaunch looks like. Rebind instead of remove. Two tests pinned the removing behaviour and are retargeted with the reasoning. * fix(remote): handle a rejected deferred-placement pull and bound its retry chain The deferred placement retry ran its body as `void (async () => { try {…} finally {…} })()` with no `catch`. `getSnapshot` is an IPC call that rejects when the relay drops, and `applySnapshot` can reject with it, so a dropped relay produced an unhandled rejection in the renderer. Swallow it: the module already documents that a pull which fails is `unverifiable` and the target is left on `conflict`. The retry also re-armed itself through `applyUnsolicitedSnapshot` with no cycle bound, next to a sibling loop capped at MAX_SNAPSHOT_APPLY_ATTEMPTS = 3. When an apply reports still-unplaced paths that the catalog nonetheless reports placeable, `waitForSnapshotWorktreePlacement` returns true immediately and the arm -> pull -> apply -> arm chain never yields. The added test measures 50 pulls with no yield before this change. The bound counts only re-arms where the unplaced set stops shrinking. A chain that keeps placing rows is converging and is already bounded by that set emptying, so a raw count would strand a legitimately converging target on `conflict`; a test pins a five-round convergence that a raw count truncates at three. Re-arms are also only counted inside a retry's own apply, so a fresh host snapshot arrival does not spend the budget. |
||
|
|
d084a2a36a |
fix(ssh): decide remote-vs-local from the resolved execution host, not a raw field (#18294)
`repoIsRemote` read `repo.connectionId` directly. That is one of four spellings of host ownership, so the predicate was wrong in both directions: a row carrying only `executionHostId: 'ssh:<target>'` read as local and got the Linux-only `orca-ide` rename it cannot resolve through the relay shim, while a row that declares itself `local` with a stale `connectionId` read as remote and lost the rename it needs on a Linux desktop. The predicate now resolves the host first and asks "does an SSH target hold this row's files" via `getRepoSshConnectionId`. That keeps a `runtime:` host's nested SSH target remote (that machine reaches the files through its own relay shim) while a runtime with no nested target - a full Orca install - stays local, as do WSL and local. Its call sites did not all want that question: - The four launch-scope sites in main already hold the resolved PTY route on `TerminalWorkspaceLaunchScope.connectionId`. `scope.repo` is documented display metadata and can be a row from a different host than the worktree names, so they now read the route they will actually spawn on. A launch shape that disagrees with its own route is the bug, not a second predicate. - `launchAgentInNewTab` picked its repo row with a host-blind `store.repos.find`, so a worktree that names its own host could be shaped by another host's row. It now resolves through `getConnectionIdFromState`, the same rule the file already used for transcript readability. - `resolveAgentBackgroundLaunchHost` derived the route, the trust write and the launch shape from three reads of the raw field; one resolution now feeds all three. Also converts the raw `repo.connectionId` agent-detection probe eight lines above `buildWorktreeStartupForDraft`'s launch shape, which #17919 deferred precisely because converting it alone would have left that file internally inconsistent. Tests cover two distinct SSH hosts (a single-host fixture passes even when the answer comes off the wrong row, which is how the `ssh:m4air` -> openclaw leak survived review) and a `runtime:` host carrying a nested SSH target. |
||
|
|
c61ca56a9b |
fix(ssh): resolve the worktree's execution host instead of guessing from one repo row (#17909)
* fix(host-routing): resolve the execution host before reading a connection Three issues in one defect class: a resolver reads one spelling of one arbitrarily chosen row instead of resolving the worktree's execution host, so something local answers a question about a remote. returned that row's connectionId. With duplicate repo rows for one repo id it could pair a runtime owner with a client-owned SSH connection. It now resolves through the same ambiguity-aware index getRuntimeEnvironmentIdForWorktree uses, prefers the repo row for the host the worktree names, and derives the connection from the resolved host. Conflicting rows return `undefined` (this module's documented "cannot determine the host"), never `null`. `store.getRepo(worktree.repoId)?.connectionId ?? null`. `getRepo` is host-blind and the same repo id can exist on local, SSH and runtime hosts, so a remote worktree could spawn its PTY on the client with the remote cwd. resolveWorktreeLaunchHost picks the row for the worktree's host and reads the connection off that host; conflicting rows are unresolved, not local. session-partition owner maps that contradict each other. Both now compute through one shared function whose argument records the divergence. No behaviour change on either side: converging needs a read-both migration, since both partitions hold real data written by shipping builds. * fix(host-routing): keep nested SSH connections resolvable under a runtime host getRepoSshConnectionId read only the resolved execution host, so a repo row owned by a runtime that reaches a nested SSH target (connectionId: ssh-*, executionHostId: runtime:*) resolved to no connection — answering 'local' for a remote worktree, the same defect #17909 fixed in the other direction. * fix(host-routing): resolve both sides of the execution host through one rule The renderer resolver leaked between two different SSH hosts: a worktree on `ssh:m4air` whose only indexed repo row belonged to `openclaw` answered 'openclaw', because the host-scoped lookup missing fell through to an id-only one. Main's resolver, in the same change, answered 'm4air' — two resolvers, one right and one wrong, on identical input. Both sides now adapt one shared rule (`worktree-execution-host-resolution.ts`): the worktree's own host outranks every repo row, and a row on a different host is never evidence about this one. The renderer's WeakMap index becomes the memoizing adapter it always was; `resolveWorktreeLaunchHost` becomes main's mapping of unresolved onto its throw. Settles the rule the change previously answered two ways. `getRepoSshConnectionId` and `getSshTargetIdForExecutionHost` disagreed for a runtime host carrying a nested `connectionId`; they now compose, so the execution host is the single authority. On a `runtime:*` row that field is a paired HUB's private SSH target, spread through by `repoWithFetchedOwner` and unaddressable from this client — the project-first successor of the row nulls it for exactly that reason. That also fixes the `kind !== 'ssh'` fallback, which fired for `local`: a row declaring itself local handed out an SSH connection. |
||
|
|
b8b7a6be9d |
fix(activity): persist the agents unread filter and grouping (#18255)
* fix(activity): persist the agents unread filter and grouping The Agents view's "Show unread threads only" toggle and Group-by select were plain component state in the sidebar and the Activity page, so both reset on every mount — including app restart — while their neighbours in the same toolbar (compact rows, show child agents) survived via the persisted UI store. Promote both to `agentsReadFilter` / `agentsGroupBy` persisted UI preferences, wired through the same seams as `agentsCompactMode`: shared type, default, strict client RPC schema, pairing-local field census, web read pin, store contract/actions, and hydration normalizers that reject unknown values. Both consumers now read the store, so the sidebar and the Activity page share one filter the way they already share compact mode. * refactor: centralize thread filter value domains Establish filter and groupby value domains as the single source of truth, with types derived from them to prevent drift between valid values and their normalizers. Extract common validation logic into a shared isMember helper to keep the two normalization functions in sync. * refactor: centralize thread filter value domains Consolidate filter value definitions in agents-view-thread-filters and use them in Zod schema validation to ensure consistent, persistent serialization of filter state. |
||
|
|
510305e574 |
fix(relay): signal capacity loss instead of dropping, hanging, or truncating (#17870)
Three failures with one shape: a payload past a fixed capacity was met with silence, with a wait that never ends, or with a prefix presented as a whole. **The workspace snapshot was silently dropped.** `workspace.changed` carries the tab/session list, and a snapshot past the producer frame capacity (12288 B on a Node <=21 remote) was dropped with only a relay stderr line, so the client kept a stale list forever. The relay now publishes per client and, for a client whose sink refused the frame, sends a compact `workspace.stale` marker on the control lane; the client re-reads through `workspace.get`, whose lane is budgeted in megabytes rather than in one producer frame. A new JSON-RPC notification rather than a new field on `workspace.changed`: `normalizeSnapshot(undefined, ns)` yields revision 0 and an empty session, so a Rule-1 field would make an old client replace its tab list with nothing — worse than the drop. An old client ignores the unknown method and is exactly where it is today. The marker retention/retry machinery is extracted from the `fs.changed` overflow path and shared by both. **The Windows upload hung, and the fix for it could truncate.** `#16432` was attributed to `[Console]::In.ReadToEnd()` materializing the base64 bundle. That is not what the reporter measured: he also measured `new IO.StreamReader([Console]::OpenStandardInput())` — an incremental reader — hanging at 1 MB. The limit is in the stdin the host hands PowerShell over a non-pty ssh exec, not in the string the script builds. - `uploadFileViaSystemSsh` — the user file-import path — was piping a whole file into one Windows stdin, unchunked and untimed. That is the path large files take; it now chunks into 32 KB writes and bounds each wait. - The Windows directory upload reuses that single-file path rather than repeating a weaker copy of chunk-read + write-buffer; the `ino`/`dev` TOCTOU verification comes with it. - A Windows write needing more than one exec lands on a `.orca-partial` staging path and is published by rename, so a failed chunk cannot leave a truncated artifact under the real name. `exclusive` is enforced once at the rename, not on the first chunk, where a retry met its own leftovers. - The mkdir batch reads stdin through the stream reader the reporter measured surviving 50 KB, not `[Console]::In`, which he measured wedging at that size. - `waitForChannelClose` takes an optional bound. A wedged PowerShell stays alive at idle CPU and never closes, so without one the promise is simply never settled and the caller waits forever with no error to show. **Quick Open showed a prefix as the whole workspace.** The mechanism "a full page means there is more" only works if the caller named the cap, and the failing UI named none — it hardcoded `truncated: false`. Quick Open now names `QUICK_OPEN_LISTING_MAX_RESULTS` on both the Electron IPC hop and the runtime-RPC hop (the field #17954 added to `files.listAll`), and reads a full page as truncation. The local hop honours the cap too, which it previously ignored. Rebase note on `fs.listFiles`: an earlier revision of this work also clamped the host unconditionally, and #17934 escalated an uncapped request to an explicit error. #17954 has since landed and made an oversized reply streamable, which removes the premise — the host no longer has to choose between a prefix and a refusal, so it returns the whole listing when no limit is named and only clamps a limit it was given. Keeping either would have regressed #17954 and hard-failed three in-tree callers that deliberately pass no options (`runtime-file-commands-search-runtime-files.ts:81`, `filesystem-read-handlers.ts:125`, `runtime-file-commands-constructor.ts:41`). |
||
|
|
7f8eb90ac3 |
Align worktree host labels across desktop and mobile (#18237)
* refactor: align worktree host labels across clients * fix(mobile): expose safe host display labels * fix(mobile): preserve legacy mixed-host labels --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
278f9ee876 |
fix(ssh): answer every MFA stage, stop dialling an unclaimed alias, and say where a clone failed (#17946)
* fix(ssh): answer every MFA stage, not just the first ssh2 walks one flat auth-method list exactly once, so keyboard-interactive could only ever be offered a single time. A host running `AuthenticationMethods keyboard-interactive,keyboard-interactive` (or any ladder ending in a second challenge) partial-succeeds the first stage and then finds the list exhausted, which the user sees as "All configured authentication methods failed" — the reports in #8622 and #16820. Orca's own auth handler now runs for every target instead of only multi-key ones, and rebuilds its queue on each SSH_MSG_USERAUTH_FAILURE that carries partial success, narrowed to the methods the host still offers. Narrowing also stops keys being re-offered after the host has moved past publickey, which is what exhausts MaxAuthTries before the challenge is ever shown. Covered by a real ssh2 server fixture that stages partial success. * fix(git): say where a failing clone ran and why nothing could prompt Clones go through nonInteractiveGitEnv, so `ssh` runs with BatchMode=yes and an emptied SSH_ASKPASS. On a remote or paired-runtime clone that produces `fatal: Could not read from remote repository.` while the same `git clone` typed by hand on that box succeeds — the divergence in #14533. Nothing in the message said the clone ran on the other machine, under its keys, with the prompt deliberately disabled. getGitCloneFailureMessage now appends that fact, and names the two recognisable shapes: a publickey refusal (load the key into an agent there) and a host-key failure (record the key in that machine's known_hosts). Unrecognised SSH failures still get the where-it-ran note; non-SSH failures are untouched. One builder, so the SSH-target relay path and the runtime path both get it. * fix(ssh): stop dialling a bare alias no ssh_config block claims A wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so shouldUseSystemSshTransport picks the system transport for an alias whose own Host block was renamed or deleted, and buildSshArgs then dials that alias verbatim: no -l, no -p, no Hostname. Orca connects as the wildcard's user to the wildcard's host and discards the endpoint it stored (#11746). The signal #11746 assumed (hostBlockMatch, from the still-open #11707) does not exist, and `ssh -G` cannot supply it — it prints the merged config and answers for unknown aliases too. The config file is the only source of truth, so: - parseSshConfigAliasClaims retains raw Host patterns and flags Match blocks, which parseSshConfig discards because it mints importable targets. - sshConfigMayClaimAlias is sound in the negative direction only: an unreadable file, any Match block, or any non-catch-all pattern that might match all answer "claimed", so absence of evidence is never read as evidence of absence. Only a proven-unclaimed alias licenses an override. - buildSshArgs then states Hostname/Port/User, and only those: the wildcard is still the route, and -o Hostname does not change block selection, so the proxy keeps applying and %h expands to the host we mean. The verdict is injected rather than read inside buildSshArgs, so an arg builder does not answer differently per machine. Default is today's behaviour. Scoped to the system-SSH transport and the connection's own command/transport path. Port-forward processes and the ssh2 transport (#11707) are unchanged. * fix(ssh): read a negated Host group as uncertainty, and gate clone SSH guidance `Host * !prod` applies to every alias but `prod`, yet skipping both the catch-all and the `!` pattern answered "unclaimed" for `stage` — which licences overriding Hostname/Port/User against a block the user wrote. Any negation now makes the whole group uncertain; the function is only sound in the negative direction. Also require an ssh(1) diagnostic beside "could not read from remote repository" before appending the SSH clone note: git prints that same line for the HTTP remote helper, where advice about keys and agents is simply wrong. * fix(i18n): restore the activity-options key the rebase dropped * fix(i18n): union en.json with main so the rebase cannot drop keys |
||
|
|
7104056984 |
fix(watcher): route relay watch-root capacity refusals off the fast ladder (#17950)
* fix(ssh): stop two unrecoverable relay refusal loops A relay refusal that is a pure function of state the client cannot change was being retried forever, on two different paths. - pty.openClient: a superseded owner proof is refuted evidence, not a transient fault. The client kept re-presenting the identical proof, so every reconnect reproduced the same refusal until the relay was redeployed (#12895, #12931). It is now dropped exactly as a stale lease already is, and the claim re-asked without it. - fs.watch: the relay's watch-root capacity refusal was classified 'unavailable' and retried at 1 Hz per root for 60s, re-armed indefinitely. A folder workspace with more repos than the cap turns that into a permanent install storm scaled by the excess root count (#11196). It is now its own 'capacity' result that goes straight to the existing dormant backoff, mirroring what the local watcher path already does. * fix(watcher): route relay watch-root capacity refusals off the fast ladder A full watch-root cap is a decision, not a fault, so a 1 Hz reinstall per refused root only bills the relay the load that keeps the cap busy (#11196). Capacity refusals now go straight to the dormant backoff. The relay side no longer refuses on a slot it is about to hand back: an over-cap caused by roots still unsubscribing waits once on the teardowns settling — the release event, mirroring WatcherSupervisorCapacityWait — before it answers. A parked waiter is excluded from the accounting so it cannot take a slot from the root already reclaiming one. Drops the SSH owner-recovery half of this branch. Its premise — that a -32043 SUPERSEDED refusal is permanent — is false: the refusal fires only while the incumbent is 'active', and assertPtyConsumerOwnerRecovery explicitly admits the identical lower-generation proof once the incumbent flips to 'disconnected' (relay-pty-consumer-owner-displacement.test.ts proves it). The remedy could not work either: the proofless re-ask routes into refuseHeldPtyConsumerOwner, which is declared `: never` and, with sameClient true by construction, always throws. It would have traded one refusal loop for another, minus the checkpoints and minus the proof that resumes the claim once the relay reaps the incumbent. * fix(i18n): restore the activity-options key the rebase dropped * fix(i18n): union en.json with main so the rebase cannot drop keys |
||
|
|
31007c0d86 |
fix(ssh): reclaim relay PTYs the client has provably lost, on host attestation only (#17831)
* fix(ssh): reclaim relay PTYs the host attests this client orphaned (#9819) Orca could lose track of terminals running on an SSH relay until the 50-slot cap refused to open any more. This reclaims them, and the whole design is built around the fact that getting it wrong destroys a user's running process on their remote machine: the failure mode is leak, never kill. A stop requires all nine of: 1. the relay published an `ownerClientInstanceId` read from the live authenticated consumer grant of the connection that requested the spawn — never from a spawn parameter, since an echoed claim is no evidence; absent means skip 2. that id equals this client's persisted consumer identity 3. this connection holds the negotiated `session-owner` grant 4. `paneBound === true`, host-published 5. no `agentSessionOwners` — the host still advertises it as adoptable 6. `hostAgeMs >= 30s`, measured on the host's clock 7. this client has no route: not reattached, no lease outside terminated/expired, no pending kill, and no `expired` lease either — an expired lease is the record of a process deliberately left running, never a licence to kill it 8. every stop is fenced on the incarnation the same listing published, and on the owner identity, both re-checked by the host 9. a pass wanting to stop more than 8 refuses entirely Absence from a client-side set is `unverifiable` by construction (docs/reference/ssh-execution-boundary.md): a second machine attaches to the same relay and displaces the session owner, and its live agents are missing from this client's store for exactly the reason a genuine orphan is. So the host has to attest ownership, and the host has to attest that nothing is running. That second attestation is measured over the pane's whole tty, not its foreground process group. `tpgid == pgid` is foreground-only: on a real `bash -i` on a real pty, a shell holding `sleep 300 &` and a shell holding a Ctrl-Z'd job both read `pgid == tpgid`, `Ss+` — byte-identical to an idle prompt, with only the job's own row differing. A foreground-only gate therefore attests `pnpm build &` and a suspended editor as idle, and the stop that follows SIGKILLs every process group on the tty. `shellOwnsEveryTtyProcessGroup` is measured over that same set of groups, so the evidence and the kill describe the same thing. No new probe: `tpgid` already identifies the terminal, because a process group belongs to one session and a session to at most one controlling terminal. The freshness field is real rather than decorative. `capturedAgeMs` is stamped from when the capture was taken, deliberately as an upper bound since the process table is TTL-shared, and the sweep refuses an observation older than its own pass budget, counting its own elapsed time since the listing arrived. Stale evidence degrades to "do not sweep", never to "sweep". The display consumer of the same measurement keeps no age budget, as a stated decision: a stale pane title costs a redraw and self-corrects. `pty.shutdown` is authorized on the host that owns the process. `pty.spawn` and `pty.attach` both take a request context and check it; the one irreversible call took none, so the rule above lived entirely on the client that decided to make the call. It gains an optional `expectedOwnerClientInstanceId` and refuses unless the connection still authenticates as that identity AND this host recorded it at spawn. Finally, a reattach refusal now says whether it observed the process. Three refusals carry the same `SSH_SESSION_EXPIRED` text and only one is absence; `restoreRequired` means the PTY is live and only its source stream is not. Testing that text with `.includes()` expired the lease and deleted ownership for a running process, erasing this client's only record of it — and a PTY with no record is one the sweep may stop. Wire compatibility: four new optional fields and one new optional param on existing methods, no new method and no new stream opcode (Rule 1, and Rule 2 does not apply). Rule 1's caveat is discharged explicitly — no reader requires any of them, each absence is a named skip reason, and an ordinary pane teardown must omit the owner fence because a revived PTY carries no attested owner at all. New client plus old relay stops zero PTYs; old client plus new relay never reads the fields. Windows relay hosts publish no evidence and therefore never sweep. Verified by joining the real publisher to the real client reader over `ps` captured verbatim from a Linux container, and by driving a real group-for-group SIGKILL against a real pty: backgrounded and suspended jobs survive by pid, and an idle shell is still reclaimed, so the narrowed predicate is not a silent no-op. Squashed deliberately. The sweep is unsafe at every intermediate commit of its own history — before the foreground gate it reaps a hand-launched `claude`, and with a foreground-only gate it reaps a backgrounded build — so this ships as one commit with no bisectable state that kills live work. Refs #9819. Folds in #17939. * fix(i18n): restore the activity-options key the rebase dropped * fix(i18n): union en.json with main so the rebase cannot drop keys |
||
|
|
fb48a9771b |
fix(gh): reap the whole gh/glab process tree at the deadline on POSIX (#18258)
`gh` and `glab` on PATH are routinely shims — mise, asdf, volta, or a hand-written wrapper — so a timed-out invocation has a chain to stop, not one process. `execFileCapture`'s POSIX kill path signals only the direct child; the descendants are orphaned to init and keep running. #18234 is exactly that shape: `bash ~/.local/bin/gh` -> `mise x gh` -> `gh`, where the reporter found the tail reparented to `systemd --user` and still at 100% CPU nearly two hours later. The 15s deadline #18239 added bounds Orca's semaphore slot and its promise; it does not bound the CPU burn. Route both CLIs through `execFileCaptureToTermination`, the primitive git's barrier path already uses: POSIX children spawn `detached`, the deadline signals `-pgid` and escalates to SIGKILL, and the promise waits for verified termination. Windows behaviour is unchanged (`taskkill /t` either way). Switching primitives also swapped execFile's hard maxBuffer failure for `runProcess`'s silent clipping, which would have turned an oversized gh response into a shorter valid-looking one. `ProcessResult` now reports truncation and the capture rejects on it, restoring the old contract and closing the same latent gap on git's barrier path. |
||
|
|
817827be5b |
perf(renderer): drop react-markdown and the emoji catalog off the boot path (#18149)
The sidebar pulled react-markdown, remark/rehype and DOMPurify onto the eager module graph through two static importers -- WorktreeCardMeta's hover-card notes and DashboardAgentRowMessage's inline agent preview -- and built a 3,979-key emoji shortcode catalog at module scope in both the renderer and the main process. Neither is needed before first paint. Route both markdown surfaces through one shared lazyWithRetry boundary that preloads on pointer-enter (250ms hover open delay) and on agent-row mount, with a same-box raw-text Suspense fallback so a pre-load paint cannot shift layout. Memoize the emoji catalog behind loadCatalog() so import costs nothing. Eager renderer JS: 5,569,446 B / 331 chunks -> 5,198,787 B / 325 chunks (-370,659 B, -6.7%). Emoji catalog module eval: ~19.6 ms median -> 0 ms, paid once on renderer boot and once on main boot. |
||
|
|
0886db2b90 |
refactor(process-table): extract the correlation indexes into their own module (#18246)
`src/shared/process-table-snapshot.ts` is 308 code lines against the 300 cap for `**/*.ts`, so `static analysis` is red on `main` and every open PR inherits it. Neither PR that grew the file crossed the cap alone. #18151 took it to 427 raw lines; #18166 added ~35 more. #18166's branch predated #18151, so the head CI linted was 428 raw lines and passed, while the squash onto main is 463 -> 308 code lines. The gate lints the PR head, not the merge result, so nothing linted the sum until it was on main. Pure move, no behaviour change: the generic index machinery (ProcessIdentityRow, ProcessTableIndexOf, buildProcessTableIndex, collectDescendantsFromIndex, lookupProcessTableIndex, getProcessTableIndex and its WeakMap) moves to process-table-index.ts. `ProcessTableIndex` and `scoreForegroundCandidateRow` stay behind because they need `ProcessTableRow`, which keeps the new module free of any import back and so introduces no cycle. |
||
|
|
623d58e386 |
fix(native-chat): show pasted images while they save, and make them previewable (#18118)
* fix(native-chat): show pasted images while they save, and make them previewable Pasting an image into the native chat composer showed nothing until the clipboard image finished being written to disk, and the resulting chip could never render the image at all. Preview was blocked by path authorization, not by rendering. Clipboard pastes are written to the OS temp dir, which sits outside every allowed root, so the composer's own `fs:readFile` of the file Orca had just written was denied. `saveClipboardImageBufferAsTempFile` now authorizes the path it writes, the same way other Orca-produced external files are handled. The delay is the macOS paste route: Cmd+V is intercepted in main and delivered through the app-menu paste channel, which has no clipboard blob in hand, so the composer only learned an image existed after the save round-trip. A new `clipboard:readImageThumbnail` probe reads the clipboard in memory and returns a downscaled preview; it runs alongside the save rather than before it, so text paste gains no latency. The DOM-paste route needs no probe — it mints a blob URL from the clipboard file on the same tick. Attachments now carry `pending` and `previewUrl`: the chip appears immediately with the real image dimmed under a spinner, then settles in place on the saved path. Send is blocked while anything is pending, because a pending chip has no agent-readable path yet. Pending chips are kept out of the pane attachment cache so a mid-save unmount cannot strand one, and blob previews are revoked on remove/clear. SSH pastes now carry their connectionId onto the chip so remote previews read over SFTP. Verified in a real Codex native chat under an isolated dev instance: the chip appears in 42-61ms with a spinner, settles at ~141ms, three rapid pastes produce three independent chips with Send disabled throughout, and the lightbox opens the full 5120x2880 image read from disk. Ablation confirms the authorization fix: the written path reads back, an unauthorized sibling in the same temp dir does not. Claude-Session: https://claude.ai/code/session_01NnEfY8NpfFtVnboLKnmgdW * fix(native-chat): avoid stale image attachments and preview cache growth --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
c2fce80289 |
Fix agent dashboard setting configure (#18245)
* Make agents activity always-on; toggle via bell icon - Remove optional showAgentsSidebar setting - Replace sidebar view-toggle with bell-button for activity access - Agents activity now always accessible in sidebar - Preserve migration flag for introduction to existing users - Remove visibility inference utilities * Simplify sidebar when agents view active: hide workspace options, add to - Hide workspace options menu and add project button when agents view is active, reducing UI clutter in that mode - Add tooltip to the activity bell button for better discoverability - Localize sidebar search field text - Move search and filter toggles to local state in SidebarAgentsList, removing unused callbacks from thread list components - Manage search input focus properly when opening |
||
|
|
e3de6b2ce8 |
Add automation runs dashboard with pagination and filtering (#18226)
* Add automation runs dashboard with pagination and filtering Adds a new Runs view in the Automations page that lets users browse all runs across automations with status/host filtering, search, and pagination support. Includes virtualized table rendering for efficient handling of large run histories and summary cards showing 24h/7d success/failure counts. * Fix missing dependencies in useCallback hooks and imports Missing dependencies in useCallback can cause stale closure bugs. This adds missing state setters to dependency arrays and consolidates type imports for consistency. * Use keyset pagination for stable automation runs pages Pagination now uses createdAt:id boundaries instead of offsets, so new runs arriving between pages don't shift the window. Maintains backwards compatibility with legacy offset cursors. Move pagination to shared module, fix outcome counting for future-dated runs, and improve hook state tracking on authority re-pairing or target changes. * Extract automation run details to top-level page view Moves run display from detail pane to dedicated page, establishing three-level navigation (Automations → Runs → Run Details) and simplifying the detail pane component. * Fix pagination stability when automation runs share createdAt - Define a stable total order with createdAt and id tiebreaker to prevent runs tied on createdAt from being dropped when the boundary run is pruned between page requests - Retain cursor on failed pagination so pages remain retryable - Update ownerNotice type to AutomationActionNotice * Extract automations list panel and worktree map logic Split AutomationsPageSurface into smaller, focused modules for better maintainability and reusability. Move list panel UI rendering to AutomationsPageListPanel component and worktree map selection logic to a standalone utility function. * Add i18n strings for automation runs dashboard Adds localized strings for the automation runs dashboard view, including search, filtering by host and status, run counts for 24h/7d windows, and empty state messaging across all supported languages. * fix missing translation * fix missing translation |
||
|
|
6b1cbe54a1 |
fix(process-table): fail a short ps capture loudly, and stop a resume spending 49 of them (#18166)
The POSIX process-table capture ran `execFile('ps', ...)` with no `maxBuffer`,
inheriting Node's 1MB default. Measured at 1,460 processes the capture is 326KB
with a 5,116-char longest row — ~3x headroom, which a busy host clears.
Two separate defects follow, fixed here:
1. `parseProcessTableRows` drops unparseable lines, so any short capture reads
as a COMPLETE table whose missing processes simply are not running. Verified:
a capture cut at 4KB parses to 59 of 1,463 rows, and an empty capture parses
to `[]`, both with no error — and `resolveAgentForegroundProcessWithAvailability`
then answers `available: true`. That is the `unverifiable` -> `exited` collapse
the execution boundary forbids. The capture now rejects with
`ProcessTableCaptureError` on a ceiling-length or row-less capture, so both the
lenient and strict views fail loudly and callers report unavailable.
2. `maxBuffer` is now an explicit 32MB, matching the sibling reader in
`pty-descendant-termination.ts` and its stated reasoning. Without it a 4,000-
process host fails EVERY capture, degrading the whole subsystem permanently.
Separately, `readStructuredTuiProcessIdentity` polled a fresh whole-machine `ps`
every 50ms for up to 5s. Each capture costs ~0.065 CPU-s, and the 5s ceiling is
only reached when the child never appears — where the tight interval buys
nothing. The interval now holds at 50ms for the first second, then doubles to a
500ms cap. Identification latency is unchanged for any child appearing inside
that window, and the 5s ceiling is unchanged.
|
||
|
|
53f105827b |
perf(windows): stop asking the process table for memory, and share one projection per snapshot (#18151)
Two costs on the Windows process-table hot path, plus the EDR doc that described neither of them accurately. 1. The snapshot set `ProcessDataFlag.Memory` and surfaced `memoryBytes`, which nothing read. The addon serves that flag with a second `OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` and a `GetProcessMemoryInfo` per process (process.cc:47-63), so the flag was one wasted handle per process per snapshot. 2. The shared TTL cache gave every pane the same native rows array, but each pane still ran `native.map(toProcessRow)` over the whole table, rebuilt a `childrenByPpid` Map from scratch, and did two linear scans. The `.map()` also handed `getProcessTableIndex` a new array each call, defeating the POSIX memo by construction. Both now cache per snapshot identity, and the POSIX resolver drops its duplicate descendant walk. `getProcessTableIndex` / `buildProcessTableIndex` are generic over the row shape so the Windows rows reuse the existing pass instead of a parallel one. No behavior change: same rows in, same rows out, same descendant ordering and same has-children answers. |
||
|
|
084dbbc3b3 |
perf(persistence): build the state file once per save instead of seven times (#18161)
Every debounced save stringified the full persisted state, then ran two `String.replace` passes per secret sentinel — one for the on-disk payload, one for the guard hash. Each replace returns a rope the next one has to flatten before it can search, so three sentinels cost seven flattened copies of a 4.65 MB state (a two-byte V8 string, ~8.9 MB each), and the state was then UTF-8 encoded twice more: once inside `sha1.update(string)` and again inside `handle.writeFile(payload, 'utf-8')`. `applySecretSentinelSubstitutions` walks the state once with a single alternation regex, encodes each literal run to a Buffer exactly once, and feeds those same buffers to both the payload and the hash. Measured on the author's 4.65 MB store with three live secret slots: 48.8 MB -> 17.9 MB allocated per save, 26.6 MB -> 0 of large_object_space churn, and 22.1 -> 15.1 ms (min) / 32.3 -> 16.9 ms (median) for build+hash+encode. Bytes on disk and the guard hash are proven identical to the previous loop. Separately, non-local host session partitions carried stale replicas of the `browserUrlHistory` global — 589,807 bytes, 12.7% of the file — that neither the split (which writes globals only to 'local') nor the merge (which reads them only from 'local' unless local has none) can ever reach. The load path now drops them when the local slice already holds the field. Only the two history globals are dropped: the rest are read out of every partition by the worktree ownership sweep or the mobile/runtime projections. |
||
|
|
c0e5b189aa |
perf(sidebar,terminal): memoize terminal-title agent classification and lineage projections (#18148)
Idle-app CPU profiling showed `titleHasAgentName` running 11,771x/sec and the legacy any-agent regex 4,399x/sec, roughly once per zustand subscriber notify. The regexes were already precompiled; the problem was call volume — every store write re-classified every unchanged pane title through the whole agent-name ladder. Every title classifier is pure in the title string, so memoize them on it (bounded FIFO, 1024 entries). A new title is a new key, so there is no staleness window. The same profile showed the sidebar lineage projection re-scanning all worktrees several times per pass; cache it on the identity pair of its two immutable inputs, mirroring store/worktree-repo-index.ts. |
||
|
|
f03043544a |
perf(keybindings): stop recomputing shortcut labels on every render (#18145)
Shortcut labels were rebuilt from scratch in the render body of every component that shows one, which kept parseKeybinding running ~120x/sec in a fully idle app. - Cache the label layer per overrides object (WeakMap), so a keybinding edit hands out a new object and therefore a fresh cache. - Memoize parseKeybinding behind a bounded cache; binding strings come from a fixed definition set plus user overrides. - Hoist the per-call token/label object literals in normalizeKeyToken and formatKeyToken to module constants. |
||
|
|
104f9655e4 |
perf(git): answer remote-URL questions from one subprocess, not one per remote (#18158)
Four copies of the same loop ran `git remote` and then a serial `git remote get-url <name>` per remote to answer "which remote has this URL". On a repo with 58 remotes that is 59 subprocesses -- measured at 1083 ms -- for one question, and worktree create asks it several times. `git remote -v` answers for every remote from one child, reporting the same insteadOf-expanded first fetch URL `get-url` prints. The batched `cat-file --batch-check` branch-conflict probe decides from stdout, but its WSL route was unfenced, so a login-shell fallback printed the distro banner onto the stream it parses. That broke the one-line-per-ref contract, made every batch undecided, and fell straight back to one `show-ref` per remote -- the cost the batch exists to remove. Measured at 58 remotes / 4346 branches, spawns and wall time: push-target remote scan 59 -> 1 (1083 ms -> 8 ms) branch-conflict probe 60 -> 3 (984 ms -> 43 ms) configured push target 123 -> 6 (2707 ms -> 157 ms) |
||
|
|
1d94ebee3f |
fix(agents): stop a deeper vendor helper from stealing a pane's agent identity (#18062)
* fix(agents): keep outer agent identity over vendor helpers * fix(agents): preserve outer identity across relay scans --------- Co-authored-by: Merge Sim <sim@local> |
||
|
|
61e010079f |
New agent dashboard (#18222)
* more obvious toggle
* more obvious toggle
* feat(activity): redesign thread rows and add child agent filtering
- Emphasize task title and last activity in row layout over metadata
- Add child agent toggle; hide orchestration workers by default
- Support collapsible groups and ungrouped view mode
- Improve orchestration worker message handling to surface replies
- Add sidebar search and filter controls for agent activity
* periodic checkin
* feat(activity): add "Clear completed" action and performance improvement
- Add "Clear completed" action for activity threads with undo window; clears completed and interrupted rows from view, persists across restart
- Virtualize activity thread list to render only viewport-bounded rows
- Cache activity thread search text to prevent recomputation on every keystroke
- Cache dashboard bucket counts per-worktree for selective invalidation on unrelated changes
- Use useDeferredValue for activity search filtering to keep input responsive
- Make compact mode the default display for activity threads
- Add activity-cleared-at persisted state tracking (per-pane cutoff timestamps)
* improve style
* minor change
* feat(activity): add persisted host and project filters to agents view
Agents scope filters are deliberately separate from workspace-nav filters so a monitoring surface never inherits workspace context silently. Filters survive restarts and always display an active-filter chips row with hidden count, making filtering visible and reversible.
* Graduate Agents view from experimental, refine activity handling
- Agents Dashboard moves from experimental to standard feature with showAgentsSidebar setting controlling visibility
- Add identity-checked cache eviction (dropPersisted IPC) to prevent newer runs from being evicted when UI clears older status, fixing clear-completed safety
- Extract ActivityThreadHoverCardSummary and ActivityThreadListToolbar components for better organization and reusability
- Implement mark-thread-read as separate action from select with clickable bell icon
- Add hasActivityThreadWorkspace helper for checking workspace availability across hosts (SSH/runtime targets)
- Preserve scope filter array identity during hydration for memo optimization
- Track manually-unread turns in auto-ack to prevent re-acknowledgement
- Clean up activity cleared-at cutoffs on pane retirement
- Remove activity-thread-hover-card max-lines lint override (code refactored below threshold)
* Refactor agent cache identity to use timing fields only
- Simplify AgentStatusCacheIdentity: keep only paneKey, receivedAt, stateStartedAt
- This fixes silent no-ops where renderer-enriched fields diverged from main's cache
- Add worktree-jump-navigation for navigating activity to workspaces
- Add manual mark-unread protection separate from auto-ack
- Optimize activity owner resolution with per-build memoization
- Optimize detected worktree lookup with indexed search
* Remove sticky header, add scroll position persistence
Replace the floating sticky header overlay with scroll position memory via
a ref. This preserves the user's scroll location when switching between
threads or remounting the agents list, improving UX without requiring
React state.
* Implement sticky group headers in activity thread list
Keep group headers visible at the top while scrolling when threads are grouped. Headers stick to the viewport while their section is in view, then unstick as the next header approaches.
* add blue flash
* update settings appearnce
* Extracted activity acknowledgement/clearance actions from the oversized UI slice.
- Removed dead sidebar search/menu props and the unused search ref.
- Removed the unnecessary sidebar visibility bitmask.
- Replaced hardcoded sidebar toggle colors with design-system tokens.
- Removed duplicate “mark all read / clear completed” controls in the sidebar.
- Preserved manual-unread state correctly across pane retire, transfer, and drop.
- Made clear-completed cutoffs monotonic so clock skew cannot resurrect old activity.
- Fixed blank workspace names in hover cards with the existing fallback helper.
- Added missing localization entries and stabilized hydrated filter array identity.
- Updated misleading Agents setting copy to describe both sidebar surfaces.
* add onboarding guide for the new agents panel
* Add activity clearance tracking and synced agent view settings
Agent view filters and presentation settings now sync across paired clients.
Preserves per-pane activity clearance cutoffs in persistent state. Improves
activity thread row accessibility with proper ARIA roles, and preserves
terminal host ownership after pane teardown via retained terminal handle.
* rm html
* Graduate Agents from experimental and improve activity visibility
- Migrate `showAgentsSidebar` setting from legacy experimental flags; default new profiles to the agents sidebar
- Replace scoped-thread filtering with visible-thread filtering so bulk actions (mark all read, clear completed) only affect rendered rows
- Rewrite child agent classification as a set of visible pane keys to fix orphan promotion and parent-cycle handling
- Improve activity cleared-at cutoff lifecycle: preserve on row dismissal (pane may still be live) but clear on pane removal
- Add pagehide flush for pending clear-completed evictions so quit/reload cannot replay cleared activity
- Polish agents sidebar: unread count badge, expand button, onboarding intro for migrated/new users
- Extract shared time-ago formatting to a library module
- Fix scroll restoration to defer until content can contain the saved offset
- Improve stable message hold for compact agent rows using state instead of refs
- Add worktree filter-visibility check to distinguish collapsed-but-unfiltered from filtered-hidden
* Graduate Agents from experimental and improve activity visibility
- Remove the deprecated full-page Agents view; fix settings navigation fallback
- Refactor bulk action bindings and separate mark-all-read from visible threads
- Preserve sidebar collapse state across remounts; fix child-agent badge filtering
- Add safety window for scroll-restore and improve worktree host-qualified filtering
* Graduate Agents from experimental and add manual unread tracking
- Move Agents sidebar from experimental settings to standard feature with intro flow
- Add persistent manual unread turn tracking for activity feed
- Consolidate workspace activation through activateAndRevealWorkspace dispatcher
- Improve sidebar view toggle with radio semantics and arrow-key navigation
* Graduate Agents sidebar and separate dashboard experiment
The Agents tab now has its own `showAgentsSidebar` setting (defaults on) independent from the dashboard popout experiment. Activity unread counting is simplified to count all events uniformly without mode-specific filtering. Dashboard visibility is now controlled solely by `experimentalAgentDashboardPopout`, with its own UI in the Experimental settings pane. Migration path updated: only `experimentalActivity=true` graduates to the sidebar; the dashboard experiment remains separate.
* Add agent-session tab support to activity tracking
Build activity event contexts from structured agent-session tabs and
worktree-attributed status entries. When activating a thread, try
agent-session tab activation before falling back to terminal pane.
* • The workspace sidebar tab is now a static Spaces
label—no grouping-based “Projects” label or hidden
width-reservation span.
* Show unread count badge and prioritize attention-needing agent threads
Activity group order now surfaces threads needing attention (blocked,
waiting, interrupted) before working/done so they're never buried. The
Agents tab shows an unread count badge while viewing Spaces, since the
open Agents list already highlights unread rows.
Also improves UX text ("Hide Agents" vs "Maybe later"), accessibility
with proper ARIA labels, and handles edge cases: preserves read state
for retained panes on SSH reconnect and handles deleted worktrees
gracefully in navigation.
* Batch agent-status evictions and optimize activity pane rebuilds
- Add dropPersistedStatusEntries batch API; consolidate evictions into one persist
- Implement fallback timeout in clear-completed for unseen toast callbacks
- Project only activity-relevant tabs; memoize terminal tab derivations
- Stabilize activity virtualizer key to prevent unnecessary item measurements
* Remove unread count badge from Agents sidebar tab
Simplify useActivityUnreadCount by removing the enabled parameter and
conditional logic, as the badge is no longer displayed in the UI.
* Deduplicate activity unread counts across source overlaps
Live pane status is the primary source; retained and migration entries
serve as fallback caches that may briefly overlap it during lifecycle
transitions. Count each pane only once by tracking seen keys, prioritizing
the live status as the canonical source.
Also fix monitoring state display: it's a distinct agent state, not a
tool-running row state, so exclude it from tool preview checks.
* Update activity pane tests to remove unread badge assertions
- Remove ActivityPaneVisibility type and readActivityPaneVisibility() helper
- Update agentsSidebarButton selector to match badge-less state
- Simplify assertions to check pane focus instead of visibility isolation
- Remove test for unread badge acknowledgement flow
* Fix activity pane workspace resolution and localization handling
- Thread defaultHostId through activity operations for correct host resolution
- Add language-aware caching for standalone terminal names with cache invalidation
- Fix scroll restoration bounds calculation for tall viewports
- Add focus management to sidebar radio group keyboard navigation
- Refresh localized sidebar content on language changes
- Preserve activity state across heartbeats to prevent history loss
- Improve host-id strictness in worktree jump navigation
* Preserve activity view when settings fetch fails
A failed window.api.settings.get() leaves settings null, which was
incorrectly treated as opt-out. Add the missing null check so the
activity-view gate only applies when settings are available.
Includes tests for this scenario and related edge cases in keyboard
navigation, worktree jumping, and session state handling.
|
||
|
|
8dc3c1dd97 |
Display favicons for browser website entries (#18099)
* Display favicons for browser website entries Capture favicons from pages as they load and persist them with browser history entries. Display favicons in tabs, tab creation search results, and palette searches to improve visual recognition of websites and help users identify pages at a glance. * Fix favicon retry on back navigation after load failure Reset the favicon failure cache when the favicon URL changes, enabling retry of a previously failed favicon when navigating back to the same URL. Distinguish between explicit null (clear cached favicon) and omitted (don't update history), so stale favicons don't persist incorrectly. |
||
|
|
f37d2fec97 |
fix(linux): land the reviewed Linux packaging stack on main (#18100)
* fix(linux): give the CLI one entrypoint by extracting the AppImage once
* refactor(linux): trim AppImage CLI registration seams
* test(cli): assert registration lock serialization
* fix(linux): fence AppImage terminal shim mounts
* fix(linux): accept extracted AppImage runtimes with APPDIR only
* docs(linux): make headless AppImage extraction runnable
* refactor(linux): import bundled launcher directly
* fix(linux): reclaim superseded AppImage payloads and packaged symlinks
Pruning removed 3215 of 3216 files from a superseded generation and always
stranded resources/app.asar, leaking ~105 MB per version update. Electron's
asar shim reports a *.asar file as a directory, so the recursive remove tried
to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it.
Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and
519M again once the payload is actually reclaimed.
removeExtractedAppImagePayload holds process.noAsar for the removal, counted
so overlapping removals cannot hand the shim back early, and the prune site
now warns with the path instead of swallowing the rejection. All three
removal sites use it -- staging cleanup and displaced roots leaked the same
way.
Also reclaim symlinks left by a packaged deb/rpm install, which the
extracted-cache-only rule turned into a hard conflict on a deb -> AppImage
migration, and name the remedy in the conflict error.
* fix(linux): bound the CLI registration lock wait
`retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per
attempt an IPC-driven registration could hang ~16 minutes against a wedged
holder with no feedback.
A legitimate holder is bounded by the extraction timeout, so wait that plus
slack and then fail with a message naming the lock file, rather than hanging.
`maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile.
* fix(linux): stop re-extracting the AppImage on inode metadata churn
The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime.
ctime moves on any inode metadata write -- `chmod +x`, which every AppImage
user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup
restore -- none of which alter a byte of the payload.
Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime
identical and moves ctime alone, so the key changed and the next launch paid
a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it
already had, then pruned the old generation.
Key on content identity instead. An in-place content change moves mtime and
almost always size; a replacement moves the inode. The existing
replace-in-place test still passes.
* fix(linux): stop CLI commands from falling through to Chromium startup
* refactor(cli): remove redundant command membership check
* test(cli): cover command-named project selectors
* fix(cli): redirect the open-url command before startup
* test(linux): cover AUR serve wrapper flags
* fix(linux): tighten CLI launch detection
* fix(linux): respect CLI flag value boundaries
* fix(linux): strip injected Chromium switches from CLI args
* fix(linux): report a missing display instead of dying in uv_close
* refactor(linux): read display locks without a preflight race
* fix(linux): preserve unverified external displays
* chore: format reliability gate manifest
* test(packaging): split runtime resource checks
* fix(linux): fail serve when no display is available
* fix(linux): do not treat a lockless X socket as a dead display
An X server writes its lock beside its socket and both survive a crash
(verified against Xvfb under SIGKILL), so a socket with no lock was never
left by a crashed server. It is an endpoint published from elsewhere: a
container bind-mounting only /tmp/.X11-unix, WSLg, or a foreign PID
namespace. Declaring those dead made the desktop gate exit(1) on displays
that work, with no workaround, and the serve gate refuse to start.
Liveness now splits by ownership. A foreign DISPLAY trusts a lockless
socket; Orca's own :99 does not, because removeStaleDisplayArtifacts
unlinks the lock before the socket and so manufactures that state itself --
adopting it would resurrect the orphan-socket bug and stop the cleanup from
self-healing. The stale-lock rejection is unchanged.
Also correct four doc statements this behaviour falsified.
* fix(linux): fail closed when a stale socket blocks the Xvfb rebind
Readiness only checked that /tmp/.X11-unix/X99 exists. A stale socket we
could not unlink still exists after our own Xvfb refused to bind, so Orca set
DISPLAY to a dead server and Chromium died in Ozone init.
Measured on Ubuntu 24.04 against the pre-fix build: with a leftover :99
socket and no lock, serve exits 139 (SIGSEGV), the socket inode is unchanged
before and after, and no lock is recreated -- it neither cleaned up nor
respawned. To a user that is a crash, not a misconfiguration.
This is reachable in the documented topology, where orca-xvfb.service has no
User= and runs as root while serve runs as User=orca: /tmp is sticky, so the
orca uid cannot unlink a root-owned socket, rmSync fails, and Xvfb exits with
the display already active.
Readiness now requires the display to actually be live -- our socket plus a
lock naming a running process -- so the same state reports an unusable
display and exits 1 with the existing diagnosis.
* fix(linux): recognise abstract X sockets and inherited Wayland fds
Two display setups this gate could not prove were refused outright, and on the
desktop path that is app.exit(1) with no workaround.
An X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which
leaves no filesystem socket to stat. Abstract addresses are kernel-owned and
vanish the moment the owner exits, so an entry in /proc/net/unix is proof of a
live server -- no lock file needed and no stale entry possible. Verified on
Ubuntu 24.04, where 139 such addresses were present.
WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so
there is no path to stat and WAYLAND_DISPLAY may be unset entirely. Its
presence is the display.
Both are consulted only after the filesystem-socket check fails, so no
existing verdict changes.
* fix(linux): never treat Orca's own display number as a foreign endpoint
Recognising a lockless X socket as live is correct for an endpoint published
from elsewhere -- a container bind mount, WSLg -- because an X server writes
its lock beside its socket and both survive a crash. It is wrong for
VIRTUAL_DISPLAY_NUMBER, because Orca's own teardown unlinks the lock before
the socket and so manufactures that exact state.
The managed branch was already strict, but a caller that sets DISPLAY=:99
explicitly takes the foreign path and skipped it, accepting a dead display
left by Orca's own interrupted cleanup. Route the managed number through the
strict probe on both paths.
Found by an adversarial audit of the asymmetry introduced earlier in this
branch; the documented systemd topology is unaffected because its Xvfb writes
a real lock.
* test(linux): add a packaged-artifact contract for the CLI launch paths
* test(linux): avoid buffered serve readiness detection
* test(linux): signal AppImage serve owner directly
* test(linux): tolerate readiness timeout boundary
* test(linux): add startup margin to shutdown oracle
* ci(linux): give package contracts timeout headroom
* fix(ci): route all Linux packaging contract changes
* test(linux): poll shutdown readiness without tail leaks
* test(linux): bound shutdown cleanup grace
* test(linux): assert on CLI output, not the harness's own control lines
run-cli-case.sh echoes `RESULT status=N case=<name>`, and the two cases named
*-skills asserted `expectOutput: 'skills'`. That substring was satisfied by
the case name in the harness's own line, so 2 of 8 cases asserted nothing
about the command -- gutting `skills` entirely would still have gone green.
Control lines are now excluded before matching, and both cases assert the
rendered help header, which only real help output produces. Verified on an
Ubuntu 24.04 host: 8/8 still pass against a stack-tip AppImage.
Also register the gate in reliability-gates.jsonc, which #15085 added a CI
Docker gate without. Red/green is recorded from a stock release AppImage
failing 4 of 8, three of them at status 133 (SIGTRAP).
* fix(linux): require static AppImage runtimes (#17319)
* test(linux): reject a wrong-architecture native binary at packaging time
Cross-building the arm64 slice on an x64 host silently packed an x86-64
`pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and
shipped the host's binary anyway. Every gate here inspects symbol versions,
which are perfectly valid on the wrong architecture, so nothing noticed.
Observed on a Raspberry Pi 5: the packaged app loaded, then failed with
"Failed to load native module: pty.node", and the launch contract reported
3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64
`pty.node` took the same build to 8/8.
Compare ELF `e_machine` against the slice being packaged and fail with the
offending path. Checked before the glibc pass, because a wrong-architecture
binary's symbol versions are valid but meaningless and would send the reader
down the wrong path.
Release CI builds arm64 on a native runner, so this guards local and future
cross-builds rather than a shipped artifact.
* test(linux): judge per-arch vendored binaries against their own path
The first CI run of the architecture gate failed the x64 package job on
`@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on
purpose: the package ships every architecture and its loader picks the match,
so its presence in an x64 build is correct.
Judge a binary against the architecture its own path names, falling back to
the slice when the path names none. That keeps the case this gate exists for
-- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what
shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through.
Dry-run over the real dependency tree flags nothing for either target arch.
* fix(linux): move deb/rpm update installation outside Orca (#17318)
* fix(linux): complete deb/rpm package metadata
* fix(linux): preserve CLI link during package upgrades
* docs(linux): document local RPM build prerequisites
* fix(linux): move deb/rpm update installation outside Orca
* fix(updater): preserve Linux recovery across stale events
* fix(updater): fence stale downloaded events by active target
* fix(updater): preserve active Linux package recovery
* test(linux): keep workflow order assertion in scope
* test(updater): assert stale recovery stays silent
* fix(updater): preserve Linux package recovery after checks
* refactor(updater): keep Linux marker message with status
* fix(linux): describe the right manual update path for deb/rpm hosts
A remote host installed from .deb or .rpm now reports
manual-service-update-required, and the guidance told the operator to
"update through the service manager that starts this server" -- which is
correct for unsupported-headless-serve but wrong for a package install,
where nothing about the remedy involves the service manager.
Say both, keyed on how the host was installed.
* docs(linux): document orcad update restart safety
* docs(linux): scope restart census omissions
* docs(linux): use absolute service CLI launcher
* fix(serve): validate in-process serve options before startup (#17683)
* fix(linux): stop offering updates a distro-managed install cannot apply (#17918)
Closes #17702.
The resources/package-type marker is authoritative but never checked against
the host, so any repackager that unpacks Orca's .deb -- AUR, Nix, a container
rebuild -- inherits `deb` verbatim. Install feasibility was then computed
after a ~165 MB download, so those users got check -> download -> a card
promising an install command -> a dead end.
Validate the marker against the host: a deb/rpm marker with no matching
package manager in the trusted directories means a package manager owns this
install. This reuses the exact lists and resolver that
buildLinuxPackageInstallCommand already loops over, so a false positive is
impossible by construction -- any host flagged here would have failed with
no-package-manager after the download anyway. The gate only moves that
verdict earlier. Verified across Debian 12, Ubuntu 24.04, Arch, Fedora 40 and
openSUSE Leap: no false positive on a real deb host, correct on every
repackaging host.
The release is still reported, because the user does want to know 1.4.194
exists and to update through their distro; only the download path is closed.
`externallyManaged` is an additive optional field on the existing `available`
status, so older paired clients decode it unchanged. downloadUpdate() refuses
authoritatively, since main owns this verdict rather than the card, and
unwinds any pinned-build state first -- a Linux pinned jump resolves to
'release', and stranding isPinnedBuildActive would silently kill every
background check for the rest of the process.
Note the fix the issue suggests cannot work: electron-updater builds a
PacmanUpdater whose doDownloadUpdate looks for a .pacman asset Orca does not
publish, then dereferences undefined.
* style(cli): restore prettier wrapping on install error copy
* test(linux): re-pin the child-process ratchets and the batch-shim allowlist after the merge
|
||
|
|
aa3ae6f56e |
fix(ssh): close the pty master fd leak on relay hosts too (#17920)
* fix(ssh): close the pty master fd leak on Linux relay hosts The app gets the FD_CLOEXEC patch through pnpm patchedDependencies (#17914); the relay installs stock node-pty from npm, where no pnpm patch reaches. Linux is where that matters -- it is the only relay platform that takes forkpty()'s no-atomic-O_CLOEXEC path, and it is also the only one that already compiles node-pty at install time, so the fix costs a second compile rather than a first. Ships the patch as a relay asset applied like the existing Windows console-list one, and rebuilds only after the probe has proven node-pty loadable. The rebuild is non-fatal by construction: the working build is moved aside first and moved back on any failure, a failed attempt drops a skip marker so the compile is attempted at most once per relay directory, and the caller swallows the whole step. macOS and Windows relays never run it. Measured on node:22 with a relay-style npm install: before, the master is cloexec=false and shows up as `26 -> /dev/pts/ptmx` in both a later pty child and a later child_process child; after, cloexec=true and neither child sees it. Closes #17915. * test(ssh): feed the cloexec patch exec to the hand-rolled namespace fixtures These sequences are positional, so the new Linux-only patch exec swallowed the READY slot and every install/repair case timed out waiting for the relay. * fix(ssh): patch the pty master before publishing the shared native-deps tree * fix(ssh): refuse to publish a native-deps tree whose cloexec patch did not take |
||
|
|
34999e328e |
fix(orcad): stop demanding a spawn-helper only macOS builds (#18122)
node-pty declares the spawn-helper target inside binding.gyp's OS=="mac" block and pty.cc execs it only under __APPLE__. Asserting it on `!== 'win32'` made every Linux orcad boot degraded with spawn_helper_missing while its terminals worked fine. Route all four sites through one shared `usesNodePtySpawnHelper` predicate: the precondition verdict, the prebuilt slot install, the +x repair, and the prebuilds build script (which threw outright on a Linux slot build). Fixes #17844 |
||
|
|
5dc1195a47 |
fix(native-chat): keep disabled CLI models out of the Claude picker (#18055)
* fix(native-chat): keep disabled CLI models out of the Claude picker
The Claude CLI advertises models it cannot run yet as disabled placeholder
rows. On 2.1.237 `list_models` returns a sixth row alongside the four real
models:
{"value":"cc-update-required-1","displayName":"Fable 5.1 (disabled)",
"description":"Update to 2.1.255+ to use Fable 5.1","disabled":true}
`toListedModel` never read `disabled`, and for Claude the discovered list
replaces the seed catalog verbatim, so the picker rendered that row as a
selectable model and `/model cc-update-required-1` went to the CLI. It was
also adoptable as a launch default, putting the sentinel behind `--model`
on spawn. Drop disabled rows at the parse choke point, which both the
native-chat picker and commit-message model discovery share.
The two adjacent fixes are the same version-pinning bug the placeholder
announces. `compactTerminalText` strips only whitespace, so a point release
keeps its dot and the pinned consent literals (`fable5uses…`,
`switchtofable5?`) stop matching a "Fable 5.1" prompt — the switch would
degrade to `unknown` instead of `interaction-required`. Likewise the scoped
weekly usage window matched `display_name === 'fable'` exactly, so it would
disappear once the scope is named "Fable 5.1".
Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4
* fix(native-chat): make the Fable consent match version-optional
Probing a 2.1.258 CLI shows the shipped Fable 5.1 row carries displayName
"Fable" with the version only in the description:
{"value":"claude-fable-5-1[1m]","resolvedModel":"claude-fable-5-1",
"displayName":"Fable","description":"Fable 5.1 · Most capable for …"}
So the consent prompt may name the model with no digits at all. Requiring
a version would have missed that, the same way the old pinned literal
missed "Fable 5.1". Accept both.
Claude-Session: https://claude.ai/code/session_01SJy4XGrdre6YaU1wYNKak4
---------
Co-authored-by: Merge Sim <sim@local>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
|
||
|
|
99d9111653 |
fix(relay): fail an over-budget RPC response, not the connection (#17968)
The relay's control lane is a shared 1 MiB budget, and `sendResponse` admitted responses onto it with the fatal default: once the lane was full, admission closed the client. A ~900 KB `fs.listFiles` reply from a large remote workspace therefore took down the whole remote session -- every terminal on it -- rather than failing the one Quick Open request. The substitute `ResponseOverCapacity` frame already there only covered the `legacy-response` lane, because the fatal close beat it to the client. A JSON-RPC response is the droppable class of control frame: it carries an id, so one caller can be told and can retry. `pty.replay` and `notifyControl` keep the fatal default -- they are never re-sent, and a silent drop there desyncs the client with nothing to retry. Both response enqueues now pass `controlOverflow: 'reject'`, so the substitute error is what the caller sees; in the corner where even ~150 bytes will not fit, the caller's own 30s request timeout settles it and the session survives. Old clients are unaffected: they already decode this error code and message generically (`ssh-channel-multiplexer.handleResponse` rejects the pending promise with both), and the frame shape is unchanged. What changes is that a listing which used to drop the connection now returns an error on it. |
||
|
|
75dcda438f |
feat(editor): add Show Whitespace toggle option in diff viewer (#15120)
* feat(editor): add Show Whitespace toggle option in diff viewer - Add `diffShowWhitespace` boolean option to `GlobalSettings` (defaults to false). - Pass `ignoreTrimWhitespace: !diffShowWhitespace` to Monaco DiffEditor options in `DiffViewer`. - Expose "Show Whitespace" checkbox in `EditorPanelMarkdownActionsMenu` for diff surfaces. - Add unit tests for diff whitespace action menu binding in `EditorPanelMarkdownActionsMenu.test.tsx`. * fix(editor): apply Show Whitespace to combined diffs Honor the persisted preference in DiffSectionBody as well as DiffViewer, add a combined-diff toolbar control, and extract a shared Monaco option helper with settings UI and unit tests. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> |
||
|
|
3924b7276f |
fix(automations): report an unverifiable process loss as lost, not failed (#17967)
* docs(exit-cause): pin why isProvenProcessExit(0) must stay true
isProvenProcessExit asks whether the process ended; the cause resolvers ask
why. Their disagreement on 0 is the design, not a defect: login(1) wraps
every macOS local PTY once the TCC preflight passes, so routing
hostReportsChildExitStatus through the predicate would leave every pane a
user closed with `exit` mounted forever.
No behavior change; comment and regression tests only.
* fix(automations): report an unverifiable process loss as lost, not failed
Two automation readers consumed the raw PTY exit code with no liveness
check, so the -1 unverified sentinel — on SSH, a live relay whose reattach
failed — was published as status 'dispatch_failed' with "Automation process
exited with code -1." The run was asserted finished when all that happened
was that we lost contact.
Route both through the existing vocabulary:
- The completion tracker records no result for an unproven code. The run
keeps its non-final 'dispatched' status, so it is never evicted and never
shown as Failed, and stays owned by main's AutomationRunCompletionWatcher,
which already reports a genuinely unobservable run truthfully ("lost the
terminal for this run") rather than inventing an exit code. finalize() is
never reached, so a terminal whose process cannot be proven dead is never
closed. A later done can still complete the run.
- Both runtime `terminal.wait` readers defaulted an absent status to 0,
minting a clean finish out of no evidence. They now share
runtimeWaitExitCode, which defaults to the new UNVERIFIED_PROCESS_EXIT_CODE.
- The background-session exit handler no longer clears the tab-PTY binding
on an unverified loss, matching pty-exit-hibernate.ts, and marks the tab
so orphan cleanup cannot sweep an agent that may still be running.
A proven exit is unchanged: 0 still completes and finalizes, and a real
nonzero failure still reports dispatch_failed.
|
||
|
|
0c9c3c00cf |
test(ci): ratchet Windows-gated tests into both registration lists (#18047)
* test(ci): ratchet Windows-gated tests into both registration lists
PR CI has one windows-2022 job running a curated explicit file list. Every
other job runs on ubuntu, where a Windows-gated suite self-skips and reports
success -- so an unregistered Windows-gated file executes on no machine and
passes green with nothing to tell the author.
Scans every test file for the win32 suite-level gate spellings in use plus the
.win32.test.* filename, and asserts each one appears in BOTH the
"Test Windows-specific boundaries" vitest argv and WINDOWS_PACKAGE_TESTS: the
classifier decides whether the job runs, the argv decides whether the file
runs. The eight already-unregistered files on main are held in a shrink-only
debt list.
* fix(ci): detect compound win32 gates in the lane-registration ratchet
The gate matcher anchored its argument on the closing paren, so
`runIf(platform === 'win32' && hasAddon)` was not matched at all -- the
guard excluded real Windows-gated files by accident of a regex rather
than by design, and would have missed a compound gate on a file that
genuinely needed registering.
Match the condition followed by `)` or `&&`, and resolve named flags from
their assignment in the same file, so `RUN_REAL = platform === 'win32' &&
env…` used as `runIf(RUN_REAL)` is detected whatever the flag is called
and whichever polarity it was written in. That replaces the hardcoded
`isWindows`/`IS_WINDOWS`/`isWin32` names, which guessed polarity from a
name; an imported flag stays undetected and is now documented with the
live example. `||` compounds are rejected on purpose: they can run off
Windows.
Ten env-opt-in suites surface as a result. They are win32-gated but also
require an `ORCA_REAL_*` env var, so registering them would not make CI
run them; they go in MANUAL_OPT_IN, whose entries are asserted to be
genuinely compound and env-gated so the list cannot become a quiet
parking spot.
Also: reuse `scanSourceTree` instead of a fifth divergent walk in the
repo (its docblock records the incident where a hand-rolled walk scanned
`tests/e2e/.cross-version-checkouts/`), adding an `extensions` option so
it can see `.mjs`; strip comments so prose about a gate is not a gate;
skip `mobile/`, which `classifyPrJobs` can never report as registered;
assert exactly one `windows-2022` job, the premise the guard rests on;
cap growth of both grandfathered lists; and test that the self-exemption
covers nothing but this file.
Corrects two docblock claims that were false: that nothing in the repo
computes a gate indirectly (three files did), and that a compound gate's
registration was asserted while only its execution was not (neither was).
* fix(ci): make the manual-opt-in exemption prove the env read reaches the gate
`requiresEnvOptIn` proved the file MENTIONED an env var, not that the gate
DEPENDED on one, so `runIf(platform === 'win32' && hasAddon)` in a file
that happens to read `process.env.RUNNER_TEMP` parked as manual. That is
the native-addon-bytes shape -- a test CI could run -- and only the cap
number stood in the way. Now the win32 check must be compound and one of
its other conjuncts must read `process.env` itself or name a const that
does, which still accepts all ten listed suites.
The compound clause guarding that hole was itself unasserted: deleting it
left every test green. Two fixtures close it, including an env read on the
same line as a bare gate, which is the case that makes the `&&` do work
rather than decorate.
Split FLAG_ASSIGNMENT by polarity. One shared `&&` lookahead was right for
`===` (a second conjunct narrows) and wrong for `!==` (it widens), so
`p = platform !== 'win32' && x` used as `skipIf(p)` read as Windows-only
though it runs on Windows and on POSIX when `x` is false. The literal form
was already rejected; routing it through a flag flipped the answer.
Widen the one-lane assertion from a `windows-2022` equality test to any
`runs-on` that could land on Windows -- `windows-latest`, a label array, a
`{ group, labels }` object -- treating an unresolvable `${{ }}` expression
as Windows so it fails closed.
Docblock: the case-level count is now deliberately approximate. The
reviewer measures 26 against this guard's 31; the figure moves with which
gate spellings are counted, and the policy does not rest on it.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
|
||
|
|
f9587f74f5 |
fix(ssh): repair a rebuildable node-pty failure once, instead of asking the user to reconnect (#17907)
* fix(relay): diagnose why node-pty will not load instead of hedging The relay could only say "terminals are unavailable" and then list three remedies for four different faults, none of which the user could verify (#17830). Two things were destroying the evidence: - `loadPtyUncached` caught the load error into bare `catch {}` blocks (pty-handler.ts:539, :551) and returned null. The only cause anyone had was discarded on the spot. - node-pty's own loader walks three directories and rethrows only the LAST failure, so even an uncaught error arrives as `Cannot find module '../prebuilds/...'` — the GLIBC/ABI/arch sentence is already gone. The relay now keeps the load error, recovers the real dlopen message with an out-of-process load of the file node-pty would have opened, reads what node-gyp configured the binding for (`build/config.gypi`), captures the host's Node ABI, arch and glibc, and probes the toolchain only when nothing was compiled. Each fault gets its own message naming values the user can check: toolchain_missing, dependency_missing, abi_mismatch, arch_mismatch, libc_floor, shared_library_missing, load_crashed, and load_failed which quotes the loader verbatim. A probe that did not answer stays `unverifiable` and prescribes nothing. The classification is now also structured data on the error, so a client can repair the host instead of printing a paragraph: an additive, schema-validated `data` field on an existing JSON-RPC error, with `repairable` true only for a proved fault that recompiling on the host actually fixes. Reuses orcad's loader-message parsers and out-of-process probe rather than adding a second copy; `classifyLoaderMessage` moves to a shared module and gains architecture and missing-shared-library cases, which the orcad boot precondition picks up too. * fix(ssh): repair a rebuildable node-pty failure once, instead of asking the user to reconnect |
||
|
|
266b2ea190 |
fix(agent-status): report a stale pane that still holds a PTY as unverifiable, not idle (#18012)
* fix(remote): stop one unlabelled inventory tombstoning a live worktree mirror #11495 Step C. `buildMissingWebSessionTabsRemovals` synthesised a `removed: true` tombstone -- emptying a worktree's entire mirror -- for any tracked worktree absent from a single inventory frame, without ever consulting the host's own authority label. `mirror-settle` already refuses to settle an *empty* inventory that is not `authoritative` (#16414, #16546); the strictly more destructive action was ungated. An inventory the host labels `authoritative` carries a complete PTY census, so one omission is host attestation and removal stays immediate. An unlabelled inventory is a degraded or version-skewed census: `unverifiable`, not `exited`. It must now repeat before it can destroy anything, reusing the two-observation shape of `confirmSurfaceInventoryAbsence`. A legacy host that never negotiates the capability still converges after two rounds, so ghost rows cannot outlive the fence. The 14 tests from #13621 that blocked this were all written before the `authoritative` label existed (#13621 landed 2026-08-11; the capability landed 2026-08-26 in #16546). #13621's own summary says "Reconcile each resumed host from an authoritative inventory, including removals", so their fixtures are retargeted to say so explicitly rather than weakened. Refs #11495 * fix(agent-status): stop a reconnect replay restamping the staleness clock #15317 correctness half. `receivedAt` was doing two jobs: delivery order and evidence age. A relay reconnect replays every cached row, and `receivedAt` must restamp to clear the connection watermark that `clearStatusEntriesForConnection` raises -- so a pane stuck at `working` had its 30-minute deadline pushed out by another 30 minutes on every reconnect. The TTL was never reached, which is why this read as a tuning question. Two clocks, not one rewritten clock: - `receivedAt` is untouched. The transient-clear watermark and the four `<` ordering drops (`agent-status-event-applicator`, `agent-status-live-entry-builder`, `agent-status-cleanup-actions`) keep working unchanged. Restamping a replay with its original time would have made it `<= watermark` and dropped it outright, leaving the pane with no row at all. - `evidenceObservedAt` is new, optional, and read only by the staleness comparison (`isFreshNonDoneAgentStatus`, `isExplicitAgentStatusFresh`, the freshness scheduler). Main holds it per pane across the transport clear -- the clear deletes the row on purpose, but the *age* of evidence a later replay restates is not a claim about the pane. Absent means "no separate observation", and every consumer falls back to `receivedAt`/`updatedAt`, so old hosts and old rows behave exactly as today. Behaviour: a genuinely active pane keeps stamping the observation clock from its real events, so it stays `working` across a reconnect. A pane whose relay restarted replays nothing and still falls through to title evidence. A torn-down pane drops its remembered clock in `clearPaneState`, so a reused pane key cannot inherit one. `AGENT_STATUS_STALE_AFTER_MS` is deliberately unchanged -- the window length remains a product decision. Refs #15317 * fix(sidebar): stop a stale agent row claiming the pane is empty A stale non-`done` entry decayed to `idle` whether or not Orca still held the pane's PTY, so "we lost the reporting stream" and "nothing is running here" were the same display class. Split the destination on evidence already computed: with a live PTY the row is `unverifiable` and reports the observer's own fact — how long the silence has run — so the user can apply context Orca has no way to know. With no PTY it stays `idle`. Smart sort gains class 4 for it, between working (3) and idle (now 5): still plausibly the most important pane, never outranking one that is reporting, and never a claim that the agent finished. `unverifiable` stays renderer-local; the dashboard card projection publishes today's `idle` because that vocabulary is validated against a fixed allowlist in main and read by older pop-outs. AGENT_STATUS_STALE_AFTER_MS is unchanged. * fix(agent-status): decay a mirrored remote row on the replica's own clock A paired client mirrored a remote host's status rows verbatim, host wall clock included, and the staleness gate then computed `rendererNow - hostStamp`. The effective window was 30 minutes plus or minus the two machines' skew: a host running fast held every remote row permanently fresh, a host running slow decayed them on arrival. The constant was never the lever there — the subtraction straddled two clocks. The replica now stamps `mirroredEvidenceReceivedAt` from its own clock when the authority's observation advances, carries it forward across an exact repaint (a restated observation is not a new one), and decays against it. Both sides of the subtraction come from one machine; locally observed rows carry no stamp and are unchanged. The alternative the type comment named — carrying the authority's freshness verdict — was rejected: a verdict is computed at publish time and cannot age between snapshots, so once the host goes quiet the replica would hold `fresh` forever. That is precisely the loss-of-contact case the window exists for. AGENT_STATUS_STALE_AFTER_MS is unchanged; the clock rules move to agent-status-freshness.ts to keep agent-status-types.ts under its line budget. |
||
|
|
4e35e058fc |
fix(remote): stop unlabelled inventories and replayed rows authorising destruction (#17981)
* fix(remote): stop one unlabelled inventory tombstoning a live worktree mirror #11495 Step C. `buildMissingWebSessionTabsRemovals` synthesised a `removed: true` tombstone -- emptying a worktree's entire mirror -- for any tracked worktree absent from a single inventory frame, without ever consulting the host's own authority label. `mirror-settle` already refuses to settle an *empty* inventory that is not `authoritative` (#16414, #16546); the strictly more destructive action was ungated. An inventory the host labels `authoritative` carries a complete PTY census, so one omission is host attestation and removal stays immediate. An unlabelled inventory is a degraded or version-skewed census: `unverifiable`, not `exited`. It must now repeat before it can destroy anything, reusing the two-observation shape of `confirmSurfaceInventoryAbsence`. A legacy host that never negotiates the capability still converges after two rounds, so ghost rows cannot outlive the fence. The 14 tests from #13621 that blocked this were all written before the `authoritative` label existed (#13621 landed 2026-08-11; the capability landed 2026-08-26 in #16546). #13621's own summary says "Reconcile each resumed host from an authoritative inventory, including removals", so their fixtures are retargeted to say so explicitly rather than weakened. Refs #11495 * fix(agent-status): stop a reconnect replay restamping the staleness clock #15317 correctness half. `receivedAt` was doing two jobs: delivery order and evidence age. A relay reconnect replays every cached row, and `receivedAt` must restamp to clear the connection watermark that `clearStatusEntriesForConnection` raises -- so a pane stuck at `working` had its 30-minute deadline pushed out by another 30 minutes on every reconnect. The TTL was never reached, which is why this read as a tuning question. Two clocks, not one rewritten clock: - `receivedAt` is untouched. The transient-clear watermark and the four `<` ordering drops (`agent-status-event-applicator`, `agent-status-live-entry-builder`, `agent-status-cleanup-actions`) keep working unchanged. Restamping a replay with its original time would have made it `<= watermark` and dropped it outright, leaving the pane with no row at all. - `evidenceObservedAt` is new, optional, and read only by the staleness comparison (`isFreshNonDoneAgentStatus`, `isExplicitAgentStatusFresh`, the freshness scheduler). Main holds it per pane across the transport clear -- the clear deletes the row on purpose, but the *age* of evidence a later replay restates is not a claim about the pane. Absent means "no separate observation", and every consumer falls back to `receivedAt`/`updatedAt`, so old hosts and old rows behave exactly as today. Behaviour: a genuinely active pane keeps stamping the observation clock from its real events, so it stays `working` across a reconnect. A pane whose relay restarted replays nothing and still falls through to title evidence. A torn-down pane drops its remembered clock in `clearPaneState`, so a reused pane key cannot inherit one. `AGENT_STATUS_STALE_AFTER_MS` is deliberately unchanged -- the window length remains a product decision. Refs #15317 |
||
|
|
b552bcb91f |
fix(relay): diagnose why node-pty will not load instead of hedging (#17891)
The relay could only say "terminals are unavailable" and then list three remedies for four different faults, none of which the user could verify (#17830). Two things were destroying the evidence: - `loadPtyUncached` caught the load error into bare `catch {}` blocks (pty-handler.ts:539, :551) and returned null. The only cause anyone had was discarded on the spot. - node-pty's own loader walks three directories and rethrows only the LAST failure, so even an uncaught error arrives as `Cannot find module '../prebuilds/...'` — the GLIBC/ABI/arch sentence is already gone. The relay now keeps the load error, recovers the real dlopen message with an out-of-process load of the file node-pty would have opened, reads what node-gyp configured the binding for (`build/config.gypi`), captures the host's Node ABI, arch and glibc, and probes the toolchain only when nothing was compiled. Each fault gets its own message naming values the user can check: toolchain_missing, dependency_missing, abi_mismatch, arch_mismatch, libc_floor, shared_library_missing, load_crashed, and load_failed which quotes the loader verbatim. A probe that did not answer stays `unverifiable` and prescribes nothing. The classification is now also structured data on the error, so a client can repair the host instead of printing a paragraph: an additive, schema-validated `data` field on an existing JSON-RPC error, with `repairable` true only for a proved fault that recompiling on the host actually fixes. Reuses orcad's loader-message parsers and out-of-process probe rather than adding a second copy; `classifyLoaderMessage` moves to a shared module and gains architecture and missing-shared-library cases, which the orcad boot precondition picks up too. |
||
|
|
058e618bb4 |
fix(ssh): stop a failed worktree scan from publishing authoritative emptiness (#17833)
* fix(ssh): keep an unreadable worktree catalog from authorizing teardown #14004: the relay's worktree-list fallback caught every failure and returned `[]`, so `SshGitProvider.listWorktrees` resolved as a success with an empty list. Downstream reconciliation treats a resolved listing as authoritative, which reaches `teardownMissingWorktreeTerminalsBestEffort` and the unregistered-worktree removal paths — a data-loss path from a failed scan. - relay: the `-z`-unsupported fallback lane propagates its failure instead of swallowing it to `[]`. - provider: an empty or malformed `git.listWorktrees` response is refused as `WorktreeCatalogUnavailableError`. A Git repo always lists its own checkout, so a zero-row listing can only be a scan that never answered — this is the mixed-version guard against relays that still swallow. - `listRepoWorktrees`: an unreachable SSH host reports unavailable instead of an empty catalog. #12661: `ssh:terminateSessions` now returns `{ terminated, unverifiable }`, so an offline sweep that only tore down local transport cannot be mistaken for a remote kill. The Manage-hosts toast warns instead of claiming success. * chore(i18n): register the unreachable-terminal terminate message |
||
|
|
bed9734a9d |
Prevent deleted workspace browser snapshot resurrection (#17779)
* Prevent deleted workspace browser snapshot resurrection * fix: tear down folder workspace browser tabs * fix: fence pre-publication browser snapshots * fix: route folder deletion through runtime cleanup * chore: retrigger CI * fix: sweep folder PTYs on runtime deletion * fix: restore deletion fences after runtime refactor * test: cover deleted renderer snapshot after recreation * fix: avoid publishing ambiguous worktree snapshots * fix: preserve optional worktree index state * fix: fence paired PTYs on worktree removal * fix: harden deletion fence and folder-delete teardown - Folder-group delete no longer fails on a mixed-host group: an ambiguous connection skips the PTY sweep instead of rejecting the delete. - Share one folder-workspace PTY teardown helper between the runtime removal path and the project-group controller. - Simplify the mobile snapshot fence: identity-carrying frames are judged against the live catalog instanceId and clear the fence once the successor is accepted; identity-less frames are fenced by renderer generation. Drops the unbounded epoch bookkeeping. - A fenced frame no longer triggers a resync request on every sync while the renderer still lists it as unchanged. - Cross-host id collisions publish without an instanceId rather than blanking the mobile session for that workspace. - Folder delete IPC always routes through the runtime; the store-only fallback and double notify are gone. - Drop the redundant rescue-path tombstone check; ownership is purged at removal. - Fence tests drive removeWorktreeMetadataAndHistory + syncWindowGraph instead of seeding the fence map, and add accept-after-recreate, no-resync, and ambiguous-host folder delete cases. |
||
|
|
6c8eea5ebe | perf(worktree): fix the prepared-checkout hit rate and make misses visible (#17863) | ||
|
|
a7fda48fe3 |
feat(telemetry): measure macOS stale-daemon adoption and cwd denials (#18043)
* feat(telemetry): measure macOS stale-daemon adoption and cwd denials Adds two enum-only PostHog events so #17696 can be sized instead of guessed at: - daemon_adopted: once per macOS launch that keeps a daemon an earlier app launch forked (invisible to daemon_lifecycle, which only sees replacements). Carries app-version match, spawner-path class (installed app / Squirrel ShipIt cache / other / missing), the existing TCC attribution verdict, and the bucketed live-session count. - daemon_pty_cwd_denied: the symptom itself. The daemon probes the requested cwd in its own process (only its TCC context counts) and returns an additive cwdReadableByDaemon field; the app emits only when the daemon was denied AND the app can read the same path, so a missing or genuinely unreadable cwd never counts. Non-permission errors read as readable on purpose. Both emitters swallow every failure; nothing here can delay or fail daemon startup or a PTY spawn. Off macOS neither event fires. The new wire field is optional, so older daemons and clients are unaffected. * fix(telemetry): keep cwd-denial classification inside the swallow guard Read the pid record at emit time (inside the try) rather than passing the adapter's startup snapshot: a throwing app-environment read can no longer escape spawn(), and a denial after a respawn is billed to the daemon that actually spawned the PTY. |