* 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>
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/systemexists). - A reachable user bus — a connectable
bussocket in the per-UID runtime dir (/run/user/<uid>, or whateverXDG_RUNTIME_DIRpoints at). For a service account that is not otherwise logged in, that meansloginctl enable-linger <user>; a unit whoseRuntimeDirectory=hardening movesXDG_RUNTIME_DIRoff the per-UID path is handled, because the real per-UID path is probed first. systemd-runonPATHand 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.
SIGTERMorSIGINTstarts 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 aSIGKILL, 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'sRestartPreventExitStatus: 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 byORCA_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 --scopeso 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 runsdaemon-entry.jsbesideorcad.jswith 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_loopand 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, wherecheckPtySpawnHealthreturns without spawning anything. A green verdict there covers the handshake and nothing more. It is reported separately rather than folded intookso 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.
healthis published once, in the readiness payload. A supervisor's periodic liveness/readiness probe needs an HTTP or RPC surface over the samecollectOrcadHealth(); that surface does not exist yet. - Supervision of an unscoped fallback daemon. When the durable
systemd-run --user --scopelaunch 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
--portstill falls back to an OS-assigned port on conflict. - Reconciling
webClientUrlwith reachability under the loopback default. - State-schema rollback rules.
- Daemon log rotation.
<data-root>/logs/daemon.loggrows unbounded.