mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
2c479aabd7a5d0a8aef3a704e4857541d329d567
14
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2c479aabd7 |
refactor(browser): share one retention budget and drop the reclaim sweep
Both hosts already bounded live browser renderers, but with two separate implementations: the desktop app evicted hidden worktrees' webview guests on a visibility change, and the headless page reclaimer re-evaluated residency every 15 seconds. The rule they were each expressing turned out to be the same one — keep the most recently used, never evict something in use, optionally shed what has gone stale — so it now lives in src/shared/browser-retention-budget.ts and each host supplies its own configuration and its own trigger. The desktop keeps its exact behaviour: rank-only, no clock (idleMs Infinity, graceMs 0), the active worktree left out of the budget entirely, and isEvictable still consulted lazily. Its 13 tests pass unchanged. Headless drops the recurring sweep. A repeating heartbeat was never needed: the limit can only be crossed when a page is created, woken or closed, and every one of those already re-arms. Only the idle window needs a clock, so the reclaimer now computes the earliest moment its own answer could change and arms one timer for it. A host with nothing resident holds no timer at all, and one pinned by a download or a streamed client re-asks on a bounded cadence because releasing those pins raises no event the backend can observe. Two guards fell out of building it. The deadline and the selector are separate functions, so a fuzz test asserts nothing can become evictable before its deadline — it catches an off-by-one in the rank branch. And a sweep that frees nothing now backs off, measured by observed residency rather than by trusting park(), which no-ops for a window that is already gone; without it a deadline that lands early re-arms at 0ms and spins. Headless semantics change in one case: a stale pinned page no longer forces fresher unpinned pages to be evicted in its place. That is the desktop rule, and the limit was already documented as exceedable by pinned work. Removes ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS, which no longer has a meaning. |
||
|
|
58e0960598 |
fix(browser): bound how long a wake can hold the request that triggered it
Design review found the one thing that made the "wake transparently" contract untrue in production. A wake awaited the full 30s page-load budget, but a wake happens inside browser.tabShow, which the paired client caps at 15s (remote-browser-page-session.ts, remote-browser-screencast-subscription.ts). So revealing a parked page whose site is slow reported the browser unreachable — the same "flaky remote" symptom the original ticket is about, reintroduced by the fix for it. The asymmetry was mine: createTab deliberately never waits for its load, and returns a tab that is operable and still navigating. A wake now does the same, with a budget comfortably inside the client's, and the load continues after it returns. Fast pages are still fully loaded when the wake resolves, which is what an agent expects; slow ones no longer stall the caller. Also drops the reclaimer's `close` dependency, dead since the retained-page close was removed. Reviewers were unanimous that park-and-wake is the right mechanism, the main process the right owner, and command activity the right signal; both other lenses returned no merge blocker. |
||
|
|
3d50a886c7 |
refactor(browser): match the desktop guest budget instead of inventing policy
Headless reclamation now has exactly the policy surface the desktop guest budget has, and nothing more. Dropped the retained-page limit and its outright close. Desktop destroys guests but never removes a tab, so closing a page past a count had no precedent here and was the one genuinely novel product decision in this change — it permanently destroyed a page id an agent still held. Parked records are ~200 bytes against the 220-480 MB renderers being reclaimed, so retaining them indefinitely is the right trade and matches what desktop already does. Dropped the download veto's progress window with it. Desktop treats an active download as work in progress for as long as it stays active, and headless now does the same. That accepts the same unbounded-veto shape desktop has: a download that never completes keeps its page resident. Deliberate, and the alternative was a threshold desktop does not have. What stays is the part with precedent: a resident cap of 4 (the desktop limit), LRU eviction, and the idle clock headless needs because it has no notion of a visible worktree. |
||
|
|
a1c9853e41 |
fix(browser): stop a stalled navigation from defeating the resident cap
Round four. Compatibility came back clean; two real findings remain, and the first is a regression the round-three fix introduced. Pinning a page while `webContents.isLoading()` was meant to protect a slow navigation past the load helper's timeout. But a navigation can stay running forever — a server that accepts the request and never finishes it — so that pin had no bound: one stalled page per create would hold a renderer indefinitely, which is exactly the failure this reclaimer exists to prevent. The pin is gone. `loading` still covers the load helper's own bounded window, so a page is never parked mid-create; past that timeout it is deliberately reclaimable, and waking retries the address. The test now asserts that directly, so the cap cannot be quietly re-broken. A wake also validated a page that had been closed and replaced underneath it: `createTab` waits for an in-flight release but not for an in-flight wake, so the stale wake resolved true and handed the original command a different page under the name it had asked for. The wake now checks record identity, not presence, after every await. Both mutation-checked. |
||
|
|
de4fef9c5a |
fix(browser): close the round-three parked-page reporting and reuse gaps
- Reusing a page id while its close was still tearing down let the new renderer register and the old close then unregister it, leaving a page the runtime owned but no command could resolve. A create now waits out an in-flight release for that id, which orders the whole reuse. - Closing a parked page told nobody: it has no WebContents teardown for the session-tabs publication to piggyback on, so a paired client kept showing a ghost tab until an operation against it failed. - A page waiting on a certificate decision was parkable. Its challenge id dies with the renderer, so parking discarded both the warning and the ability to approve it. Such a page is work waiting on an answer and stays resident. - The load helper resolves on its own 30s timeout, so `loading` stopped covering a navigation that was merely slow. The renderer is asked directly. - An iframe changing its own hash fires did-navigate-in-page too, and its address was being recorded as the tab's — waking would have landed the page on the iframe's document. Only main-frame navigations move the record. - `tab switch --index` reported the position the wake had renumbered the target to, which described neither the request nor the listing the caller read. Each mutation-checked. An ownership guard added alongside the reuse fix turned out to be unreachable once creates are ordered, so it is not here. |
||
|
|
2bba8e17f5 |
fix(browser): close the reclaim gaps three independent reviewers found
Three fresh gpt-5.6-luna reviewers took lifecycle, resources and compatibility. Two of their high findings were the ones the previous commit already fixed (session snapshot, worktree teardown), which is a useful cross-check. Four were real and new: - `tab switch --index` resolved the index *after* target resolution had already woken the most recently used parked page. Waking makes that page live, which moves it to the front of the listing, so index 0 selected a different tab than the caller had just read. Indices are now resolved before any wake. - An in-flight command was only pinned while the queue was executing it. `enqueueTargetedCommand` awaits `ensureSession` first, and that can spend up to EXEC_TIMEOUT_MS (90s) closing a stale helper session — far past the 30s grace — so a sweep could destroy the renderer mid-setup. A page is now claimed from target resolution through completion. - A renderer lost on its own never told the bridge, so a crash loop leaked one helper session, CDP proxy and listening port per page. It now routes through the same teardown as park and close, and stops the sweep when the last page goes. Reading `win.webContents` in that handler throws once the contents are destroyed, which wedged the whole main process — the id is captured up front. `CdpWsProxy.stop()` also closes its socket even when detaching the debugger throws, which is exactly the case a crashed renderer produces. - Parking a page mid-navigation snapshotted the uncommitted `about:blank` address over the URL the agent asked for, so the wake loaded the wrong page. A loading page is no longer parkable and an uncommitted address is ignored. Also bounds the last unbounded thing: parking caps renderer processes but not the page records behind them, so past a generous retention limit the oldest parked pages are closed outright and logged. Each fix has a regression test that was mutation-checked against the exact defect. The `tab switch` case is covered at the runtime layer rather than e2e because driving a real switch needs the agent-browser binary, which cannot run in the sandboxed e2e HOME. |
||
|
|
c7320525eb |
fix(browser): reclaim idle headless browser tab renderers (STA-4341)
On a headless `orca serve` host every agent-created browser page is backed by
its own hidden BrowserWindow, i.e. its own renderer process, held in a map that
only ever shrank on an explicit `tab close` or process exit. Agents open pages
per measurement and rarely close them, so a long session accumulated renderers
until the host saturated: a reporter measured 9 tabs / 6 renderers at 220-480MB
each, the oldest at 1.3GB after 15h, load 6.85 on 4 cores. A hidden guest also
runs with background throttling disabled, so an animated page kept painting
where nothing could see it.
Reproduced on a real serve process (tests/e2e/headless-serve-browser-tab-reclaim
.spec.ts): 6 tabs -> 6 renderers, still 6 after 20s fully idle, and 2405 rAF
frames in ~25s on a window that is never shown.
Give the offscreen backend a lifecycle owner. It now keeps the page identity an
agent holds — id, address, worktree, profile — and treats the renderer as a
reclaimable resource. An idle page is parked (renderer destroyed, record kept)
and woken by the next command that targets it. Eviction mirrors terminal
hidden-view parking: a resident cap of 4 is the primary evictor, a 5 minute idle
window is the tail, and a page a client streams or that has a command in flight
is never parked. `tab list` keeps listing parked pages so an agent does not read
a reclaimed tab as gone and open yet another one.
Two related defects fall out of the same teardown path. The headless close path
never notified the agent-browser bridge, so every closed page left its helper
session, CDP proxy and listening port behind; park and close now both route
through the bridge before the mapping and the window go away. And because the
listing now spans resident and parked pages, `--index` resolves against that
same listing, so `tab close --index` can no longer address a different tab than
the one the caller read.
Parking discards in-page JavaScript state; cookies and local storage live in the
profile partition and survive. The thresholds are settable via
ORCA_HEADLESS_BROWSER_{RESIDENT_LIMIT,PARK_IDLE_MS,PARK_GRACE_MS,PARK_SWEEP_MS}.
Desktop is untouched — it has no offscreen backend.
|
||
|
|
ff8dda81e8 |
fix(serve): exit cleanly after headless Linux signals (#14334)
* fix(serve): keep owned Xvfb alive through Electron teardown * test(serve): gate packaged signal shutdown * test: harden headless shutdown lifecycle gate * fix(serve): isolate Xvfb from foreground signals * docs(serve): preserve Xvfb during systemd stop * test(serve): pin shutdown policy to owned Xvfb unit * test(serve): harden shutdown gate portability * test(serve): bound systemd unit parsing |
||
|
|
d8e5944b60 |
Stop a duplicate headless orca serve from crash-looping and exhausting AppImage FUSE mounts (#12212)
* fix(startup): stop a duplicate headless serve from crash-looping and leaking AppImage mounts A second Orca launch that loses the single-instance lock called app.quit() before `ready`. That quit is deferred, so the doomed process kept booting into Chromium's Linux display initialization, failed with "Missing X server or $DISPLAY", and died with SIGSEGV. systemd read that as a crash and restarted it forever; each restart re-mounted the AppImage and left the squashfuse mount behind, until the host hit the 1000-mount FUSE ceiling and every later launch failed. The lock-losing launch now calls app.exit(3), which terminates synchronously before any display init. Exit code 3 is a stable "another process already owns this userData profile" contract, and the documented systemd unit uses RestartPreventExitStatus=3 plus a real StartLimitIntervalSec/StartLimitBurst window so a permanently failing launch can no longer retry unbounded. Second-instance argv is now forwarded to the owner, and a duplicate `orca serve` no longer asks the live headless server to open a desktop window. Desktop activation for ordinary launches and macOS dock re-activation is unchanged. Closes #11935 * docs(headless): clear the start limit before the scripted service starts StartLimitIntervalSec=300/StartLimitBurst=5 rate-limits operator starts too, so after a crash-loop trips the burst systemd refuses a plain `systemctl start` for the rest of the window. The Upgrade and Roll back scripts run under `set -euo pipefail`, so that refusal aborted the rollback mid-flight and left the server down on the exact recovery path the doc prescribes. Both scripts (and their EXIT-trap recoveries) now run `systemctl reset-failed` first, the unit reference explains the interaction, and the crash-loop bullet points at it for manual starts. Co-authored-by: Orca <help@stably.ai> * test(startup): reproduce the #11935 duplicate-serve crash loop under real Electron The committed coverage for #11935 was source-text greps, so nothing gated the mechanism the fix rests on: pre-`ready` `app.quit()` is deferred, which is why the lock-losing headless `orca serve` kept booting into Linux display init. This runs two real Electron processes against one disposable profile. The duplicate executes the lock-loss gate's own `app.*` statement, lifted out of `src/main/index.ts`, so reverting to `app.quit()` fails the test. It also feeds the owner's real forwarded argv through `shouldActivateDesktopForSecondInstance`. Also record why the activation predicate matches `--serve` and not the `serve` subcommand: an AppImage launched as `orca serve` exits at the CLI redirect before requesting the lock. * test(startup): wait for the owner process to exit before removing its profile Windows holds the profile's handles for a beat after SIGKILL, so an immediate rmSync can fail with EBUSY/EPERM. Co-authored-by: Orca <help@stably.ai> * test(startup): pass the fixture marker path by env, not argv Chromium reorders argv and the duplicate's argv is itself under test, so a trailing positional was the wrong channel for it. Co-authored-by: Orca <help@stably.ai> * test(startup): only the activation case waits on the owner notification The exit-contract cases assert on the duplicate's own already-terminated process, so they should not block on cross-process delivery. Co-authored-by: Orca <help@stably.ai> * test(startup): drop the staged lock race, keep the real-Electron gate contract CI proved the two-process form cannot work on a display-less Linux runner: Chromium's ProcessSingleton needs the browser IO thread, which needs `ready`, which needs a display. The pre-`ready` owner looked stale and the duplicate took the lock (`expected [ 'DUPLICATE_WON_LOCK' ] to include 'DUPLICATE_LOST_LOCK'`). Lock acquisition and argv forwarding are already covered in single-instance-lock.test.ts. What only a real process can settle is what the loser does next, so that is all this file now runs -- display-independent. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> |
||
|
|
676ef7fab8 |
feat(cli): add orca skills install and orca skills update for headless skill setup (#9201)
Adds `orca skills install` and `orca skills update` so skills can be set up without the GUI — SSH hosts, containers, CI. Previously `orca skills` had only `list` and `get`, so there was no headless path. **Agent targeting is scoped explicitly rather than delegated to detection.** The `skills` CLI decides which agents to install into, and with `-y` and zero detected agents it takes `targetAgents = validAgents` — all ~75. That is not a corner case for a headless CLI: a fresh SSH box or container with no agent installed is the normal starting state. Measured on a bare host, the unscoped command created **52 top-level agent directories and 54 junctions** (one real payload in `~/.agents/skills`, the rest links) on Windows, and 52/53 on macOS. The CLI now passes `--agent` derived from Orca's own detection, mapped to the `skills` key namespace, plus `universal`. Supplying `--agent` makes `runAdd` use it directly and never call `detectInstalledAgents()`, so the fan-out branch is unreachable. On a bare host it now refuses with `No coding agent detected on this host` and exit 1, creating nothing. Same command with scoping: **1 directory, 0 junctions.** `universal` alone would under-install — Claude Code is not in that set, and 19 of 28 mapped keys write agent-private homes `universal` never touches. `--agent '*'` is the bug itself. The mapping is hedged three ways: `null` for any agent whose key could not be confirmed, `satisfies Record<TuiAgent, …>` so a new Orca agent is a compile error, and a test pinning every mapped key against the CLI's own valid list. Fixed during review — two holes that each restored the full fan-out through a different door: - `--agent ','` trimmed to nothing, which skipped the refusal *and* emitted no `--agent`. - `--agent -y` passed an emptiness check, and the vendor CLI silently drops `-`-leading values, re-emptying its list. The real invariant is argument *shape*, not emptiness, and it is now enforced at the choke point in `buildAgentFeatureSkillInstallArgs`, so no caller can emit `-y` without a usable target. `*` remains allowed — asking for every agent explicitly is a choice, not an accident. Verified with 51 hostile inputs through the built binary, each recorded argv replayed through the vendor's own parser. Also fixed: the `ORCA_CLI_CWD` refusal now runs before target resolution (it was quoting the wrong host's agent list), and `--dry-run` is refused in a forwarded shell rather than printing a command naming the wrong machine. Validated on a real Windows host across PowerShell 7, PowerShell 5.1, cmd.exe and Git Bash: `.cmd` shims route through `cmd.exe` and `.exe` shims spawn directly (proved with instrumented shims, not inferred), the ENOENT path produces an actionable error rather than a silent failure, and `skills update` genuinely restores a corrupted skill byte-for-byte. Known, not addressed here — both upstream behaviours this only forwards: a partial install failure exits 0, and "no installed skills found" exits 0. Both are invisible to the headless callers this feature exists for. Co-authored-by: scastanoh21 <scastanoh21@gmail.com> |
||
|
|
a0944cc129 |
fix(linux): restore Ubuntu 20.04 launch — pin node-pty glibc symbols + add glibc/libstdc++ packaging gate (#9902) (#10019)
* fix(linux): restore Ubuntu 20.04 launch by pinning node-pty glibc symbols (#9902) The bundled node-pty pty.node is compiled from source in release CI on ubuntu-latest (glibc 2.39). glibc's 2.32-2.34 libpthread/libutil merge relocated openpty/forkpty (GLIBC_2.34) and pthread_sigmask (GLIBC_2.32) into libc under new symbol versions, so the from-source build bound to versions absent on Ubuntu 20.04 (glibc 2.31). The main process imports node-pty at startup, so the app crashed on launch. pty.node is the sole blocker (Electron needs GLIBC_2.25; other native modules <= 2.17). - Patch node-pty: a .symver shim pins the 3 symbols to their pre-merge version (GLIBC_2.2.5 x64 / GLIBC_2.17 arm64), and Linux-only ldflags force libutil.so.1/libpthread.so.0 back into DT_NEEDED. Guarded to Linux; macOS/Windows untouched. - Add a packaging gate (verify-linux-glibc-floor.cjs, afterPack): reads each bundled native binary's objdump -p version needs and fails the Linux build if any strong GLIBC_/GLIBCXX_/CXXABI_ node exceeds stock Ubuntu 20.04 (glibc 2.31 / GLIBCXX_3.4.28 / CXXABI_1.3.12). Catches GLIBC_ABI_DT_RELR, rejects GLIBC_PRIVATE, skips weak needs, fail-closed. - Docs + tests; the lazy sherpa-onnx speech prebuilt (GLIBCXX_3.4.29, never loaded at launch) is a documented libstdc++-floor exemption. * fix(linux): assert DT_NEEDED provider deps in the glibc-floor gate Harden the packaging gate (flagged in adversarial re-eval): the version-floor check alone can false-pass if the patch's forced `-l:libutil.so.1` ever silently drops — the pinned openpty@GLIBC_2.2.5 still resolves from libc's compat alias at build time, but fails to load on Ubuntu 20.04 where openpty/forkpty live only in libutil. The gate now also asserts that any binary importing openpty/forkpty keeps libutil.so.1 in DT_NEEDED. Validated on a real symver-pinned .so with libutil dropped (now fails) vs. present (passes). Documents the recommended real-host smoke-test follow-up. |
||
|
|
34c160442f | Fix headless Linux serve pairing readiness (#9785) | ||
|
|
aad34cbb32 |
docs(headless-server): add upgrade SOP for orca serve on Linux (#9575)
* docs(headless-server): add upgrade SOP for orca serve on Linux The headless Linux guide covered install/run/systemd but had no upgrade section, leaving operators to guess how to move to a new AppImage without losing state. Add an "Upgrade" section documenting the manual SOP (serve mode never auto-updates) and one troubleshooting bullet: - State lives under the service user's ~/.config (orca + Orca dirs), independent of /opt/orca, and orca-data.json is forward-migrated on load, so a forward upgrade is safe. - Replace the binary with an atomic same-filesystem rename (download to .new, verify, mv) — never curl -o over the FUSE-mounted live binary. - Back up the whole .config before upgrading, because rollback is NOT binary-only safe: an older build strips newer orca-data.json fields it doesn't recognize, and the .bak.* ring is corruption-recovery, not a pre-upgrade copy. - Note there is no headless version command; track the release tag instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(headless-server): harden the orca serve upgrade/rollback runbook Address CodeRabbit review on #9575: - Fail closed: run the upgrade block under `set -euo pipefail`, remove any stale `.new` file before download, and gate the atomic `mv` on an explicit ELF check so a failed/partial/non-ELF download can never be promoted. - Keep /opt/orca/VERSION tied to the installed binary: a single `TAG` variable drives both the download URL and the recorded VERSION, saved as VERSION.prev on upgrade and restored on rollback so the audit file never drifts. - Crash-loop troubleshooting now points to Roll back first (restores the pre-upgrade orca-data.json) instead of re-running Upgrade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(headless): harden server upgrade SOP --------- Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> |
||
|
|
9694830764 |
Document headless Linux server setup (#6802)
* docs: add headless Linux server guide * docs: add ldd/appimage-extract tip for diagnosing missing libraries Salvaged from #6817 before closing it as a duplicate. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo@stably.ai> Co-authored-by: Orca <help@stably.ai> |