@kolu/surface-daemon-supervisor reference
The client half — the code that spawns, watches, recycles, and upgrades a daemon.
It runs in the client process, never the daemon, and carries no @kolu/* app
dependency. Its only workspace edges are the daemon half and @kolu/surface,
used to dial the frozen control core.
The face is Effect-native
Every verb on this page returns an Effect, and every plug it takes from a
consumer — connect, probe, driver.spawn, RestartSteps’ three steps,
drain, awaitExit — is one. There is no Promise-shaped twin: a supervisor
composes its boot, its recycle and its drain into one fiber tree, so a boot that
loses a race, a restart the user abandoned, and a drain past its ceiling all tear
their own work down instead of running on unobserved.
Three seams are deliberately not Effects, and each is a synchronous fact
rather than an operation: Endpoint.current() (a read of held state, whose eager
throw callers rely on), and a DaemonConnection’s dispose() / onClose(cb)
(the supervisor tears down from paths that cannot await).
Nothing on EndpointSpec is Promise-shaped. The two that were —
readProcessIdentity and readSocketHolders — were Promise-shaped only because
osfacts-client, the package that satisfies them, declared no effect
dependency, so the supervisor lifted each at its one call site. The client takes
effect as its one runtime dependency now and its spawning verbs return
Effects, so both seams state
Effect.Effect<…, OsfactsClientError> and both lifts are gone. The error
channel is the client’s own union rather than a wrapper of this package’s
making: the success halves (SocketOccupancy, SocketHolder) already come
from there, so a local error type would be a second name for facts this package
does not produce — and an identity read that fails stays a typed failure the
endpoint answers (report dead, then propagate) instead of a defect that
strands the UI at connecting.
awaitExit no longer takes an AbortSignal. It existed for exactly one job —
telling a poll-based exit oracle to stop once the drain ceiling had won — and
fiber interruption does that job unconditionally. Interruption is not refusable;
an abandoned AbortController was.
The endpoint
createEndpoint({ home, readProcessIdentity, policy, probe, connect: (socketPath) => …, … }) is the
state machine: connecting → connected | dead | incompatible, then
connected → degraded if the daemon dies mid-session. Gate and socket come from
home (the same spine primitive daemonMain takes); readProcessIdentity is the
injected OS fact reader for start-qualified gate ownership (required — never
defaulted); connect receives the
socket path from the framework so callers never re-thread it. The policy is the
consumer’s whole convergence surface (who I am + how I converge); the public
verbs are converge(endpoint) and recycle(endpoint, steps) — the boot-method
trio (ensure / adoptOrEnsure / adoptOrSpawnOrRefuse) is absent from the
public Endpoint type and from the runtime object. Private binds live only in
a package WeakMap keyed by the genuine createEndpoint handle; kit modules
(converge, recycle) and same-package tests resolve them via a
package-internal relative import of endpoint.private.ts — there is no public
asEndpointInternal or ./testing subpath.
Unreadable gate (EACCES/EIO): the endpoint’s holder probe folds the daemon
half’s three-way gate-read law exhaustively — when readGateIdentity yields
unreadable, ensure / recycle throw before any unlink, kill, or spawn
(status still emits connecting → dead then rethrows so the UI never wedges).
An unreadable gate is not free and not stale; a mode-000 gate is the unit pin
(recycleForeignGate.test.ts).
incompatible is the PROVEN contract-skew verdict — a respawn from the
realised closure already skewed (or a refuse-policy survivor skews at
handshake) — reported with both contract versions on the status
({ state: "incompatible", daemonVersion, requiredVersion }), never collapsed
into dead/degraded: a consumer deriving affordances from the state sum
must not offer a restart against it (restarting cannot fix a skew; only
changing the closure can). It is deliberately NOT coerced by a restart hold —
a skew inside a restart is that restart’s terminal verdict, not a transition.
current() is the live connection; holdRestarting is the recycle emit-guard.
It is generic over the client C and identity I.
const policy: ConvergencePolicy<"not-drainable"> = {
capability: "not-drainable",
baked,
onContractSkew: { kind: "recycle" },
onBuildMismatch: { kind: "nudge-human" },
};
const endpoint = createEndpoint<TopClient, TopIdentity>({
hostId: "local",
home, // SAME call as the daemon — disagreement impossible
readProcessIdentity,
readSocketHolders, // osfacts-client's osfactsSocketHolders(<the same resolved bin>)
policy,
probe: probeDaemonIdentity({ capability: "not-drainable" }),
driver: survivableSpawnDriver({
binPath: daemonEntry,
args: [],
// COMPLETE child env for the detached spawn: the daemon's base plus its
// locator vars. A partial env here would spawn a daemon with no PATH/HOME.
env: {
...spawnEnvBase(),
FLEET_TOP_GATE: home.gatePath,
FLEET_TOP_SOCKET: home.socketPath,
},
unitPrefix: "fleet-top",
}),
// the framework hands you the path
connect: (socketPath) => connectTop(socketPath),
log: stderrLogger(),
onStatus: (hostId, status) =>
process.stderr.write(`[supervisor] ${hostId}: ${status.state}\n`),
});
return Effect.gen(function* () {
// #region converge
// The only boot verb — policy (who I am + how I converge) is fixed on the endpoint.
const outcome = yield* converge(endpoint);
| Export | Role |
|---|---|
createEndpoint({ home, readProcessIdentity, readSocketHolders, policy, probe, connect, … }) | returns an unforgeable public Endpoint (boot trio lives in a package WeakMap, not on the object); home is DaemonHomePaths; readProcessIdentity is ReadProcessIdentityAsync, i.e. (pid: number) => Effect.Effect<ProcessIdentity | undefined, OsfactsClientError> (off the serving loop, so an osfacts spawn never blocks it — prefer processIdentityAsync(bin), with bin resolved once at the composition root). The awaitable type lives in this package beside EndpointSpec; the daemon half keeps only the sync ReadProcessIdentity, which is what the synchronous gate-claim paths still use. readSocketHolders is the second OS-fact inject (ReadSocketHolders) — both are required, because the endpoint performs no platform traversal of its own. Gate unreadable (EACCES/EIO) → throw before unlink/kill/spawn; emit dead then rethrow |
destructiveRecycleSteps() | named no-preservation steps for fail-closed recycle (all steps still required) |
survivableSpawnDriver(cfg, deps?) | the default DaemonDriver: systemd-run --user under a service, detached +unref otherwise. DaemonDriver.spawn is an Effect<void> VALUE, not a method — re-running it IS “spawn again” |
scrubDaemonNodeOptions(nodeOptions) | strip dev-only Node flags from an inherited NODE_OPTIONS so a spawned daemon never opens the SUPERVISOR’s inspector or writes its profiles; returns undefined when nothing of value remains, so the var is dropped rather than emptied. Opt-in per driver |
recycle(endpoint, steps) · serializeRestart(endpoint) | recycle is the composed capture → drain → recycle → reattach sequence, as one Effect; RestartSteps’ three members are Effects too (capture is a VALUE — an effect is already the description of work not yet done). serializeRestart returns a single-flight triggerRestart whose concurrency is COALESCING, not queueing: a second caller JOINS the restart already in flight and gets its outcome, rather than waiting and then running a second one |
waitForPidGone(pid, opts?) | an Effect<boolean> that polls isHolderLive until the pid is reaped (ESRCH), so a respawn never races a live gate holder |
reapHolder(pid, opts?) | an Effect<ReapOutcome> — the two-deadline stop every supervisor kill site shares: SIGTERM → wait REAP_TERM_CEILING_MS (120 s) → SIGKILL → wait REAP_KILL_CEILING_MS (5 s). Answers { kind: "reaped", endedBy, waitedMs } or { kind: "survived", waitedMs } — a record, because which signal ended it and how long it took are what an operator reads after the fact |
ReadSocketHolders | the TYPE of the readSocketHolders inject: (socketPath: string) => Effect.Effect<SocketOccupancy, OsfactsClientError>. The reader that satisfies it is osfactsSocketHolders(bin) from osfacts-client, bound to an already-resolved osfacts binary path (bakedOsFactsBin(<your env var>) — this package never learns which var a consumer bakes). It calls osfacts socket-holders <path> --procs; the platform split, the parse, the honesty policy AND the fold live in the tool and its client, not here. Bind it to the same resolved path as processIdentityAsync so one root spells its env var once for both OS facts |
SocketOccupancy · SocketHolder | the three-way answer, never one possibly-empty list: { kind: "held", holders } (a NON-EMPTY tuple by type — readonly [SocketHolder, ...SocketHolder[]] — so a consumer never writes an unreachable empty-check), { kind: "none" } (proven nothing holds it — only linux’s world-readable /proc/net/unix can prove this), { kind: "unattributed", detail } (something may hold it and the OS would not say what — a bound-but-unreadable holder, or a search that could not complete). Only none is evidence of freedom, and the endpoint still re-probes before spawning. Both types are re-exported from osfacts-client, whose foldSocketOccupancy(reading) produces them from a SocketHoldersReading — one fold, so kolu and drishti cannot spell the three-way differently |
SocketSquatterForeignError · isSocketSquatterForeignError(err) | thrown when the process holding the rendezvous socket did NOT complete the kaval handshake — a foreign / non-conforming holder that is never killed. Carries socketPath, holders: SocketHolder[] (each a pid and an OPTIONAL command — absent, never "?", when the identity read lost the race, so a sentinel can never be mistaken for a process actually named ?) and detail?: string so the refusal names the squatter — and when the OS named NOBODY, detail is the unattributed reading’s own words (the holder search could not complete (darwin_proc_fds: BLIND_OR_EMPTY)), which the message renders instead of “an unidentifiable process”. On darwin the nameless case is the ordinary one, so the explanation, not just the decision, has to survive the fold. Brand-checked, realm-robust |
SocketProbeIndeterminateError · isSocketProbeIndeterminateError(err) | thrown when a one-shot socket connect probe times out (SocketServeState "indeterminate") — not proof free or dead. The endpoint never kills, unlinks, or spawns on it; carries gatePath / socketPath / optional gatePid. Failures after connecting emit dead then rethrow so status never wedges |
DaemonContractSkewError · isContractSkewError(err) | the ONE typed connect rejection that proves incompatibility. Constructed from fields — { subject, daemonVersion, requiredVersion, pid? } — with the message derived from them (no consumer ever re-parses the prose); readonly daemonVersion / requiredVersion ride the instance so downstream translators (a typed RPC rethrow, a status arm) read them structurally. The optional readonly pid is the skewed daemon’s self-reported OS pid (additive), carried so the gate-less-squatter recovery of an OLD orphan — which throws here before a connection exists — has its identity attestation. The guard is brand-checked (realm-robust), never instanceof |
ENDPOINT_STATES · ENDPOINT_STATE_DOWN · isDownEndpointState(s) | the state tuple and its TOTAL down/terminal classification, both living in the zero-dependency, browser-safe /states subpath (re-exported from the package root). ENDPOINT_STATE_DOWN is a Record<EndpointState, boolean>, so a new state is a compile error at the home until classified; consumers (a dial-ended check, a client presentation table) read isDownEndpointState instead of hand-spelling the degraded || dead || incompatible disjunct |
The convergence kit
converge(endpoint) is the only boot verb: it probes the running daemon’s
identity, asks a pure decide, admits drain via the per-boot budget (when
drainable), and enacts through the endpoint’s private boot methods, returning a
typed ConvergenceOutcome whose kinds either always carry their anomaly or
never do (no optionals). Connector arms (ssh) use convergeAdmit with the
same policy object. Anomaly arms carry typed evidence as data (running /
expected identities, drained / observed instance keys); detail is human
garnish only.
Single observation authority. Every observed identity — initial probe, bind
characterization, post-drain successor, post-give-up bind, drainable re-probe —
flows through one internal fold (foldObserved). Every bind transition
(plain / recycle / post-drain / give-up) flows through one BindResult consumer
(consumeBindResult); call sites never inspect r.kind. That fold always runs
decide(policy, identity | null) (never a hand-copied compat/match subset), owns
connection lifecycle so refused / not-adopted / probe-failed exits call the
idempotent releaseHeld unconditionally (no holding mirror), preserves
four-valued characterizations (characterized | absent | failed | uncorrelated),
and reports unconverged.running: null when the running identity is honestly
unknown (never fabricates expected). A characterization probe throw is
probe-failed with the true message — never catch-to-null into
identity-unverifiable. Named probe instance keys must match conn.startedAt;
mismatch is uncorrelated.
// The only boot verb — policy (who I am + how I converge) is fixed on the endpoint.
const outcome = yield* converge(endpoint);
| Export | Role |
|---|---|
converge(endpoint) | single fold over every observation → decide → budget-gated drain → private bind; genuine createEndpoint handle only; same-lineage admitted drains continue (F1a); null re-probe is identity-unverifiable (F1b); give-up re-decides characterization without re-draining |
convergeAdmit({ running, budget, drain, awaitExit, … }) | connector arm, an Effect<ConvergeAdmitVerdict>; budget is ConnectorDrainBudget (recycle/nudge-human unspellable); drain and awaitExit are Effects, and awaitExit succeeds only on process exit, never on link loss (F3) |
probeDaemonIdentity({ capability, drainCeilingMs? })(socketPath) | endpoint probe for the frozen fragment; full ConvergenceProbe, null only for honest ECONNREFUSED/ENOENT, all other dial/hello failures—including a control-core version other than frozen 1.0, and an unspeakable peer (UnspeakableProtocolError)—throw |
probeDaemonIdentityFrom({ client, dispose, capability: "not-drainable" })probeDaemonIdentityFrom({ client, dispose, capability: "drainable", awaitExit, drainCeilingMs }) | single probe-assembly authority for an already-dialed connector; drainable probes require both exit-oracle fields, while not-drainable probes omit them; SSH keeps its stronger process-exit oracle instead of treating link loss as exit |
readControlCoreHello(client) · ControlCoreProbeClient | read + validate the frozen hello on its one baked 30 s deadline (frozen-version check, plus the joint buildId/commit fact), and the already-dialed client shape it takes — client.surface.control.core.hello(), which is itself an Effect |
dialSocket(socketPath) | the package’s own unix-socket dial as an Effect<Socket>, so the control-core leg owns its framing tap — and a dial INTERRUPTED before it settles destroys the half-open socket rather than abandoning it |
isNoListenerError(error) | the one ECONNREFUSED/ENOENT classifier shared by generic and pre-fragment daemon probes |
decide(policy, running) | pure fold — reads policy.baked (no free-standing baked arg); sole decision authority under converge |
createDrainBudget · createConnectorDrainBudget · outcomeAdopted · outcomeAnomaly | opaque budget handles (no public admit/can-openers); connector mint is type-gated |
InstanceKey · instanceKeyFromStartedAt · instanceKeyTag | named instance or pre-instance (absent startedAt = older — never overloaded null) |
BindResult · BoundResidentCharacterization | private-bind result; adopted residents carry four-valued characterization (characterized | absent | failed | uncorrelated) |
drainAndAwaitExit(drain: Effect<void>, awaitExit: Effect<void>, { ceilingMs }) | framework-run drain-and-confirm-exit skeleton both enactments share. Both plugs are forked into the primitive’s own scope, so the ceiling winning INTERRUPTS the exit oracle — awaitExit’s error channel is never by type, which is the F3 contract stated in the signature rather than in a comment |
ConvergenceAnomaly | adopted-stale | skew-refused | unconverged | cross-supervisor — each arm carries typed evidence; unconverged.running is nullable (unknown); link-failed is session-owned |
UnconvergedCause | budget-exhausted | drain-not-taken | adopt-bind-failed | identity-unverifiable | probe-failed | unspeakable-protocol — evidence as data under unconverged. The unspeakable-protocol arm carries socketPath · gatePath · the pid that was CLASSIFIED; it rides a refusal only when the gate stopped naming that pid before the takeover could act |
UnspeakableEvidence · unspeakableClause(evidence) | WHY a peer is unspeakable, as DATA: { trigger: "undecodable-frame", frame } or { trigger: "silence", silentForMs }. There is no third arm, and the renderer is shared so the dial path, the corroboration site and the fold cannot drift in what they tell an operator |
UnspeakableProtocolError · isUnspeakableProtocolError(err) | the TRANSPORT fact, raised by probeDaemonIdentity’s dial at one of those two triggers — never by the hello deadline, a close, or a member error. Carries socketPath and the evidence field. Uncorroborated it is an ordinary probe-failed. Brand-checked, realm-robust |
UnspeakablePeerError · isUnspeakablePeerError(err) | the CORROBORATED verdict — the same fact plus the two attestations the endpoint adds: the gate at this rendezvous is OURS and its pid passed the holder identity law. Carries gatePath and pid beside them. Only this becomes the unspeakable-protocol observation |
UNSPEAKABLE_SILENCE_MS | the silence bound (8 s), pinned between two protocol facts: Effect’s RPC socket protocol pongs every 5 s below the handlers (so a merely slow daemon has still demonstrably spoken), and kills the connection itself at ~10 s (past which there is nothing left to classify) |
contractIsNewer · contractIsCompatible · buildsMatch | re-exported from @kolu/surface-daemon (contract versions ordered; build ids match-only) |
The generic probe deliberately throws when a live pre-fragment daemon answers
the socket but has no core.hello route. A consumer with a named upgrade window
may catch only that structured route-missing failure and project it as an
older-build observation. Kaval does this so its not-drainable policy reports the
existing human nudge; it never turns the served-daemon fact into null, and no
other probe error is caught. (The oRPC NOT_FOUND frame that carried this
failure is gone with the protocol epoch; a consumer’s narrowing re-derives on the
Effect RPC failure it was replaced by. A previous-epoch daemon is a different
observation entirely — see below.)
The third observation — unspeakable-protocol
A daemon from a previous protocol epoch cannot be asked its version at all: version negotiation happens inside the protocol that was replaced. Convergence therefore has a third narrowly-typed peer observation beside “identity” and “absent”.
Such a peer betrays itself in exactly two ways, and which one you get depends on who greets whom:
| Trigger | What happens | Evidence |
|---|---|---|
undecodable-frame | the peer speaks FIRST, in a framing we cannot parse | a bounded, JSON-quoted frame excerpt |
silence | the peer waits for a greeting in a protocol we do not speak — it accepts the connection, takes our frames, and answers nothing | silentForMs, the bound it stayed mute through |
The silence arm is measured against the real previous release: the old oRPC
ServerPeer waits for its own client hello, our ndjson frames never look like
one, and the connection dies by timeout with no first frame to fail decoding.
Both are the same verdict, so they are one error type with the trigger as a
tagged field, never two classes a consumer would have to union.
The fact is raised under those triggers alone; the observation additionally
requires two attestations — the gate file at this rendezvous is ours and its
pid passed the holder identity law. Nothing else earns it: an uncorroborated
trigger stays probe-failed, and a foreign socket-squatter keeps the untouched
SocketSquatterForeignError path. That split is the whole safety story, and it
is what lets the silence trigger exist at all — a merely slow peer and a
stranger on our socket both stop at the transport fact, so the observation can
never put a SIGTERM near a process we have not proven is ours.
Classification happens at the trigger, not by waiting out the frozen hello’s
30 s deadline — which is also what keeps awaitHelloGone responsive inside a
drain ceiling when the peer will never answer.
The disposition is TAKEOVER, for every policy — the contract-skew policy is not consulted, because this is not a skew (a version is something you read off a wire you can speak) and the drain verb the ordered padi policy would reach for does not exist on an undecodable wire. What is available is the act the drain verb was only ever a way to request: the daemon’s own in-process shutdown, asked for with a signal instead of a message.
| step | what happens |
|---|---|
| re-attest | the gate must still name the exact pid that was classified. If it names anyone else, nothing is signalled — that holder was never observed — and the pass refuses with the unspeakable-protocol cause |
SIGTERM | the old daemon’s own shutdown runs (persist, close, release the gate). Bounded by REAP_TERM_CEILING_MS (120 s) — a deadline, not a delay |
SIGKILL | only past that deadline, bounded by REAP_KILL_CEILING_MS (5 s). Still alive after it ⇒ the endpoint reports dead and fails loud |
| spawn | a daemon of this epoch is started and connected at the same rendezvous; it seeds from disk, so loss is bounded by the old daemon’s autosave debounce |
The takeover is logged as one operator-readable line carrying what was stopped,
why it was provably ours, which signal ended it, and how long the wait took. The
outcome is recycled — the holder was replaced, not adopted.
Kaval’s recycle reaches the same act by the same path; padi’s old refuse
is gone, because reasoning from “the drain verb is unreachable” to “leave it
standing” left a cross-epoch upgrade permanently unconverged until a human
stopped a daemon out of band.