Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/jera/.nornir/agents-multimachine.md

jera agents — multi-machine worker-groups (the distributed-runner seam)

Class: design · strict "what it IS". Sibling: the job ledger (jobs) records that work ran; this module (jera::agent) is where it runs.

f0 — the whole

One shared runner layer for the constellation (nornir · dwarves · holger consume it — never a per-app twin, reuse-first law). An Agent is a worker in a named worker-group advertising AgentSpec{ id, group, caps, cores, mem_mb }. A Scheduler (the control-plane) matches a ready WorkUnit{ id, group, required_caps } to the first capable idle agent in the target group and dispatches it, returning a WorkHandle with bidirectional kill.

f1 — capability match (the schedule rule)

Agent::can_run(w) = idle ∧ agent.group == w.group ∧ w.required_caps ⊆ agent.caps. Scheduler::match_agent returns the first eligible agent in registration order (deterministic); dispatch returns None when none is eligible — the caller re-queues, never a silent drop (RAGNARÖK).

f2 — bidirectional lifecycle (WorkStatus)

Pending → Running → Done | Failed | Killed. Two kill directions, both real+tested:

f3 — the two backends (the seam)

impl what status
LocalAgent in-process, one std thread per unit; a LocalRunner closure does the work REAL, tested now
GrpcAgent a worker on another machine over gRPC skeleton — reports Offline so it's never scheduled; dispatch returns an immediately-Failed handle. TODOs mark the transport.

Going multi-machine = implementing GrpcAgent, not rewriting the scheduler. The Buildkite split: one control-plane (Scheduler, e.g. inside dwarves-server) + N <host> agent --group … workers registering their AgentSpec, then dispatch/poll/kill as gRPC calls. Everything above GrpcAgent stays byte-identical.

f4 — observability

Scheduler::state_json() exposes the fleet as data (id/group/caps/cores/mem/state) — feeds an "Agents" pane and a nornir-testmatrix cell at the consumer (nornir/dwarves), so the robot/matrix asserts the pool in code (remote-control-in-code law). jera stays lean (no testmatrix dep in this foundational crate; the cell lands where the pool is wired to a UI/op).

Tests (drive-in-code)

agent::tests: group+caps match→Done · no-eligible→None (no silent drop) · cancel→Killed · agent-offline→in-flight Killed · busy-not-rescheduled · GrpcAgent-offline-skeleton-never-scheduled · state_json-as-data. 7/7 green, pure std (no network).

The transport seam (remote path — loopback REAL now)

GrpcAgent no longer hard-codes Offline: it delegates state/dispatch/go_offline to an AgentTransport. Two impls:

Remaining wire bits: the agent-grpc proto (register/dispatch/kill/status) + tonic client/server; a <host> agent --group … worker that registers its AgentSpec and runs units. (dwarves already consumes the Scheduler + ships the dwarves agent CLI over the LocalAgent path — commit 055da45.)

Reuse / boundaries

Pure std (threads + atomics), additive to jera's public surface. Provisioning of the container/VM a remote unit runs in still goes through draupnir (jera::{container,vm} → policy::JobPolicy) — the agent layer schedules where, draupnir fires the instance.

agent-grpc wire — LANDED (feature agent-grpc, 2026-07-13)

The remote transport is now real (the drop-in the LoopbackTransport proved):

Proven in code (no manual run): grpc_dispatch_runs_to_done_over_the_wire + grpc_kill_propagates_to_killed_over_the_wire (in-process tonic on a loopback port). Scheduler/match/kill semantics unchanged — this only added the socket. Next: a <host> agent --group <name> worker binary that calls serve, and the control-plane registering GrpcAgents from a worker roster.

Real cross-machine runner — stages 1–3 LANDED (2026-07-14)

The seam above proved where work runs; these three additive stages make jera a real cross-machine runner (what runs, which fleet, which node ran it). Each is a green commit; the single-machine Scheduler/WorkUnit/LocalAgent API is untouched (L2 additive).

Stage 1 — payload-carrying WorkUnit (the keystone)

WorkUnit gained payload: Option<WorkPayload> (#[serde(default)], skip_serializing_if), so a unit now says what to run instead of relying on a fixed pre-bound closure. A payload-less unit decodes as None and keeps the legacy closure path — additive.

Stage 2 — durable node roster + heartbeat/liveness (agent::roster)

Roster is the durable list of fleet nodes (Loki 12c · Odin 32c · NUC 6c + Njord), each a NodeEntry { spec, endpoint, last_heartbeat_micros }.

Stage 3 — JobRecord.node (which machine ran it)

JobRecord gained node: Option<String> (#[serde(default)], non-breaking like parent_id). JobHandle::on_node("odin") (builder, re-upserts) stamps it; JobHandle::node() reads it back. A distributed job now records its machine; a local job stays None; a pre-field row decodes as None (no migration).

Stages 4–5 + wire auth — LANDED (2026-07-14)

The return leg, the secured wire, and resource-aware fleet placement — the pieces a serial HEAVY bench dispatched across the fleet needs to route heavy runs to Loki/Odin and record each machine's result centrally. All additive; the stage 1–3 API is byte-unchanged.

Stage 4 — worker result return (WorkResult → JobRecord.node)

WorkResult { job_id, unit_id, status, node, exit_code, result_ref, detail_json } is the terminal outcome a worker returns for a dispatched unit — serde-ready so it round-trips over the return wire.

Wire auth + worker entrypoint (secure the agent-grpc wire)

The agent-grpc wire was unauthenticated. It now composes the shared nornir-service bearer interceptor + serve loop (L5 — auth is not re-rolled):

Stage 5 — resource-aware placement + fleet-exclusive gate

ResourceReq { min_cores, min_mem_mb, tags } is a WorkUnit's placement floor — WorkUnit::with_resources(r); match_agent/can_run only route to a node that clears it.

Deferred remainder (documented)