Skip to content

Pool head: reconciler, controller, membership and a fleet simulator - #60

Closed
data-angel wants to merge 9 commits into
pool-planner-phase1from
pool-head-sim
Closed

data-angel wants to merge 9 commits into
pool-planner-phase1from
pool-head-sim

Conversation

@data-angel

Copy link
Copy Markdown
Contributor

Summary

Stacked on #59 (lloom pool plan). This PR adds the rest of the pool head and tests it all in a deterministic fleet simulator. Nothing here is wired into a live gateway's control loop.

  • The reconciler and controller run only in the simulator.
  • Membership is inert until a token is issued.
  • Two changes do touch live paths: admission and the runtime manager. See "Live-path changes" below.

What's in it

  • Reconciler (src/pool-reconciler.mjs): turns a plan into ordered make-before-break steps: prefetch → start → route flip → drain and stop.
    • Residency downgrades happen before stops.
    • When the old lane must go first, a swap moves its aliases to an interim member, then back. If there is no interim member, the interruption is reported.
    • When a step fails, everything that depends on it is skipped. A lane that never starts therefore never strands its aliases.
    • Every step has a timeout and can be cancelled.
  • Controller (src/pool-controller.mjs): runs in off, advise or auto mode.
    • Changes that restore coverage, or vacate a cordoned node, act at once.
    • Other changes need a stable plan, a minimum gain, a cooldown and room in an hourly load budget.
    • Residency-only changes get slower hysteresis.
    • A failed start is backed off.
    • An urgent plan with different routes preempts the change in progress.
    • Ticks never overlap.
  • Membership (src/pool-membership*.mjs, src/pool-node.mjs): enrollment tokens, node leases and heartbeats, and cordon, drain, uncordon and leave. It comes with HTTP routes and CLI commands, and feeds liveness into pool plan.
  • Simulator (src/pool-sim.mjs, lloom pool simulate): a discrete-event model of the fleet.
    • It models requests, admission with LRU eviction, keep-warm restore, memory-guard aborts and the latch, node hangs, failures and partitions, membership leases, and operator actions.
    • The real planner, reconciler and controller run against it.
    • Seven scenarios in test/fixtures/pool/scenarios/ compare today's behaviour (off) with the controller (auto).

