kolu
Docs

How to recycle and upgrade a daemon

When you ship a new daemon build, an older one may still be holding the gate with clients attached. converge() detects the mismatch, decides what to do by your declared policy, and enacts it through the endpoint — without the client noticing more than a brief reconnect.

1. Declare a policy

A ConvergencePolicy<Cap> says what to do per trigger — a contract skew versus a same-contract build change. The two shipped shapes:

  • recycle-on-skew (kaval): a mismatched daemon is killed and respawned.
  • drain-newer-else-refuse (padi): the daemon with the strictly-newer contract version wins — it is drained in and replaces the old; an older or equal contract refuses. (It keys on ordered contract versions, never on builds, which are match-only.) This arm is only available to a drain-capable handshake.

2. Converge before you connect

Call converge() with the endpoint, your baked expectation, a probe that reads the running daemon’s identity, the policy, and a build-drain fence:

// recycle-on-skew (kaval): a mismatched daemon is killed and respawned; a
// same-contract build change is reported to a human rather than auto-recycled.
const policy: ConvergencePolicy<"not-drainable"> = {
  capability: "not-drainable",
  onContractSkew: { kind: "recycle" },
  onBuildMismatch: { kind: "nudge-human" },
};

const outcome = await converge({
  endpoint,
  baked, // expected build id + contract version
  probe: () => probeIdentity(SOCKET_PATH), // identity over a version-agnostic channel
  policy,
  buildFence: createBuildDrainFence(), // once-per-boot drain fence
  log: stderrLogger(),
});

converge runs three steps: probe the running identity (over the version-agnostic channel, so a skew can’t hide it), decide with the pure decide fold (zero I/O — the policy table), and enact through the endpoint’s existing boot methods. It returns a ConvergenceOutcome; wire it to your own status surface and logs.

3. What the live recycle does underneath

When the decision is to recycle, the endpoint runs:

  1. read the gate (gatePid + isHolderLive) — is a live survivor holding it?
  2. kill it, then waitForPidGone — block until the pid is reaped, so the respawn never races a live gate holder;
  3. driver.spawn() — a survivable spawn (systemd-run --user, or detached +unref);
  4. waitForSocket, then connect() and the identity handshake.

For a connected client, the daemon is replaced underneath and the client’s session loop reconnects — re-running converge — with an onStatus(degraded) bridging the gap. restart(endpoint, steps) wraps this as capture → drain → recycle → reattach, so a session can be captured and re-adopted live across the kill.