Files
orca/docs/reference/orcad-operations.md
T
88f2f01061 fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY (#19430)
* fix(daemon): escape the terminal daemon into its own systemd scope so a service restart no longer kills every live PTY

Root cause: daemon-launched-child.ts forks the detached terminal daemon with
detached: true, which escapes the POSIX process group (setsid) but never the
systemd cgroup. Every PTY the daemon owns is itself an undetached direct
child of the daemon (native-pty-spawn.ts). Under a combined systemd unit
(Type=simple, KillMode=mixed, per docs/reference/headless-linux-server.md),
a systemctl restart/stop SIGKILLs every process still in the cgroup at the
stop timeout -- the daemon and every live terminal -- even though the
codebase already has a fully-built adoption/reattachment path for a
surviving daemon (orcad-entry.ts's refreshRestoredOrchestrationAuthority +
reconcileLegacyWorkerTerminals, gated on daemonOwnsFreshPersistentPtys()).
That path never fires today because the daemon never survives long enough.

Fix: when systemd is actually supervising the process and the OS user has a
reachable systemd --user manager (isDurableDaemonScopeSupported(), Linux
only), launch the daemon via systemd-run --user --scope so it lands in a
cgroup that is a sibling of the service unit's cgroup, not a descendant of
it. A systemctl restart of the combined unit then never reaches it. Any
failure of the scoped launch (no reachable bus, D-Bus policy rejection,
etc.) falls back transparently to the existing plain fork() launch, so
every platform/environment without this capability is unaffected.

The daemon self-detects its own resulting cgroup scope via /proc/self/cgroup
(detectOwnCgroupScopeUnit()) rather than trusting the launcher's intent, and
publishes it as cgroupUnit in its pid record and orcad's health/readiness
payload (health.terminalDaemon.cgroupUnit), so a running deployment can be
observed to confirm the fix actually engaged.

No new session registry is added: the existing daemon pid-record + adoption
protocol (publishDaemonPidFile, daemon-pid-record-quarantine.ts's
dead-record reclaim, refreshRestoredOrchestrationAuthority) already
implements durable, crash-safe reattachment for a surviving daemon -- it
was simply never exercised against a full unit restart before now.

Proven via a systemd-in-Docker recovery test: a live PTY session's shell
process, its daemon, and the daemon's cgroup scope were all confirmed
unchanged across a real systemctl restart of a Type=simple/KillMode=mixed
unit, while the main process pid changed (confirming the unit actually
restarted) and the new process's health payload recognized the surviving
daemon as adopted and live. A fresh write into the same PTY post-restart
reached the same running shell. Ordinary terminal create/work/release and
the #18789/#18790 worker-release reap-fix regression tests are unaffected.

Fixes stablyai/orca#19408

* fix(daemon): probe the real per-UID XDG_RUNTIME_DIR before trusting the process's own env

isDurableDaemonScopeSupported()/buildDurableDaemonScopeCommand() trusted the current
process's own XDG_RUNTIME_DIR env var first, falling back to /run/user/<uid> only when
that var was unset entirely. On mtl-02, orca-serve@factory.service's RuntimeDirectory=
hardening directive makes systemd export XDG_RUNTIME_DIR=/run/orca_serve/factory into the
unit's process -- a private scratch dir that shares the env var's name but has nothing to
do with the user session bus. /proc/<pid>/environ on that host confirmed exactly that path
plus DBUS_SESSION_BUS_ADDRESS=disabled:, while the real bus was reachable the whole time at
/run/user/985 (confirmed via systemctl --user is-system-running with that dir exported by
hand). The probe treated the hardened override as authoritative, found no bus socket there,
and reported unsupported on every launch -- so the cgroup-escape fix from #19408/#19430
never actually engaged on real hardware, even though tonight's factory deployment picked it
up.

Fix: resolveUserRuntimeDir() now always tries the conventional /run/user/<uid> path first
(computed independently via getuid(), never trusted from env), checking for a genuinely
connectable bus socket via statSync(...).isSocket() rather than a bare existsSync. It falls
back to the process's own XDG_RUNTIME_DIR only when that canonical path has no reachable
bus -- covering hosts that legitimately have no /run/user/<uid> at all but do have a
working bus wherever their own environment points. buildDurableDaemonScopeCommand() now
explicitly sets XDG_RUNTIME_DIR to whichever path this resolution picked, rather than
inheriting the spread env's (possibly hardened-wrong) value.

Both isDurableDaemonScopeSupported() and buildDurableDaemonScopeCommand() gained an
injectable canonicalRuntimeDir parameter (defaulting to the real computed path) so tests
can exercise the hardened-override scenario deterministically with a real, connectable
AF_UNIX socket fixture instead of the live host's actual runtime directory.

Docker's stock jrei/systemd-ubuntu test container never had this hardening directive, so
this gap was structurally invisible to the container-based verification in #19430 -- only
caught against real mtl-02 hardware.

* fix(daemon): report the daemon's own pid over the ready handshake, not systemd-run's

The launcher used to infer the daemon's identity pid from the immediate
spawned child (`child.pid`). On the durable-scope path that child is
`systemd-run --user --scope`, not the daemon, so the launcher was asserting
an identity it had no authority over.

`DaemonReadyIdentity` now carries a required `pid` populated from
`process.pid` inside the daemon itself, and `daemon-launched-child.ts` takes
`launchedIdentity.pid` from that self-report. Both sides of the
`holdDaemonAdoptionLease` pid comparison therefore originate inside the
daemon process, which is the idiom this branch already uses for cgroup
membership (`detectOwnCgroupScopeUnit` reads `/proc/self/cgroup` rather than
trusting what the launcher intended).

Note on the reported consequence: `systemd-run --scope` registers its *own*
pid on the transient scope unit and then `execvpe()`s the target command --
same pid, no intermediate process -- so adoption did not in fact fail on
systemd >= 206 (verified against systemd 255.4-1ubuntu8.17 and current main,
`src/run/run.c` `start_transient_scope()`). The fix stands on its own merits:
it removes a silent dependency on that exec-vs-fork implementation detail,
which a `systemd-run` shim earlier in PATH or any future systemd change would
have broken with no diagnostic.

`terminateLaunchedDaemonChild` was audited and deliberately left on
`child.pid`: for the same execve-preserves-pid reason that pid is either
still systemd-run mid-scope-setup (killing it correctly aborts the launch) or
already the daemon, so it targets the right process either way.

Regression coverage: `daemon-launched-child-identity.test.ts` pins the
identity source, and `daemon-ready-identity.test.ts` gains pid-validation
cases. Ready-message fixtures across the `daemon-init-*` suites were updated
for the now-mandatory field.

Addresses:
https://github.com/stablyai/orca/pull/19430#discussion_r3953722704
https://github.com/stablyai/orca/pull/19430#discussion_r3954346518

* test(daemon): assert cgroupUnit in the pid-file parse contract

`parseDaemonPidFile` returns `cgroupUnit` on every branch as of the
durable-scope commit on this branch, but five exhaustive `toEqual`
assertions in daemon-health.test.ts still described the pre-scope shape, so
they failed on the branch independently of any later change.

Adds the field to those expectations. Deliberately not relaxed to
`toMatchObject`: asserting the full parsed shape is what makes these tests
catch a field silently dropped from the pid-file contract.

* refactor(daemon): resolve the canonical user runtime dir at one point

The per-UID path cannot change for a live process, so compute it once into a module
const instead of threading the same default call through three signatures, and drop
the try/catch around a getuid() that cannot throw once it exists. Trims the module
prose to the non-obvious facts and corrects the pid-file record comment: an unscoped
daemon writes null; only records no daemon wrote are absent.

* test(daemon): clean up the cgroup-scope fixtures and assert a verdict

The cgroup fixture tracked only the file it wrote, leaking one temp dir per case.
Drains both fixture lists with splice so the pop-may-be-undefined guards go away,
and replaces a not-throw/typeof-boolean pair with the verdict it was circling:
no resolvable runtime dir means unsupported.

* refactor(daemon): share the detached child options across both launch paths

cwd, detached and stdio were repeated in the fork and systemd-run branches, which
left the two comments explaining them hovering over the env block instead. Names
them once so each branch carries only its own delta.

* refactor(daemon): validate the ready pid like every other field

typeof-first narrows the value, so the two 'as number' casts the isSafeInteger check
needed disappear and the pid guard reads like the startedAtMs guard below it.

* fix(daemon): don't retry the launch unscoped after losing the endpoint race

A scoped attempt that lost the endpoint to another daemon was retried unscoped: a
second doomed fork, a misleading 'cgroup-scope launch failed' warning, and the same
DaemonEndpointUnavailableError the caller was already going to adopt on. Rethrows it
instead, since no launch mode can win a race that is already lost.

Also drops a private alias for DaemonChildSpawnOptions and the two 'as number' casts
on child.pid in the startup-failure cleanup.

* fix(daemon): unlink the pid record by the pid the daemon published

The record holds the daemon's self-reported pid, so match on that rather than on the
immediate child's, which is the systemd-run wrapper's until it execs.

* fix(daemon): route the scope launch through the child-process chokepoint

The two files this PR added imported `node:child_process` directly, which
`child-process-import-boundary.test.ts` fails on deterministically: the
offender count went 155 -> 157 against a pin of exactly 155. Raising the pin
or listing the files is what that test explicitly forbids, and the allowlist's
own note says a split "moved the import, it did not add one" -- so the fix is
to get both new files off the module and put the count back at 155.

- `daemon-cgroup-scope.ts`: the `systemd-run --version` probe now uses
  `runProcessSync` instead of `execFileSync`, so it gets the shared spawn
  decisions. Kept synchronous deliberately: `launchDaemonChild` attaches the
  readiness listener in the same tick it is called, and an await before the
  spawn moves the child past that tick. A non-zero exit is data rather than a
  throw here, so the verdict now checks `code === 0 && !timedOut`.
- `daemon-launched-child-spawn.ts`: the scoped launch uses `spawnProcess`, and
  the long-standing unscoped launch keeps `fork` semantics through a new
  `forkProcess`.
- `src/shared/child-process/fork-process.ts`: the fork arm of the chokepoint.
  `spawnProcess` cannot express a Node child with an IPC channel started from
  a module path under an overridden `execPath`, and the existing launch tests
  are written against `fork`'s contract, so a spawn rewrite would have changed
  module resolution, `execPath` and `execArgv` at once. It passes
  `windowsHide: true` -- the flag every other call site in that directory
  sets, reachable via an assertion because `ForkOptions` omits it -- which
  keeps `windows-console-visibility.test.ts` at its pin of 65 too.

Both ratchets pass with both pins and both allowlists untouched.

Docs: `orcad-operations.md` and `headless-linux-server.md` still described the
limitation this PR removes as permanent. Both now describe the durable-scope
survival path and its preconditions (systemd as PID 1, a reachable user bus /
`loginctl enable-linger`, `systemd-run` on PATH), and scope the old text to
the unscoped-fallback case, pointing at `health.terminalDaemon.cgroupUnit` as
the way to tell the two apart on a running host.

* fix(daemon): seal the cgroup capability probe from the host and correct KillMode=mixed docs

The capability probe consulted the host's own /run/systemd/system marker and
spawned the real systemd-run binary, so the hermetic unit tests could only pass
on a systemd host (and fail closed otherwise, even with faked bus sockets).

- Thread systemdBootPath and runVersionProbe as test seams through
  isDurableDaemonScopeSupported, defaulting to the real boot marker and
  systemd-run --version probe in production.
- Narrow the injected probe to the ProcessResult slice it consumes.
- Cover: no-systemd-boot, non-zero probe exit, and probe-timeout cases.
- Correct KillMode=mixed semantics in the docs: the cgroup-wide SIGKILL fires
  the instant the main process exits, not after TimeoutStopSec; document the
  Docker-container caveat and add KillMode=mixed to the multi-service template.

* fix(daemon): satisfy assertion checks in scoped launch

* fix(daemon): satisfy anti-slop and console guards

* test(serve): update shutdown docs assertions for daemon scope

* fix(daemon): migrate adopted legacy scopes

* docs: qualify restart safety by daemon scope

* docs(daemon): qualify Upgrade restart prose with durable scope caveat

Align the Upgrade section in docs/reference/headless-linux-server.md with
the earlier preservation section and docs/reference/orcad-operations.md:
a service restart terminates live processes only when running under the
unscoped fallback, and stops should be treated as destructive unless
health.terminalDaemon.cgroupUnit names an orca-daemon-*.scope.

Update the shutdown workflow test assertion in
config/scripts/headless-serve-shutdown-workflow.test.mjs to match.

* fix(daemon): harden legacy scope migration

---------

Co-authored-by: Lesley Murfin <260182349+LesleyMurfin@users.noreply.github.com>
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-21 17:23:30 -07:00

15 KiB

Running orcad

orcad is the Orca runtime served from plain Node. This is the contract between it and whatever supervises it: what it binds, what it owns on disk, who restarts what, and what its readiness payload actually proves.

Two long-lived processes, not one

A deployment is orcad plus the terminal daemon.

orcad terminal daemon
Started by the supervisor orcad, detached
Owns RPC, git, worktrees, persistence every local PTY
Lifetime one supervised run detached from orcad, not its service
Endpoint ws://<bind>:<port> <data-root>/daemon/daemon-v<N>.sock

orcad detaches the daemon and calls disconnectDaemon(), never shutdownDaemon(). The built-in remote deployment path stops only the recorded orcad PID, so the daemon and its PTYs survive. The successor adopts the current endpoint and routes supported previous protocol versions through legacy adapters. This makes a PID-scoped update, rollback or restart non-destructive to live work.

Process detachment is not service isolation. A daemon that orcad launches directly, and every PTY it owns, remain in the same systemd service cgroup. KillMode=mixed does not preserve them: it sends the graceful stop signal only to the main process, then sends SIGKILL to every process remaining in the cgroup the moment that main process exits — TimeoutStopSec never gets the chance to apply. KillMode=control-group is destructive too. KillMode=process leaves service-owned processes unmanaged and is not a supported preservation mechanism.

Service-restart survival therefore requires a separately supervised cgroup, and orcad now asks for one: on Linux it launches the daemon through systemd-run --user --scope, which places the daemon and its PTYs in their own transient orca-daemon-<launch-nonce>.scope unit under the user slice instead of the caller's service cgroup. A stop or restart of the service unit then leaves that scope — and the live terminals in it — running, and the successor adopts the endpoint as it always has.

The scope is requested only where it can work. All of these must hold:

  • Linux with systemd as PID 1 (/run/systemd/system exists).
  • A reachable user bus — a connectable bus socket in the per-UID runtime dir (/run/user/<uid>, or whatever XDG_RUNTIME_DIR points at). For a service account that is not otherwise logged in, that means loginctl enable-linger <user>; a unit whose RuntimeDirectory= hardening moves XDG_RUNTIME_DIR off the per-UID path is handled, because the real per-UID path is probed first.
  • systemd-run on PATH and answering --version.

Any of those missing, or a StartTransientUnit call that fails anyway, falls back to the direct launch — and in that unscoped fallback case the paragraph above still describes reality: the daemon shares the service cgroup and a combined-unit stop ends live terminals. Read the cgroupUnit field in the daemon health payload to tell the two cases apart on a running host; it is populated from /proc/self/cgroup, so it reports the isolation the daemon actually has rather than what the launcher intended.

Bind policy

--bind <literal-ip>, default 127.0.0.1.

Only literal IPs are accepted; hostnames are refused because DNS would decide which interface got bound. localhost maps to 127.0.0.1. 0.0.0.0 / :: are the explicit opt-ins to network reach, and the startup log says so on every launch.

The bind is pinned, not defaulted. Two things widen the desktop's listener on their own — orca serve's wide default, and a startup where some device has connected before — and an unattended host's exposure must be exactly what the operator asked for on every launch. A mobile pairing offer, which normally rebinds to all interfaces, is refused while the bind is pinned to loopback and reports network_exposure_failed rather than advertising an endpoint nothing can reach.

Under the shipping design a client reaches a remote orcad over an SSH local port-forward, so loopback is the correct default and the pairing credential travels over SSH.

Data root and the instance lock

The data root is $ORCA_USER_DATA, else $XDG_DATA_HOME/Orca, else ~/.orca.

Before the profile index or the store is touched, orcad takes <data-root>/orcad.lock. It refuses to start when:

Code Meaning
orcad_data_root_wrong_owner the root is owned by another uid (POSIX)
orcad_data_root_shared the root is group/world accessible and could not be tightened
orcad_instance_lock_held another live orcad owns this root
orcad_instance_lock_foreign_identity the lock belongs to a different identity
orcad_data_root_unusable the root cannot be created, stat'd or written

A root that is merely too permissive and that we own is tightened to 0700 rather than refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal is a private root, and refusing when we could just fix it helps nobody. We refuse when the permissions are not ours to fix. Windows is exempt from the owner and mode checks: ACLs are not expressible as a POSIX mode, and statSync().mode there reports a synthesized one.

A dead holder's record is reclaimed (PID plus process start time, so a recycled PID does not read as alive). A record belonging to a different identity is never reclaimed.

The lock scopes one role — who is the runtime. It deliberately says nothing about the daemon, which lives under <data-root>/daemon and fences its own endpoint with its own PID record. A lock that asked "is any process using this root" would refuse exactly the restarts a live daemon makes worthwhile.

Supervision

Process-scoped and cgroup-wide stops

The built-in remote updater performs a PID-scoped stop and keeps the daemon's install version pinned while it owns sessions. A combined-unit systemd stop or restart is different: unless the daemon holds a durable cgroup scope of its own (see Two long-lived processes, not one), it reaps the daemon and every live terminal after the graceful window. Treat a stop as destructive unless health.terminalDaemon.cgroupUnit names an orca-daemon-*.scope on that host.

Before a cgroup-wide stop, obtain a fresh orca-ide terminal list --json result using the same OS account and home as the daemon. Invoke the installer's absolute launcher path so sudo's secure_path cannot hide a per-user registration (for example, sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json). Replace both orca and /home/orca with the service account and home used by the unit; an extracted deployment may use its absolute resources/bin/orca-ide launcher instead. A safe empty census is untruncated, has an explicit hostScope, covers every execution host affected by the stop, and lists no terminals on those hosts. Every omittedHostIds entry must be explicitly accounted for outside the target service's execution boundary. A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, truncation, a failed request or lost contact makes the result unverifiable: defer the stop. Do not admit new work after the census. Orca does not yet provide an atomic census-and-stop fence.

Who supervises orcad

An external supervisor (systemd, launchd, a process manager). orcad conforms to it:

  • Readiness. One JSON line on stdout (--json), type: "orca_server_ready", published after the listener is bound and the daemon verdict is in. There is no separate readiness socket; the line is the signal. Set the supervisor's start timeout generously — the daemon launch has its own retries and can take tens of seconds on a cold host.

  • Shutdown. SIGTERM or SIGINT starts a graceful stop. A second signal exits immediately with code 1 rather than being swallowed — a supervisor's second signal means its first deadline elapsed, and waiting silently is what turns a stop into a SIGKILL, the one teardown that skips the daemon handoff. orcad also imposes its own 15s deadline and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.

  • Exit codes.

    Code Meaning Supervisor should
    0 clean shutdown restart per policy
    1 startup or shutdown failure restart with backoff
    78 configuration fault (bind address, data root, instance lock) not restart

    78 is EX_CONFIG. Put it in systemd's RestartPreventExitStatus: restarting on a data root owned by someone else is a restart-spin, not a recovery.

  • Logs. orcad writes human-readable diagnostics to stderr and its readiness contract to stdout; the supervisor owns capture and rotation. The daemon, being detached, writes its own NDJSON lifecycle log to <data-root>/logs/daemon.log (suppressed by ORCA_DIAGNOSTICS_DISABLED=1). Rotation of that file is not implemented — see What is not covered.

orcad supervising the daemon

  • Launch. On Linux, through systemd-run --user --scope so the daemon gets its own transient cgroup and survives a service-unit restart; everywhere else, and wherever that scope is unavailable, forked detached. Either way it runs daemon-entry.js beside orcad.js with its own PID record, token and socket under <data-root>/daemon.
  • Adoption before spawn. A daemon already answering the endpoint is adopted, not replaced, unless it is unhealthy, foreign, or built from a superseded bundle and owns no live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers to live work.
  • Restart. The adapter respawns the daemon on death, transparently to callers.
  • Crash-loop containment. At most 5 launches per 60s rolling window per orcad run; past that, launches are refused with daemon_crash_loop and terminals fail with that message instead of the process forking forever. The window slides, so a repaired host recovers without restarting orcad. An operator-initiated daemon restart clears it — that is the deliberate "try again".
  • No macOS login-session watch. That watch retires the daemon when the spawning GUI login session dies. An orcad daemon must survive its SSH session ending.
  • Shutdown. orcad never stops the daemon. A daemon that was never adopted retires itself after its adoption window; an adopted one stays resident (see Decommissioning).

Decommissioning

After a PID-scoped stop, an adopted daemon stays resident so the next orcad can reattach. A combined-unit systemd stop also leaves a scope-isolated daemon resident, but kills one that fell back to the service cgroup. To retire a process-scoped deployment, apply the census rule above, stop orcad, then stop the daemon named by health.terminalDaemon.pid. Only report it exited after verification on the execution host; loss of contact is unverifiable.

Health

The readiness payload carries a health object:

buildHash    sha256 (16 hex) of the running orcad bundle — build identity that a version
             string cannot give, so a rollback that did not replace the file is visible
buildVersion ORCA_VERSION
nodeVersion  / nodeAbi   process.versions.node / .modules — the ABI native addons must match
platform / arch / pid
terminalDaemon:
  state              live | degraded | absent
  ownsFreshSessions  whether NEW terminals are daemon-owned; this supports PID-scoped
                     restart recovery, not supervisor or service-cgroup isolation
  pid                the live daemon's pid, from its own PID record
  buildVersion       the build the LIVE daemon was forked from (may legitimately predate
                     this orcad after an update — reporting orcad's version for both would
                     hide exactly that)
  entryPath / protocolVersion
  selfTest { ok, coverage, verdict, durationMs }

What the self-test proves

selfTest runs checkDaemonHealth against the daemon's socket. It is green only when the daemon opened its socket, completed the protocol handshake, and ran ptySpawnHealth — a real short-lived PTY spawned inside the daemon's own process. It therefore spans both processes: orcad drives it, the daemon performs it, the verdict crosses the socket.

  • coverage: 'pty-spawn' — the full round trip above.
  • coverage: 'handshake'win32 only, where checkPtySpawnHealth returns without spawning anything. A green verdict there covers the handshake and nothing more. It is reported separately rather than folded into ok so nobody reads it as a PTY round trip.

state is live only when the self-test passed and ownsFreshSessions is true. A daemon that answers but has fallen back to local spawning for new terminals is degraded, because those terminals die with orcad. A daemon that answered and then failed its spawn probe is also degraded, not absent: it still holds live sessions, and calling those exited would be the verdict ssh-execution-boundary.md forbids guessing.

What is not covered

Named here so nothing reads as implemented that is not:

  • A continuous health endpoint. health is published once, in the readiness payload. A supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the same collectOrcadHealth(); that surface does not exist yet.
  • Supervision of an unscoped fallback daemon. When the durable systemd-run --user --scope launch is unavailable (see above) orcad and its daemon share one service cgroup, and a combined-unit stop cannot preserve live terminals. There is no mechanism that re-isolates such a daemon after the fact.
  • libc slot. There is no honest health value to publish until native libc detection owns it.
  • degradations[]. The readiness contract does not publish this collection yet.
  • Credential administration (list / revoke / rotate devices, expiring pending offers, structured security logging).
  • Pinned-port fail-closed. A pinned --port still falls back to an OS-assigned port on conflict.
  • Reconciling webClientUrl with reachability under the loopback default.
  • State-schema rollback rules.
  • Daemon log rotation. <data-root>/logs/daemon.log grows unbounded.