Rust workspace · course 3 · distributed control plane

Panoptes Control — Architecture

The coordinator that accepts eval-run submissions over HTTP, persists them durably, and fans their jobs out across a pool of workers — some in-process, some across a TCP wire — behind one trait the scheduler can't tell apart. How the structs, enums, traits, and functions across five crates connect, from a POSTed run to an append-only log of model responses.

panoptes-control panoptes-worker control-eval control-store control-core — every crate depends on control-core's types and the WorkerHandle seam; the network layer is additive.

The story, end to end

It starts with a client POSTing an eval run to the coordinator's axum API. create_run reads three fields — a manifest path, a list of models, and an epochs count — validates them (at least one model, epochs at least 1), and builds a Run: the top-level sweep, with a fresh RunId, a RunStatus of Queued, and a job_count computed up front as models times epochs. insert_run persists it in the Store — the SQLite-backed persistence layer — and the handler returns 201 Created with the id. Nothing executes yet: the API's entire job is request into store. A run is a promise of work, durably recorded, waiting to be claimed.

The Scheduler is the coordinator's engine, and the hub the rest of the system turns around. On each tick it calls claim_next_run — the load-bearing operation of the store: a single UPDATE … RETURNING that atomically flips the oldest queued run to Running and hands it back, so two schedulers can never grab the same run. With a run in hand it loads the manifest — a JSONL file of Vignettes, each an id plus the exact prompt to pose — and plan_jobs splits the run into Jobs: one Job per model, per epoch (an epoch is a repeat of the whole sweep, run to measure a model's consistency), each carrying an EvalJob — the entire vignette batch bound to one model at one epoch. That EvalJob is the unit that fans out. The scheduler snapshots its worker pool, insert_jobs records them, and it dispatches across the pool with buffer_unordered — a bounded-concurrency stream, four jobs in flight at once — retrying any retryable failure on the next worker via run_job_with_retry.

Every worker in that pool is an Arc<dyn WorkerHandle>, and WorkerHandle is the seam the whole design hangs on: the scheduler holds only the trait and knows nothing about how a handle runs the work, which is what makes the network layer additive rather than a rewrite. A LocalWorker runs the job in-process — its dispatch calls run_eval, which poses each Vignette to a ModelClient (HttpModelClient POSTs to /v1/generate, outside the workspace) and collects one ResponseRecord per vignette: which vignette, which model and epoch, the prompt, the response text, and token Usage. A RemoteWorker implements the same trait but fronts a TCP connection to a separate panoptes-worker process. Over it a MessageStream ships length-delimited JSON Message frames — Register, Assign, Result, Heartbeat — so a raw byte stream regains message boundaries. A per-connection actor multiplexes many jobs over the one socket, matching returning Result frames to their waiters by job id; the worker's declared capacity becomes real backpressure.

What makes that seam safe under failure is at-least-once delivery paired with idempotent recording. When a connection dies, the actor fails every in-flight job with a retryable Worker error, and the scheduler redelivers each to a surviving worker — so a job may run more than once. That is fine because record_job_outcome is idempotent by job id: it writes the JobOutcome and bumps the run's done_count in one transaction, but only advances the count the first time a job lands (the UPDATE is guarded by status != 'done') — without which a worker that dies after finishing but before its Result is acked would double-count the run. As each outcome comes back the scheduler appends its ResponseRecords to an append-only responses.jsonl log via append_records, then records it; when the last job lands, the run flips to Done. Finally /stats reads back across every stored outcome, aggregating token usage per model through usage_by_model into ModelUsage rows and an estimated dollar cost — the sweep's final accounting.

01 Pipeline flow

The path one submission travels: a POSTed run persisted as a queued Run, claimed and planned into jobs by the Scheduler, dispatched across the WorkerHandle pool, and run either in-process or over the wire to a panoptes-worker — every response landing in an append-only log and the run's counts.

persist queuedclaimTCPHTTPJobOutcome

Client · POST /runs

axum API · create_run

Store · queued Run · SQLite

Scheduler · claim_next_run · plan_jobs · dispatch

WorkerHandle pool · buffer_unordered · retry on next worker

LocalWorker · in-process

RemoteWorker · connection actor

MessageStream · length-delimited JSON · Assign then Result

panoptes-worker · run_session

run_eval · one call per vignette

HttpModelClient · POST /v1/generate

Model provider · outside workspace

responses JSONL · append_records

Store · record_job_outcome · run counts

GET /stats · usage_by_model · est_cost_usd

colour = owning crate · cylinders = the store and the JSONL log · dashed = the model provider outside the workspace

02 Type relationships

Every type that crosses a crate boundary, grouped by the crate that defines it. The hub is the Scheduler; the seam is WorkerHandle, with LocalWorker and RemoteWorker as its two implementations — the one place the single-node and distributed worlds diverge.

control-core

control-eval

control-store

