Missing service reconciliation
The coordinator runs a service reconciliation stage during every
stado optimize run and scheduled autonomy tick. It joins two independent
facts: the unit state in the newest host beacon and a fresh
stado service verify reachability sweep from the declared consumer hosts.
The result is written to both
state/autonomy/services/latest.json and an immutable
state/autonomy/services/runs/<timestamp>.json; stado optimize status prints
the latest report.
Every autonomy object is rooted under state/ because the object gateway
authorizes a write by matching its key against the configured namespace's
prefix allowlist. No namespace declares autonomy/, so the whole layer's
writes were refused with 401 unauthorized or non-immutable release write
while the local backup backend kept serving stale reads — the same defect
state/host_silence/ was moved to fix.
The fleet-wide host-silence threshold is also the service-beacon freshness
threshold. A missing or stale reported_at changes the service state to
unknown; it never authorizes a host mutation — with one exception. The
beacon unit's own death is what makes every other unit unknown, so a silent
host's declared beacon unit is reasserted through the idempotent
service ensure path over the host channel: the channel answering is the
evidence that repair is possible. Ensure restarts a matching definition in place
and reloads a changed definition only after preflight, with the loaded result
read back afterwards. Once the beacon publishes again, the rest of the host
becomes repairable from real evidence. A failed unit goes through the same
ensure path as a missing one. For a host-probed unit, a live process running a stale copy of its own
declared binary is kicked, while a
process executing a binary the unit never declared stays refused as
identity_unresolved. For a fresh beacon that omits a declared unit:
| Endpoint evidence | Reconciliation |
|---|---|
observed |
Probe the declared unit. Stado adopts a corrected path or unit record only when the unit is loaded and its live process matches the declared program. If ownership cannot be proven, Stado records identity_unresolved, alerts once on the transition, and refuses to create a duplicate. |
unreachable |
Run the existing idempotent service ensure path. It creates a missing unit, restarts a matching definition, or performs a guarded definition reload. It checks the running postcondition and updates the registry when the host selected a different valid unit path. |
| not declared in the service directory | The unit has no endpoint to disprove, so the host channel is the evidence: the unit is probed on the box, a loaded unit must prove its live program before adoption, and only a unit the host itself reports absent is ensured. |
unverified |
Record endpoint_unverified, alert once on the transition, and make no change because endpoint absence was not proven. |
A product runs as one process per host, under its one catalog unit
com.wisent.<product>. Any other unit on a host that runs that product's
program is its predecessor, and Stado finds it from what it runs; the catalog
names no unit, and a catalog that still carries retired_units or
role_units is refused. A unit runs a product's program when its declared or
running program lies in the product's own service tree
(~/.stado/services/<name>/), is the exact program the catalog declares, or
is a file named after the product wherever it was installed.
For every product but Stado the predecessor is replaced whole: stado service ensure <product> retires it before it starts the product's unit (and gives it
back if that unit does not come up), withdraws its registry declaration in the
same write that records the product's unit, and the reconciler retires it on
every pass while the product runs there. For Stado the unit's work is one or
more roles of stado serve, read from the unit's own command line by Stado's
own command definitions (serve options and the one-pass product sync and
beacon commands a native timer repeats),
or matched to the live process's runner root, edge program or forward
destination. It is retired only once the live com.wisent.stado process is
proven to run every such role; the API listener's units are retired by that
process's own takeover at API start, and the resolver's by the listener
handoff. Until then the pass records the unit kept with the reason, and a
unit that runs the Stado program with no role it can be matched to is
reported kept with it runs <command>, the stado program, and no role of com.wisent.stado does that work.
A missing declaration of such a unit is never repaired. The pass records it as
retired, and stado service ensure refuses to declare one with the same
sentence: <unit> is retired: it runs the <product> program, whose work runs inside the one <product> process (<unit>), which retires it on this host; deploy <product> instead. A release_unit_image_revisit label filed under a
product that is no product's unit is a label that product ran under before:
stado registry set refuses it with the same sentence, and the next registry
write Stado makes rewrites it to that product's one unit. A label that is
another product's unit, or a block still invalid after that rewrite, is left
as it is; the write proceeds when it does not change the block and prints `release_unit_image_revisit`: <fault>.
stado registry doctor holds a registry that still declares such a unit to
the same contract. The unit is supposed to be absent, so the declaration is
reported as retired-unit-declared rather than as missing-plist or
unit-not-active, which would call the intended state a failure and point at
the repair Stado refuses. The row names the product whose process replaced
the unit and the command that forgets what is left of it:
stado service retire <unit> --host <host>. When the product whose program
the declaration runs cannot be read, the doctor cannot tell whether the unit
is retired: it reports retired-unit-unjudged with the cause and judges the
unit's own state as for any other unit, so a missing-plist or
unit-not-active row beside it may be the intended absence.
A unit in the fleet's label namespace that no product's program explains is
judged on its own (retire_stray). One that runs nothing and either last
exited non-zero or is held by no launchd domain any more is retired, its file
left in place; one that is loaded and live, or idle between scheduled runs, is
never stopped by the pass and is reported as undeclared_live with its
program, until it becomes a product's one unit or is removed. A program under
~/.stado/ is left alone. On a Linux host the scan reads the systemd unit
files in /etc/systemd/system and ~/.config/systemd/user.
GET /api/service/reconciliation serves the latest report, with the same
policy-read authorization as GET /api/registry.json. A store with no report
answers HTTP 404 with no service reconciliation is recorded in this store: the autonomy pass writes state/autonomy/services/latest.json when it runs; an
unreadable report answers HTTP 503 naming the read that failed. Stado
Desktop's Services screen shows the report under Reconciliation: the
time it was written, the mode, the tally, and every row with its host, unit,
action, classification and the pass's own detail, including each kept
reason and each undeclared_live program.
Every repair renders its unit through the same resolution chain
service ensure uses: the host's registry declaration, then the shipped
Wisent catalog, then the declaration bundled with the build. A declaration
that names only a unit path cannot be reinstalled from the document; the
repair records declaration_incomplete and alerts until the registry entry
carries its program and args, which is the durable fix — read the truth
with stado service show <name>, write it into the entry, and every future
repair renders from the document.
AutonomyMode::Report records the same plan without executing it.
EnforceSafe and EnforceOwned execute the reversible repair actions,
bounded by max_actions_per_tick, the emergency pause, the circuit breaker,
and a per-service mutation lease. Only a mutation that failed on a host feeds
the circuit breaker; declaration_incomplete and identity_unresolved are
refusals computed before any host command runs, and a refusal must not starve
the healthy repairs behind it. Recovery-managed units stay with the fixed
host-recovery program and are never silently converted into registry
services; the beacon exception asserts the unit without writing the registry
for them.
Known gap: a timer-driven oneshot beacon (Linux host-health-beacon.timer →
oneshot service) is a unit shape service ensure cannot yet express — it
asserts a running unit, and a oneshot exits by design. Declaring it as a
plain service would create a restart loop, so such hosts stay with their
installed timer, whose oneshot publishes through stado host beacon publish FILE; a timer an older build installed still runs it as host publish-beacon, and stado serve recognises that spelling as the health role too.
A stado serve that fails — a component stopped, or its start could not
finish — appends the stop to ~/.stado/serve-stops.jsonl before it exits:
{version, revision, at, failure, reported, report}, with the version and
source revision of the binary that stopped. A build that stops again on the
same host is a failed install, not a restart to wait out: on its second stop
Stado runs oko defects record --product stado once for that build, titled
“Stado VERSION stops on every start on HOST: FIRST LINE OF THE FAILURE”, found
in stado serve VERSION on HOST (a host Stado serves on holds no checkout Oko
could verify a revision against, so the revision is the evidence's first
line), with every stop and its failure as evidence, and keeps what Oko
answered on that stop (report): recorded: … marks it reported; oko defects record ended STATUS: STDERR or could not run: CAUSE leaves it
unreported, says the cause on stderr, and the next stop of the build tries
again. Oko is run from ~/.stado/bin/oko, where a Stado product install
places it, because the service unit that runs stado serve has no login
PATH; a host without Oko there reads oko defects record could not run: ~/.stado/bin/oko is installed at none of its declared paths: HOME/.stado/bin/oko.
Once a stop is reported, later stops of the build are kept and raise
nothing, so a crash loop is one defect, not one per restart; a new build
starts over. No count or window is Stado's: the second stop of a
build is the measurement. A stop that cannot be kept is said on stderr as
stado serve's stop could not be kept: CAUSE beside the service's own
failure, which it never replaces. The defect is read where every defect is,
oko defects list and Oko Desktop. stado resolver status shows the newest
build that stopped on the host under serve_stops (--json:
{version, revision, stops, first_at, latest_at, latest_failure, report, held},
null when stado serve never stopped there, {unreadable} with the cause
when the kept stops cannot be read), printed as serve stops stado VERSION (revision REV) stopped N time(s), first T, latest T: FAILURE; defect: REPORT (the recorded defect, or while none is, why the newest attempt
failed), followed by serve stops revision REV is held from source installs until the checkout moves past it when held is true (a stop of that
revision raised the defect, the hold below); Stado Desktop's route
operations read the same report as "Read this host's resolver".
A revision whose stops raised that defect is held from source installs on
the host. After stado release local restore --previous puts a working
build back, product sync's next pass would build the canonical checkout
again — the commit that stopped on every start — so a source install of a
revision with a reported stop in ~/.stado/serve-stops.jsonl is refused
before anything is exported: PRODUCT: revision REV is held: stado serve VERSION built from it stopped N time(s) on this host (PATH), the latest with: FAILURE; REPORT. A later commit is built as usual. A stop file that
cannot be read refuses the install with whether revision REV is held cannot be told: CAUSE rather than building past it. The hold lifts by
itself when the checkout moves to a commit with no reported stop; nothing
has to be cleared by hand. STADO_BIN=<built stado> STADO_PRODUCT_TEST_PRODUCT=<product with a stado-release service recipe> STADO_PRODUCT_TEST_SOURCE=<its checkout> node tests/product/held/revision.mjs runs both refusals in isolated homes under
.build/product-held-revision-* and keeps the commands, exit statuses and
refusals in report.json.
A product process that no unit owns is outside this whole loop: no beacon
names it and no ensure repairs or ends it. stado service list --unowned
finds them on every local host. A process is a product process when it
executes out of a managed root ($HOME/.stado/services, $HOME/.stado/work
and each catalogued product's install root) or when its working directory is
under one. The second rule finds a program started inside a root with a
relative command line, such as bash release/build.sh or
node node_modules/..., whose command line names no path under the root.
Every candidate carries a verdict and its evidence: owned with the pid
launchd or the systemd .service cgroup that claims it, or unowned. Each
root also prints how many pids it matched, so an empty table says why it is
empty.
stado service reap --host <host> --command '<program>' ends the copies of
one named program on one host that no declared unit holds. It considers the
same candidates as --unowned and keeps every process a declared unit
holds: the pid launchd or systemd reports for the unit, that pid's
descendants, and on Linux every process in the declared unit's cgroup, so a
child a double fork left without a parent is still kept. Kept rows are
printed and never signalled. Every other matching row reads would_end
until --apply sends it SIGKILL and waits for it to exit. A row that
survives reads still_running and the command exits non-zero. An empty
--command is refused with a command substring is required.
Useful operator views:
stado service list
stado service list --unowned
stado service reap --host <host> --command '<program>'
stado service verify
stado optimize status
stado optimize run
Source: this website