Live-path changes (please review closely)

  • Admission now keeps the memory guard's margin free (src/runtime-policy.mjs).
    • Problem: the simulator found that admission kept only reserveMemoryGb (8 GB) free, while the guard aborts at 90% use (12.8 GB free on a 128 GB Spark). Admission could approve a load the guard was certain to abort, and the guard then latched the runtime until a manual start.
    • Now: the host check reserves max(reserve, total × (1 − ceiling)), and cluster node budgets are capped at the ceiling (never below 0).
    • Effect: admission is more conservative on large hosts. It refuses or evicts earlier, instead of letting a load fail mid-way.
  • The runtime manager records guard aborts (src/runtime-manager.mjs). Status now includes memorySafetyAbort (usedGb, plus consumedGb, a lower bound on the load's footprint) and memorySafetyLatched. A healthy start clears it.
  • The pool planner caps host budgets at the guard ceiling. On the fixture fleet this exposes a real conflict in the hand-written standalone profile: video cannot load next to a keep-warm 27B on spark-node-03.

Scenario results (seeded simulation, not benchmarks)

Scenario off (today) auto
pair-crash SLO 98.4%, 12 to cloud, 60 cold waits, 1414 GB reloaded SLO 99.5%, 0 to cloud, 26 cold waits, 1008 GB reloaded, back on the pair after recovery
lease-loss same as above acts 2 min after the crash (lease lost at 90 s); a 60 s partition moves nothing
mac-joins cognition stays on the 27B prefetches weights, moves cognition to the Mac, 0 failed requests
pair-taken operator switches profiles by hand cordon/uncordon only; one apply each way
demand-flap – ≤ 1 residency change per hour, no extra loads

Reviews

The project rules require a full code and security review before commit, so both ran on this diff, and every verified finding is fixed with a test.

  • Security. The one medium finding was a node-id takeover: any token could re-enroll an existing node. Tokens can now be bound with --node-id, and claiming a member or a configured peer requires a bound token. Low findings fixed:

    • Heartbeats are checked for the node-secret format before the body is read, and are rate limited.
    • The per-client rate limiter evicts least-recently-used entries instead of clearing everything.
    • Control characters in node-reported text are dropped.
    • A failed join reports the secret file path and what to set by hand.
    • Joining over plain HTTP prints a warning.
  • Correctness. Six bugs fixed:

    • a lane whose model moved to another node was never stopped;
    • a lane already stopping was treated as freed memory;
    • the planner's guard ceiling could exceed the guard's own;
    • a node with unknown capacity got a negative admission budget;
    • abort evidence was never cleared;
    • an interim route was never reverted.

    Hardening items from the same review are in the last commit.

Tests

  • node --test test/pool-*.test.mjs: 109 pass.
  • Every test file touching admission, the runtime manager or the server, run individually: all pass.
  • Also passing: test:cluster, security, admin-browser-security, node-onboarding, cli-diagnostics, smoke, runtime-policy, runtime-memory-safety, check:js, prettier --check, and eslint (pre-existing warnings only).
  • Not run: the full npm test.

Not in this PR

  • A real actuator that maps reconcile steps onto the runtime manager, routes and residency, and wiring the controller into serve. Advise mode first.
  • Capacity-aware planning (replicas for overloaded lanes) and interference between co-located lanes. The simulator models capacity; the planner doesn't use it yet.

🤖 Generated with Claude Code

data-angel and others added 9 commits October 11, 2026 12:21
- pool-reconciler: orders a plan into make-before-break steps (prefetch,
  start, route, drain+stop), swaps through interim members, residency
  downgrades before stops; dependency-aware executor skips dependents of a
  failed step so a lane that never starts never strands its aliases.
- pool-controller: off/advise/auto loop with urgency (coverage restored),
  stable-plan confirmation, cooldowns, an hourly load budget, slower
  hysteresis for residency-only changes, and failure backoff.
- pool-sim: discrete-event fleet model (requests, admission + LRU eviction,
  keep-warm restore, memory guard aborts, node hangs/failures, operator
  actions) driving the real planner/reconciler/controller; scenarios for
  pair taken, pair crash, a Mac joining, a low memory estimate and noisy
  demand, compared against today's behaviour (off).
- planner: budgets capped at the runtime memory guard ceiling (0.9 by
  default) so plans never rely on loads the guard would abort; variants can
  exclude nodes after failed starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes can join a head with a one-time enrollment token and keep a lease
with heartbeats from `serve`, so the pool planner knows who is in the
pool. Membership is inert until a token is issued; static cluster.nodes
config and `cluster add-node` are unchanged.

- src/pool-membership.mjs: pure membership core (tokens, enroll,
  heartbeat, cordon/drain/leave, ready/suspect/lost/left leases, planner
  node shape). Tokens and node secrets are stored only as SHA-256 hashes.
- src/pool-membership-store.mjs: pool-membership.json next to the config,
  loaded on first use, written atomically (0600) only on changes.
- Head routes: POST /gateway/pool/enroll (token only, rate limited) and
  /gateway/pool/heartbeat (node secret only) authenticate in their
  handlers; GET /gateway/pool/members is admin-read; tokens and
  cordon/uncordon/drain are admin writes.
- src/pool-node.mjs: `lloom pool join` and the serve heartbeat loop with
  backoff; the secret lives in a private file or an env var.
- CLI: lloom pool token create | members | cordon | uncordon | drain | join.
- Planner: members merge with configured nodes (config wins on endpoint
  and auth; membership adds liveness, cordon and inventory). Enrolled-only
  nodes are planned but flagged as needing `cluster add-node` to route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fleet simulator showed admission could approve a load the runtime
memory guard was certain to abort: admission kept only reserveMemoryGb
(8 GB) free, while the guard trips at 90% host use (12.8 GB free on a
128 GB Spark). After an abort the runtime is latched until a manual
start, so the lane stayed down.

- runtime-policy: the host check keeps max(reserve, guard margin) free and
  cluster node budgets are capped at the guard ceiling.
- runtime-memory-safety: memorySafetyCeiling() shared by the guard,
  admission and pool planning.
- runtime-manager: a guard abort records usedGb and consumedGb (the load's
  lower-bound footprint) and status reports memorySafetyLatched.
- pool-catalog: raises a variant's memory to the abort lower bound and
  excludes a latched lane until it is started by hand.
- reconciler: per-step timeouts so a stuck step cannot wedge the
  controller; simulator found and now covers a drain that never settled.
- simulator: models the latch and fixed admission (legacyAdmission keeps
  the old behaviour for comparison) and logs controller hold reasons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The simulator can run nodes through pool membership (enroll, heartbeat,
  lease loss) so node loss is detected the way a head detects it; new
  lease-loss scenario (crash plus a harmless 60 s partition).
- The scenario found the controller blocked for 30 minutes on a manual
  restart of a lane whose node had just died. Stability now tracks the
  moves, not just the plan; an urgent plan for a different target cancels
  a change in progress (executeReconcile takes an AbortSignal); the same
  target never preempts itself.
- Reconciler re-issues an idempotent start for lanes still loading so
  route flips wait for them.
- Register membership tests in check:js and test:unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- lloom pool simulate <scenario> [--mode off|advise|auto|compare]: runs a
  scenario through the planner, reconciler and controller; exits non-zero
  when expectations fail.
- docs/pool-planning.md: reconciling, the controller, simulation and the
  scenario results; runtime-policy.md: the guard margin in admission.
- Membership tests sliced the token body with split('_'), but base64url
  bodies can contain '_', so a short fragment sometimes matched the state
  JSON; slice the fixed prefix instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security review findings:
- Medium: any enrollment token could re-enroll any node id, replacing a
  live node's secret, endpoint and reported capacity (and flipping a
  configured peer unavailable in plans). Tokens can now be bound to a node
  id (lloom pool token create --node-id); claiming a current member or a
  configured cluster.nodes id needs a token bound to it (409 otherwise).
  lloom pool leave / POST /gateway/pool/nodes/:id/leave frees an id.