panoptes-control

panoptes-worker

Run

+RunId id

+RunStatus status

+String manifest

+Vec<String> models

+u32 epochs

+u32 job_count

+u32 done_count

Job

+JobId id

+RunId run_id

+JobStatus status

+u32 attempt

+EvalJob spec

EvalJob

+Vec<Vignette> vignettes

+String model

+u32 epoch

Vignette

+String id

+String prompt

ResponseRecord

+String vignette_id

+String model

+u32 epoch

+Usage usage

JobOutcome

+JobId job_id

+Vec<ResponseRecord> records

+total_usage() : Usage

Usage

+u32 input_tokens

+u32 output_tokens

«enum»

RunStatus

Queued

Running

Done

Failed

«enum»

JobStatus

Pending

Assigned

Done

Failed

«enum»

ControlError

NotFound

Invalid

Worker

Protocol

is_retryable() : bool

«enum»

Message

Register

Assign

Result

Heartbeat

«trait»

WorkerHandle

+id() : str

+dispatch(Job) : JobOutcome

MessageStream

+send(Message)

+recv() : Message

«trait»

ModelClient

+model_name() : str

+generate(prompt) : ModelResponse

HttpModelClient

+String base_url

+String model

«fn»

run_eval

+client · spec to records

Store

+claim_next_run() : Run

+insert_jobs(jobs)

+record_job_outcome(outcome)

+usage_by_model() : ModelUsage

ModelUsage

+String model

+u64 input_tokens

+u64 output_tokens

+u64 calls

AppState

+Store store

Scheduler

+RetryPolicy policy

+usize concurrency

+tick() : RunId

«trait»

WorkerSource

+snapshot() : workers

LocalWorker

+String id

RemoteWorker

+String id

SharedPool

+add(worker)

+remove(id)

«fn»

run_session

+register · serve · heartbeat

◆── composition · o── aggregation (boxed trait objects) · ┈▷ implements · «enum» / «trait» / «fn» stereotypes · ~T~ = generic parameter

03 Per-crate inventory

What each crate defines, and the one job it owns. Every arrow in the workspace points at control-core; the two binaries are the coordinator (panoptes-control) and the worker (panoptes-worker).

control-core

The seams and the data model — no I/O of its own. Every type that crosses a crate boundary is defined here, once.

  • traitWorkerHandle · the load-bearing seam
  • structRun · Job · EvalJob · Vignette
  • structResponseRecord · JobOutcome · Usage
  • enumRunStatus · JobStatus
  • enumControlError · retryable vs terminal
  • enumMessage · the wire protocol
  • structMessageStream · framed JSON over TCP

control-eval

The eval workload and the file contract with the harness. Provider-agnostic, mockable, shared by both workers.

  • traitModelClient · Send + Sync; async
  • structHttpModelClient · injectable base URL
  • structModelResponse · text + Usage
  • fnrun_eval · one call per vignette
  • fnload_manifest · parse_manifest
  • fnappend_records · append-only JSONL

control-store

Durable run and job state in SQLite via sqlx. Two operations carry the weight: the atomic claim and the idempotent record.

  • structStore · cheap-to-clone pool handle
  • fnclaim_next_run · UPDATE … RETURNING
  • fnrecord_job_outcome · idempotent by job id
  • fnusage_by_model · run_results
  • structModelUsage · tokens per model
  • fnnew_job · build a pending job

panoptes-control

The coordinator binary. Hosts the axum API, the scheduler, and the coordinator half of the worker network.

  • structAppState · axum API · create_run · /stats
  • structScheduler · claim · plan · dispatch
  • traitWorkerSource · SharedPool
  • fnplan_jobs · one job per model × epoch
  • structLocalWorker · RemoteWorker
  • fnserve_workers · connection actors

panoptes-worker

The far end of the wire — deliberately simple. Connects, registers, runs the eval, ships results; supervision is a loop.

  • fnrun_session · register · serve · heartbeat
  • fnserve_worker · connect + one session
  • fnrun_worker_forever · reconnect loop
  • reuserun_eval · the same workload
  • reuseMessageStream · the same frames

04 Reading the diagrams

  • ◆──Composition — the source owns a value of the target type (a Job owns its EvalJob; a JobOutcome owns its ResponseRecords).
  • o──Aggregation — a held boxed trait object (Scheduler o── WorkerSource, SharedPool o── WorkerHandle).
  • ┈▷Trait implementation — LocalWorker and RemoteWorker ▷ WorkerHandle, HttpModelClient ▷ ModelClient.
  • «enum»A closed set of variants — ControlError splits into retryable (Worker, Protocol) and terminal.
  • ~T~A generic parameter; Vec~Vignette~ is a batch of vignettes.
  • ▭ / dashCylinders are the store and the JSONL log on disk; the dashed node is the model provider outside the Rust workspace.
  • colourcore · eval · store · control · worker.