kolu
Docs

@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, awaitExitis 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);
ExportRole
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
ReadSocketHoldersthe 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 · SocketHolderthe 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);
ExportRole
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) · ControlCoreProbeClientread + 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 · outcomeAnomalyopaque budget handles (no public admit/can-openers); connector mint is type-gated
InstanceKey · instanceKeyFromStartedAt · instanceKeyTagnamed instance or pre-instance (absent startedAt = older — never overloaded null)
BindResult · BoundResidentCharacterizationprivate-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
ConvergenceAnomalyadopted-stale | skew-refused | unconverged | cross-supervisor — each arm carries typed evidence; unconverged.running is nullable (unknown); link-failed is session-owned
UnconvergedCausebudget-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_MSthe 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 · buildsMatchre-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:

TriggerWhat happensEvidence
undecodable-framethe peer speaks FIRST, in a framing we cannot parsea bounded, JSON-quoted frame excerpt
silencethe peer waits for a greeting in a protocol we do not speak — it accepts the connection, takes our frames, and answers nothingsilentForMs, 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.

stepwhat happens
re-attestthe 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
SIGTERMthe old daemon’s own shutdown runs (persist, close, release the gate). Bounded by REAP_TERM_CEILING_MS (120 s) — a deadline, not a delay
SIGKILLonly past that deadline, bounded by REAP_KILL_CEILING_MS (5 s). Still alive after it ⇒ the endpoint reports dead and fails loud
spawna 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.