- Heartbeats are refused before the body is read unless the bearer has the
  node-secret shape, and are rate limited per client.
- Rate limiters evict least recently used clients instead of clearing the
  whole map when full.
- Node-reported text with control characters is dropped (no escape
  sequences in lloom pool members output).
- join reports the saved secret path and the pool.head to set when the
  config update fails after enrolling, and warns about plain HTTP heads
  off loopback and tailnets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bugs (each with a test):
- A model that moved between nodes left its old lane running: an unplanned
  lane is now retired once every planned lane serving its models is up.
- Lanes already stopping were treated as freed memory; they now hold it and
  get an idempotent stop that starts on that node wait for.
- The planner could plan past the guard when only memorySafety was set; the
  catalog now passes memorySafetyCeiling() itself.
- A cluster node with unknown capacity got a negative admission budget,
  blocking no-request plans; the clamp is floored at zero, and the guard
  margin rounds up.
- Memory-safety abort evidence never cleared; a healthy start clears it and
  a measured footprint supersedes it.
- An alias moved to an interim member during a swap was never moved back;
  the flip back is queued and ordered after the interim flip.

Hardening: ticks never overlap; throwing observers cannot wedge the
executor or controller; concurrency is at least 1; a lane already loading
is not charged to the load budget again; one move larger than the budget
may go in an empty hour; only route changes preempt; vacating a cordoned
or draining node is urgent. The simulator reports draining lanes as
stopping and the controller's last plan, and memory-estimate-low now
asserts the abort, the latch and video planned as uncovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@data-angel

Copy link
Copy Markdown
Contributor Author

Superseded by #74, which integrates the reconciler/controller/membership/simulator work rebased onto current main (package.json and CHANGELOG resolved against main; pool sources/tests added to check:js and test:unit).

@data-angel data-angel closed this Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant