Purpose. Admit, queue, and place AI workloads onto compute resources, capping concurrency so a runaway agent or a burst of flows cannot unintentionally DDOS or max out the host or upstream model servers. The reference implementation (RI) runs single-node with local compute, but the design keeps an executor seam open so the same scheduler can later dispatch to additional LLM servers, CPU nodes, or an HPC/Kubernetes backend — without the RI itself becoming a cluster.
Odysseus had nothing here beyond a per-IP login limiter and a dead-host breaker; this is net-new.
Two unrelated things are both called "queues." This subsystem is the second kind.
| Message queue / broker | Workload processing queue (this) | |
|---|---|---|
| Examples | ZeroMQ (0mq), JMS, RabbitMQ/AMQP, Kafka, NATS | SLURM, Kubernetes (Jobs/scheduler), HTCondor, Nomad, Ray |
| Moves | messages between components | compute jobs onto resources |
| Concerns | transport, pub/sub, decoupling, durability of messages | admission, capacity, priority, fairness, placement |
| Role here | possible wire to remote executors (deferred, pluggable) | the subsystem itself |
A message transport (0mq/NATS/HTTP/gRPC) may eventually carry work to a remote executor — but that is an implementation detail of the executor transport, not the identity of this subsystem. Telemachus's scheduler is a workload scheduler: its workloads are AI jobs (model calls, agent flows, task runs); its resources are executors.
| Telemachus | SLURM | Kubernetes |
|---|---|---|
Job (+ requirements) |
sbatch task (--gres=gpu,
--mem) |
Pod / Job (resource requests, nodeSelector) |
| Executor / executor pool | partition / node | Node / node pool |
| capacity (slots) | cores / GPUs | allocatable resources |
| Scheduler + Governor | slurmctld |
kube-scheduler |
| queue (priority, fair-share) | squeue / fair-share |
scheduling queue |
A Job declares what it needs (model, estimated tokens/time, optionally a compute class); Executors advertise capacity + health; the Scheduler places jobs on a matching executor with a free slot, else queues them.
An Executor is any place work can run, with a
kind, a capacity (its real slot count),
health, and the capabilities it offers (e.g. which models).
| Executor kind | What it is | Status |
|---|---|---|
local-model |
a llama-server / model runtime on this host |
RI (v1) |
remote-model |
a networked LLM server (Tailscale/SSH fleet, or an API) | designed-for; Odysseus already discovered such fleets |
cpu-node |
a generic compute worker (embeddings, doc processing) | future |
hpc-partition |
submit to SLURM/PBS as batch jobs | future |
k8s |
submit Kubernetes Jobs | future |
The RI implements local-model. Adding
remote-model (link up more LLMs/CPUs) is "implement the
Executor interface + pick a transport" — no scheduler
change. The scheduler↔︎remote-executor transport
(HTTP/gRPC/0mq/NATS) is deliberately pluggable and out of RI
scope.
submit(job):
RBAC.require(principal, "chat:use" | "tools:invoke" | "flows:run")
d = Quota.check(subject, dims for this job) # budgets → may reject here
if d.rejected(budget): return quota_exceeded(d.reset_at)
candidates = executors matching job.requirements (healthy, offers job.model)
place on the least-loaded candidate with a free slot in {global, executor, team, user}
if none has a free slot: enqueue(job, priority)
try_later.QueuePolicy a
deployer can override — the requirement's "enforcement of any custom
policy for system use." Default = priority + weighted-fair + depth cap +
least-loaded placement.run-agent is the hook. Each round:
acquire a slot on an executor → make the
#:llm call → release. A 50-round agent
never pins one slot for its whole life; other jobs run between rounds.
Flows are cancelable and (optionally)
preemptible at round boundaries. This is the payoff of
the pure-spine + injected-effects design: the governor wraps
#:llm without touching the loop.
governed_llm = wrap(real_llm, job):
lambda messages:
slot = scheduler.acquire(job) # places on an executor; blocks/queues per limits
try: resp = slot.executor.dispatch(messages)
Quota.record(tokens from resp)
finally: scheduler.release(slot)
return resp
jobs table) so restart is safe — on boot,
running jobs with no live worker are requeued or failed per
policy. (Odysseus made background jobs restart-safe via on-disk
exit-code files; generalized here.)Executor contract and job
requirements) that makes federation possible later — not
the cluster.executors(id pk, team_id nullable, kind, name, endpoint nullable, -- kind: local-model|remote-model|cpu-node|hpc-partition|k8s
capabilities json, -- { "models": [...], "class": "gpu|cpu", ... }
max_concurrency int, health, last_seen, weight, transport json,-- transport: how to reach a remote executor (deferred)
meta json) -- team_id NULL = shared/instance-wide
jobs(id pk, type, principal_user_id, team_id, priority, -- type: chat|flow|task|tool
status, requirements json, -- { "model": "...", "est_tokens": N, "class": "..." }
executor_id nullable, cost_estimate, -- status: queued|running|done|failed|canceled
parent_flow_id nullable, submitted_at, started_at, finished_at,
tokens_used nullable, error nullable, meta json)
job_events(id pk, job_id fk, event, at, meta json) -- queued/placed/started/step/finished — observability + audit
Concurrency limits live in
quota_policies (ai.concurrency per scope:
global / executor / team / user); this subsystem reads them.
QueuePolicy config (weights, max depth, priority map,
placement rule) is an operator-editable settings/features
record.
Scheduler: # control plane — the workload queue
submit(job) -> handle
await(handle) -> result # or stream events
acquire(job) -> slot # place on a matching executor; blocks/queues under limits
release(slot)
cancel(handle)
status(handle) -> job
Executor: # data plane — RI ships local-model only
kind() -> symbol
capabilities() -> {models, class, ...}
capacity() -> {limit, in_use, queued}
health() -> ok | degraded | down
dispatch(job|messages) -> result | stream
cancel(job)
QueuePolicy (pluggable):
admit?(job, state) -> now | enqueue | reject
order(queued_jobs) -> next
place(job, candidates) -> executor | none
refimpl/racketmaximus implements
Scheduler/Governor over Racket's native
evented scheduler (green threads + sync, no libuv FFI — the
concurrency demo already proved ~40 concurrent I/O connections on one OS
thread) and a single local-model Executor.
Remote/HPC executors are additional Executor
implementations behind a chosen transport, not core changes.
Live queue + executor dashboard (depth, in-flight, per-team
fair-share, per- executor capacity/health), job cancel/requeue, executor
enable/disable + capacity, editable QueuePolicy. All
manage-gated + audited; honors features
activation.
local-model
only (RI), with remote-model as the first designed-for
extension? (Default: yes — local-model in v1, contract ready for
remote.)requirements = {model, est_tokens}; add a
compute class (cpu/gpu) + memory hints when
cpu-node/HPC land? (Default: yes.)jobs table
from the start (recommended, restart-safe) vs in-memory-only first?First cut built (slice 27). The persisted
jobs table (migration 0009), a bounded worker
pool with atomic claim, submit/poll/list/cancel endpoints
(/api/jobs), and chat + translate
job kinds are implemented in refimpl/racketmaximus
(domain/sched/scheduler.rkt,
test/scheduler-tests.rkt), with a Jobs UI tab. Decisions
realized: durable jobs table (SCHED‑4), priority+FIFO (SCHED‑5),
cancelable-not-preemptible (SCHED‑7).
Per-team fairness (slice 28). The claim now skips a
team already at its concurrency cap (= the team's
ai.concurrency limit), so one team's batch can't monopolize
the pool — enforced at claim time, so a busy team never head-of-line-
blocks a worker (unlike wrapping a shared blocking governor).
Quota metering (slice 29). Job runs go through the
same budget as sync calls: an injected admit? gate at claim
time defers an over-budget team's jobs (they stay queued
rather than failing or bypassing), and a record! hook bills
actual tokens
Deferred follow-ups: weighted-fair-share across teams (beyond the
flat cap) and an agent job kind (the tool-loop flow) — both
layer on without changing the claim/pool core.