Build: RemoteWorker and the Worker Binary
Maps to: Phase 6 (cluster). Kind: Build.
Objective
Close the seam. You have a wire (MessageStream) and, from the concept chapter, the shape of the machine that drives it. Here you build both ends: on the coordinator, a RemoteWorker that is a WorkerHandle like any other, a connection_actor that multiplexes one socket, a SharedPool the scheduler snapshots, and an accept loop that turns each incoming connection into a registered worker. On the far side, the panoptes-worker binary that connects, registers, and serves eval jobs. When this compiles green, the scheduler can dispatch a job to a process on another machine and never know the difference — the promise Part I made, paid in full.
This is the largest build in the course, and it spans two crates. Take it one test at a time; each named test locks one piece.
Scaffold
Create — coordinator side (panoptes-control):
crates/panoptes-control/src/remote.rs—RemoteWorker,SharedPool,connection_actor,serve_workers,handle_connection, and two tests.
Edit:
crates/panoptes-control/src/lib.rs— addpub mod remote;and re-exportSharedPool,serve_workers.crates/panoptes-control/Cargo.toml— no new external deps;tokio(withnet),async-trait, andcontrol-coreare already present. Thetokio::sync::{mpsc, oneshot}primitives are part oftokio'ssync/fullfeatures you already enabled.
Create — worker side (new crate panoptes-worker; add crates/panoptes-worker to the workspace members):
crates/panoptes-worker/Cargo.toml[dependencies]:control-core,control-eval(both{ path = ... }), plustokio(features["full"]),async-trait,anyhow,clap(features["derive"]),tracing,tracing-subscriber(features["env-filter"]) — all{ workspace = true }where the workspace pins them.control-evalgives youModelClient,HttpModelClient, andrun_eval— the workload the worker runs.clapparses the binary's flags;tracing-subscribersets up logging inmain.
[dev-dependencies]:async-trait,tokio(for theStubClientand#[tokio::test]).
crates/panoptes-worker/src/lib.rs—run_session,serve_worker,run_worker_forever,DEFAULT_HEARTBEAT, and one test.crates/panoptes-worker/src/main.rs— the binary:Cli,#[tokio::main], wire uprun_worker_forever.
Expected result:
cargo test -p panoptes-control→ the scheduler-arc tests still pass, plus 2 new:remote_worker_dispatches_over_the_wire,worker_death_makes_dispatch_retryable.cargo test -p panoptes-worker→ 1 test:worker_registers_runs_a_job_and_returns_a_result.
The spec (givens)
RemoteWorker and SharedPool
/// A job handed to the connection actor plus the channel to answer it on.
type Dispatch = (Job, oneshot::Sender<Result<JobOutcome, ControlError>>);
/// Coordinator-side handle to a worker across a TCP connection. To the scheduler
/// it is an ordinary `WorkerHandle`; `dispatch` hides the round-trip.
pub struct RemoteWorker {
id: String,
tx: mpsc::Sender<Dispatch>,
}
#[async_trait]
impl WorkerHandle for RemoteWorker {
fn id(&self) -> &str;
async fn dispatch(&self, job: Job) -> Result<JobOutcome, ControlError>;
}
/// A live, shared set of workers. The accept loop adds one on register and
/// removes it on disconnect; the scheduler snapshots it per run. Cheap to clone.
#[derive(Clone, Default)]
pub struct SharedPool {
workers: Arc<Mutex<Vec<Arc<dyn WorkerHandle>>>>,
}
RemoteWorker::dispatchis the whole illusion: make aoneshot,self.tx.send((job, reply_tx)).await, thenreply_rx.await. Both failure paths map toControlError::Worker(...)— asenderror means the actor (and thus the connection) is already gone; a droppedreply_rxmeans the actor died with the job in flight. Both are retryable, which is exactly what makes the scheduler redeliver. Note the double?/unwrap shape:reply_rx.awaityieldsResult<Result<JobOutcome, ControlError>, RecvError>— the outer error is the dropped channel, the inner is the actor's own answer.SharedPoolneedsadd(Arc<dyn WorkerHandle>),remove(&str)(retain byid()),len(),is_empty(), and animpl WorkerSourcewhosesnapshot(&self) -> Vec<Arc<dyn WorkerHandle>>clones the inner vec.WorkerSourceis the scheduler-arc trait — this is the second implementation of it, the remote counterpart to the local pool, and the scheduler cannot tell them apart.
connection_actor
async fn connection_actor(
mut conn: MessageStream,
mut jobs: mpsc::Receiver<Dispatch>,
capacity: usize,
);
Own the socket; multiplex it. Keep a HashMap<JobId, oneshot::Sender<Result<JobOutcome, ControlError>>> of in-flight jobs. Loop over a tokio::select! of two arms:
maybe = jobs.recv(), if pending.len() < capacity— the guarded arm. OnSome((job, reply)): capturejob.id, sendMessage::Assign { job }overconn; if that send fails, answerreplywith the error and break (the wire is broken); otherwise insertreplyintopendingunder the job id. OnNone(allRemoteWorkerhandles dropped), break.frame = conn.recv()— the socket arm. Match:Ok(Some(Message::Result { outcome }))→ removeoutcome.job_idfrompendingand sendOk(outcome)to its waiter;Ok(Some(Message::Heartbeat { .. }))→ ignore (liveness only);Ok(Some(_))→ ignore (an inboundRegister/Assignis a protocol slip, not fatal);Ok(None) | Err(_)→ break (closed or malformed).
After the loop, the crucial cleanup: drain pending and send each waiter Err(ControlError::Worker("worker connection lost".into())). Retryable, so every job the dead worker was holding gets redelivered. This is the line the concept chapter called the pivot of the arc.
serve_workers and handle_connection
/// Accept worker connections forever, registering each into `pool`.
pub async fn serve_workers(listener: TcpListener, pool: SharedPool);
/// Register one connection and run its actor until the socket closes.
async fn handle_connection(sock: TcpStream, pool: SharedPool);
serve_workersloops onlistener.accept(); for each socket, clone the pool andtokio::spawn(handle_connection(sock, pool)). Anaccepterror should not kill the loop —continue.handle_connectionruns the handshake: wrap the socket in aMessageStream,recvthe first frame, and require it to beMessage::Register { worker_id, capacity }. Anything else (or a closed/errored socket) → return, dropping the connection. On a valid register: makempsc::channel(capacity)(clamp capacity to at least 1), buildArc::new(RemoteWorker { id: worker_id, tx }),pool.add(...), runconnection_actor(conn, jobs, capacity).await, and when it returns,pool.remove(&worker_id). Add-then-actor-then-remove is the worker's whole lifecycle in the pool.
The worker binary — run_session, serve_worker, run_worker_forever, main
pub const DEFAULT_HEARTBEAT: Duration = Duration::from_secs(5);
/// Register, then serve jobs until the coordinator closes (`Ok`) or the wire fails (`Err`).
pub async fn run_session(
mut conn: MessageStream,
worker_id: &str,
capacity: u32,
client: &dyn ModelClient,
heartbeat: Duration,
) -> Result<(), ControlError>;
/// Connect to the coordinator and run one session.
pub async fn serve_worker(
coordinator: &str,
worker_id: &str,
capacity: u32,
client: Arc<dyn ModelClient>,
heartbeat: Duration,
) -> Result<(), ControlError>;
/// Serve forever, reconnecting after any disconnect.
pub async fn run_worker_forever(
coordinator: &str,
worker_id: &str,
capacity: u32,
client: Arc<dyn ModelClient>,
heartbeat: Duration,
reconnect_delay: Duration,
);
run_sessionfirst sendsMessage::Register { worker_id, capacity }. Then it sets up atokio::time::interval(heartbeat)and skips the immediate first tick. Then it loops aselect!of two arms:beat.tick()→ sendMessage::Heartbeat { worker_id };conn.recv()→ matchSome(Message::Assign { job })(capturejob.id,run_eval(client, &job.spec).await?, then sendMessage::Result { outcome: JobOutcome { job_id, records } }),Some(_)(ignore — the coordinator only ever sendsAssign),None(clean close → returnOk(())). A failed eval?-propagates and ends the session; the coordinator will notice the dropped connection and redeliver. The worker is deliberately simple: no map, no multiplexing — that all lives on the coordinator.serve_workerconnects aTcpStream, wraps it inMessageStream::new, and callsrun_session.run_worker_foreverloopsserve_workerforever, logging the outcome (Ok→ coordinator closed;Err→ session failed) and sleepingreconnect_delaybetween attempts. Workers are cattle; supervision is just a loop.mainis#[tokio::main]: parse aclapCli(a--coordinatoraddress,--model-apibase URL,--model,--id,--capacity), init atracing_subscriberwith anEnvFilter(defaultinfo), buildArc::new(HttpModelClient::new(model_api, model)), log a startup line, and callrun_worker_forever(..., DEFAULT_HEARTBEAT, Duration::from_secs(1)).
Concepts exercised
- A second
impl WorkerHandle(RemoteWorker) and a secondimpl WorkerSource(SharedPool) — the distributed layer arriving as new implementations of existing seams, the scheduler untouched. - The connection actor:
select!with a capacity guard, an id→waiterHashMap, and retryable failure of all pending jobs on close. - A TCP accept loop that spawns one task per connection and a register handshake that gates the connection.
- The worker session loop: interleaving heartbeats with job service over one
MessageStreamviaselect!. - A supervised reconnect loop and a
clapbinary that composes the whole worker.
The build loop (you drive)
Test 1 — remote_worker_dispatches_over_the_wire (in remote.rs, #[tokio::test])
- Write the failing test. Stand up a coordinator: bind
127.0.0.1:0, make aSharedPool, spawnserve_workers(listener, pool.clone()). Spawn a fake worker task: connect,sendRegister { worker_id: "w1", capacity: 2 }, then looprecvand for eachAssign { job }build oneResponseRecordper vignette andsendbackResult { outcome: JobOutcome { job_id: job.id, records } }. Poll the pool until it holds 1 worker,snapshot().pop()it,dispatch(a_job()).await.unwrap(), and assert the outcome has one record. - Predict: the fake worker answers on the same socket the actor is reading. Which task inserts into
pending, and which removes from it? Trace the one job'soneshotfromdispatchto the assert. - Run — fails to compile (nothing built yet).
- Implement
RemoteWorker,SharedPool,connection_actor,serve_workers,handle_connection. - Run green, commit.
Test 2 — worker_death_makes_dispatch_retryable (in remote.rs, #[tokio::test])
- Write the failing test. Same coordinator. This fake worker registers (
capacity: 1), receives its oneAssign, then drops the socket without answering — a crash mid-job. Poll for 1 worker, snapshot-pop it,dispatch(a_job()).await.unwrap_err(), and asserterr.is_retryable(). - Predict: the worker never sends a
Result, so thependingentry is never removed by the socket arm. What removes it, and what error does the waiter'soneshotreceive? Which cleanup path inconnection_actorfires? - Run, confirm the assertion pins the retryable error.
- Run green, commit.
pending — what would dispatch in test 2 observe? (Hint: dropping a oneshot::Sender without sending closes the channel.) Would the test still pass? Reason it through, then write the explicit cleanup anyway, because the message you attach — ControlError::Worker, retryable — is the part that actually matters to the scheduler.
Test 3 — worker_registers_runs_a_job_and_returns_a_result (in panoptes-worker, #[tokio::test])
- Write the failing test. This time the coordinator is faked and the worker is real. Define a
StubClient: ModelClient(no network —generatereturnsModelResponse { text: format!("re: {prompt}"), usage: .. }). Bind127.0.0.1:0; spawn a fake coordinator thataccepts, asserts the first frame isRegister, sends oneAssign { job }(a job with one vignette"a"/"hi"), then loopsrecvignoring heartbeats until it seesResult { outcome }and assertsoutcome.job_idmatches, one record,records[0].response == "re: hi". In the main task, runserve_worker(addr, "w1", 1, Arc::new(StubClient), Duration::from_secs(30))under atokio::time::timeout(it serves forever, so time it out). - Predict: the coordinator sends one
Assignand then never closes. Why does wrappingserve_workerin atimeoutmatter — what would happen without it, and is that a bug or expected? - Run — fails to compile (worker crate not built).
- Implement
run_session,serve_worker,run_worker_forever, andmain.rs. - Run green, commit.
interval's first tick fires immediately — call beat.tick().await once before the loop, or the worker heartbeats the instant it registers, before doing anything useful. On the coordinator, handle_connection must treat the first frame as the handshake: if you fold the register into the actor's normal loop, an actor with an empty pending map has nothing to key the connection on and the pool never learns the worker's id or capacity. Register first, then the actor.
Done when
cargo test -p panoptes-control shows the scheduler-arc tests plus remote_worker_dispatches_over_the_wire and worker_death_makes_dispatch_retryable green; cargo test -p panoptes-worker shows worker_registers_runs_a_job_and_returns_a_result green; a whole-workspace cargo test is green; and you can trace one job from RemoteWorker::dispatch, through the actor's Assign, across the socket to the worker's run_eval, back as a Result, and out the job's oneshot — and say why a worker's death turns that same path into a retryable error. Commit. The scheduler now dispatches across machines through the exact seam it has always used.
The cluster is wired but not yet safe: a frozen worker that never closes its socket would stall a job forever, and a redelivered job could be recorded twice. Those are the capstone — heartbeat reaping and at-least-once delivery made safe by the store's idempotency — where the whole course pays off.