Replace the single-purpose chat runtime with a persistent fleet daemon that provisions, adopts, and controls nodes through the dashboard. Add distributed job-runner actors and provider-backed deployment so jobs can materialize workspaces, execute remotely, and return outputs over iroh. |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| Cargo.toml | ||
| README.md | ||
provisioning — the cluster reconciler
This crate drives a declared cluster shape toward convergence. A caller states what the run should look like — which node groups, how many of each, with what provider shape and boot parameters — and the reconciler repeatedly compares that desired shape against observed reality, taking the next safe step for each node until the two match. It is modeled on Kubernetes controller mechanics (level-triggered decisions, spec/status separation, workqueue-style coalescing, finalizer-style deletion) but runs entirely in-process over the Swactor/Myelin engine: there is no API server, and no persistence beyond the process lifetime.
The payoff over the imperative lifecycle it replaced: the system converges from
whatever state it is currently in. A reconcile pass is a pure function of
(observed, desired, now) — never of the event that triggered it — so missed
events, duplicated events, and crash-of-a-single-pass all heal on the next
pass. Reconcile is a function of state, not events.
The three roles
One state-ownership rule upholds the design: only the driver mutates observed state.
| Role | Embodiment | Responsibility |
|---|---|---|
| Decider | reconcile / reconcile_node |
Pure, deterministic: reads state snapshots, returns next actions. No I/O, no clocks, no randomness. |
| Driver | ClusterDriver |
Sole writer of ClusterState. Folds observations, coalesces triggers, runs passes, records operations as pending before dispatch, schedules requeues. |
| Executor | EffectExecutor / IdempotentEffectExecutor + a provider EffectBackend |
Runs provider I/O off the pass, deduplicates by operation identity, adopts resources after ambiguous outcomes. |
Inputs
Desired state — ClusterShape { run_id, generation, groups }. It expands to
one LogicalNodeSpec per slot named {group_id}-{index}; validation rejects
cross-run groups, duplicate group or node IDs, and non-finite shape values.
The driver additionally enforces a revision contract: the run ID is fixed for
its lifetime, generation must strictly increase whenever shape content
changes, and changed content at the same generation is rejected. A node's spec
is an immutable attempt template — any drift (image, boot, role, provider,
swarm-join) means replace the attempt, never mutate it in place.
Observed state — ClusterState: the last-evaluated generation, a monotonic
attempt-ID allocator, and one ManagedNode per logical slot. A ManagedNode
carries its attempt ID, intent (Active / Deleting), the lifecycle-fact
record (NodeRecord), at most one pending operation, and per-node retry
state. Identity is layered: a LogicalNodeId is the stable slot;
a NodeAttemptId names one incarnation of that slot (like a k8s object name
vs its UID); an OperationId (attempt + sequence) names one dispatched effect.
Results from an old attempt can never mutate a newer one.
Events — executor results, bootstrap stream observations, timeouts, and a periodic tick. Events carry no decision input; they only mark the cluster dirty and are folded into observed state before the next pass looks.
Reconciliation flow
Per node, progress is a ladder of stages crossed by one effect at a time, with a deletion track that runs to completion once entered:
stateDiagram-v2
[*] --> New : Insert (desired slot, fresh attempt)
New --> LeaseRequested : Dispatch CreateLease
LeaseRequested --> LeaseCreated : lease observed, endpoint unknown
LeaseRequested --> EndpointKnown : lease + endpoint observed
LeaseCreated --> EndpointKnown : LookupEndpoint succeeds
LeaseCreated --> LeaseCreated : LookupEndpoint : not ready yet (probe)
EndpointKnown --> BootstrapRunning : StartBootstrap accepted
BootstrapRunning --> BootstrapRunning : bootstrap stream observations
BootstrapRunning --> SwactorJoined : swactor joins the swarm
SwactorJoined --> Dormant : convergence observed / bootstrap closed
Dormant --> [*] : ready steady state
LeaseRequested --> LeaseRequested : CreateLease fails (backoff, retry)
EndpointKnown --> Failed : bootstrap fails to start
BootstrapRunning --> Failed : bootstrap runtime / join failure
SwactorJoined --> Failed : bootstrap closes before convergence
Failed --> Deleting : BeginDelete (next pass)
New --> Deleting : BeginDelete (slot undesired or spec drifted)
Dormant --> Deleting : BeginDelete (scale-down / replacement)
Deleting --> Deleting : CancelBootstrap → DestroyLease (in order)
Deleting --> Destroyed : cleanup complete (MarkDestroyed)
Destroyed --> [*] : Reap (slot undesired)
Destroyed --> New : Restart after restart_at backoff (still desired)
A pass picks at most one action per node; a driver transition
(Insert, BeginDelete, MarkDestroyed, Restart, Reap) completes that
node's step, and its follow-on effect is considered in a later pass. Nodes
progress independently — one node's slow provider I/O never blocks another.
The per-pass decision ladder
For each node, the decider's rules in priority order (first match wins):
| # | Condition | Action |
|---|---|---|
| 1 | stage Destroyed, slot undesired |
Reap — remove from the map |
| 2 | stage Destroyed, slot desired, restart_at due |
Restart — fresh attempt, latest spec |
| 3 | stage Destroyed, restart backoff not due |
wait until restart_at |
| 4 | intent Active and (undesired, spec drift, or stage Failed) |
BeginDelete |
| 5 | an operation is pending | wait for its result or stored deadline |
| 6 | intent Deleting, ambiguous create/bootstrap remembered |
re-dispatch that create (executor adopts) |
| 7 | intent Deleting, active bootstrap session |
CancelBootstrap |
| 8 | intent Deleting, lease still live |
DestroyLease |
| 9 | intent Deleting, nothing left to clean |
MarkDestroyed |
| 10 | retry backoff (next_effect_at) not due |
wait |
| 11 | ready in HandedOff / Dormant |
none — steady state |
| 12 | no lease | CreateLease |
| 13 | lease but no SSH endpoint | LookupEndpoint |
| 14 | stage SwactorJoined with live session |
BootstrapConvergenceObserved |
| 15 | bootstrap running, awaiting observations | none — await stream events |
| 16 | lease + endpoint, no bootstrap session | StartBootstrap |
Rows 1–4 handle topology (scale up is an Insert seen before row 1); rows
5–10 handle in-flight work and deletion; rows 11–16 are the healthy
progression ladder. Cleanup ordering is deliberately sequential — cancel
bootstrap, then destroy the lease, then mark destroyed — so partial success is
never ambiguous.
Triggers and requeues
The driver is the process-local equivalent of a single-key Kubernetes workqueue: one pass runs at a time (reentry is an error), triggers while queued collapse, and a trigger during a pass marks dirty and guarantees exactly one follow-up pass.
| Trigger | Source | Effect |
|---|---|---|
| Desired shape update | update_desired (validated, generation advanced) |
queue a pass |
| Executor result | operation completed / failed | fold observation, queue a pass |
| Bootstrap observation | stream stage, swactor join, closure, failure | fold observation, queue a pass |
| Operation timeout | stored pending-operation deadline | fold as ambiguous failure, queue a pass |
| Retry / probe / restart deadline | trigger_if_due(now) against requeue_at |
queue a pass |
| Periodic wake | host tick (safety net, not the progress mechanism) | queue a pass if due |
Every pass recomputes requeue_at as the earliest deadline among waiting
nodes (pending-operation deadlines, backoff, probes, restarts). The host
(apps/myelin's ProvisionedClusterGuard) drives drive_until_blocked on
each wake and re-arms the timer.
Node conditions
NodeStage is the observation ladder; ready is the convergence flag:
| Stage | Meaning |
|---|---|
New |
slot inserted, nothing dispatched yet |
LeaseRequested |
CreateLease dispatched, pending |
LeaseCreated |
provider lease exists; SSH endpoint not yet known |
EndpointKnown |
lease + reachable SSH endpoint recorded |
BootstrapRunning |
bootstrap session started; stream observations flowing |
SwactorJoined |
the node's swactor joined the swarm |
HandedOff |
host marked handoff complete (reserved; the ready-check accepts it) |
Dormant |
bootstrap finished, handoff recorded — ready steady state |
Failed |
attempt-ending failure recorded (failed_reason, failed_at) |
Destroyed |
cleanup finished; awaiting reap or restart |
Bootstrap internals (BootstrapStage: SSH connect, boot check, swactor start,
join, converged, plus five failure stages) are facts folded into the record;
they update progress but the reconciler only branches on their failure/converged
classes, never on individual stream events.
Failure and backoff
Not every failed call kills an attempt. Classification by operation:
| Failure | Retained state | Behavior |
|---|---|---|
CreateLease |
nothing | retry after exponential backoff |
LookupEndpoint (not ready) |
lease | re-probe on probe interval — not a failure |
LookupEndpoint (error) |
lease | retry after backoff |
StartBootstrap, bootstrap runtime, or join |
facts for observability | attempt fails: cleanup starts immediately; backoff applies to the restart, not the cleanup |
CancelBootstrap / DestroyLease |
stay in Deleting |
retry after backoff |
| any timeout / ambiguous create | remembered | retry re-issues the same create so the executor adopts first |
Backoff is per node: exponential from 1 s to a 60 s cap (defaults), with
optional jitter sampled deterministically from the attempt ID — the decider
never reads randomness or a clock. Deadlines are computed once when an
observation is folded and stored; the pure decider only reads them. Reaching
ready resets the failure count. A failed attempt's consecutive_failures
carries into its replacement so hot-restart loops still back off.
What is guaranteed
| Class | Guarantee |
|---|---|
| Determinism | Identical traces converge to identical state; execution order of independent work doesn't matter; a pass over a settled machine is a no-op. |
| Attempt isolation | Attempt IDs are never reused; results and facts from a superseded or retired attempt are discarded, never folded or leaked into a replacement. |
| No unrecorded effects | Every effect is recorded as pending before submission; one pending operation per node, one running per attempt; a destroyed node holds no lease, session, or pending operation; failed cleanup keeps its live facts. |
| Failure classification | Per-node backoff with one stored deadline; attempt failure cleans up immediately and delays only the restart; endpoint-not-ready is a probe, not a failure; ambiguous outcomes adopt before any destructive step; exhaustion and clock saturation are errors, never spins or panics. |
| Bounded convergence | Converges to the latest desired shape — intermediate generations may be skipped — in bounded rounds once faults stop; scale-down removes only highest-index slots; replacement starts a fresh attempt only after full cleanup; generation regressions and silent shape changes are rejected. |
Operation identity and idempotency
A deterministic plan is not by itself a safe side effect; safety comes from the identity contract:
- The driver records an operation as pending before dispatch and never emits a second operation for a node while one is pending. If submission itself fails, that folds as an operation failure — no unrecorded in-flight effect is ever observable.
- The executor deduplicates by
OperationId: resubmitting a completed operation replays its recorded result; reusing an ID with different input is rejected; at most one operation runs per attempt at a time. - Provider backends must key external requests on
(run_id, logical_node_id, attempt)and adopt an existing resource for that identity before creating anew; cancel/destroy treat "already absent" as success. - Timeouts expire an operation as ambiguous only after the executor classifies it; a late completion is discarded rather than folded.
Convergence and boundaries
The cluster is converged for a generation when every desired slot holds the
exact desired spec with ready, Active intent, and no pending operation,
and no undesired or deleting nodes remain. observed_generation == generation alone means only "the driver has evaluated that shape," not
readiness.
Deliberately out of scope (v1): persistence and crash recovery — the identity
and adoption rules are the shape a later durability guarantee would build on —
leader election, availability-budgeted rollouts, and any provider-specific
behavior (backends live in application crates). The normative design spec,
including the full invariants list, is archived at
docs/specs/archive/RECONCILER_SPEC.md.