The dispatch runtime
Two shell scripts and systemd make up the entire agent runtime. There is no daemon, queue or watchdog. Starting a run is the assignment, and the close-out comment is the only sign that a run is alive.
Updated
stable
Learn the two-script runtime
Reads this page, explains the no-daemon design and its three fail-open guards back to you, and reproduces the bash byte-offset hazard in a scratch directory. No repository writes.
PROMPT 1 of 2 — learn what this module teaches. You write nothing to any repository. The one thing you create is a scratch directory under mktemp, and you delete it before you finish.
ASK EVERY QUESTION WITH the `AskUserQuestion` tool, with a RECOMMENDED option first. Not prose.
Fetch and read the whole page:
curl -s https://docs.utopiamodels.ai/docs/operating/dispatch-runtime.md
If the fetch fails, say what came back and stop.
1 — EXPLAIN IT BACK, UNDER THESE HEADINGS
THE WHOLE RUNTIME What dispatch.sh does, what task-runner.sh is, and what is deliberately absent
(daemon, queue, watchdog, scheduler). Why "starting a run is the assignment" leaves no
state that can disagree with what is executing.
LIVENESS Why the close-out comment is the only liveness, and what a clean exit code does NOT prove.
THREE GUARDS Quota (exit 9), duplicate on one agent (exit 11), fleet claim (exit 14): what each refuses,
and why all three FAIL OPEN and why none of them comments on the issue.
THE WINDOW The measured 26–27 seconds, why a unique systemd unit name is a real mutex for one user
and none at all across two users, and what closes the cross-agent case.
SELF-UPDATE Why task-runner.sh re-executes itself exactly once after fast-forwarding.
Quote the page where you lean on it.
2 — REPRODUCE THE SELF-UPDATE HAZARD
The page says bash reads a script incrementally, by byte offset, so rewriting a script under a live interpreter changes what runs. Prove it:
d=$(mktemp -d); printf 'echo one\nsleep 2\necho two\n' > "$d/s.sh"
bash "$d/s.sh" & sleep 1; printf 'echo three\n' >> "$d/s.sh"; wait; rm -rf "$d"
Paste the raw output. Explain why a line that did not exist when the script started still ran, and why "re-exec once at a known safe point" is the whole fix.
3 — CHECK THE NAMESPACE CLAIM ON THIS MACHINE
Run `systemctl --user show-environment >/dev/null && echo user-manager-present` (say so plainly if systemd is absent here). Then state, in one sentence, the transferable rule: a uniqueness guarantee is only as wide as the namespace that enforces it — and name the namespace a --user unit name lives in.
4 — QUIZ ME
Three questions with the `AskUserQuestion` tool, one at a time, four options each, the correct answer not always first. Draw from: which exit code means which guard, why guards fail open, why a refusal must not post a marker on the issue, and why an armed auto-merge is not a finished run. After each answer, quote the line that settles it.
If `AskUserQuestion` is unavailable in this session, ask the same questions as plain numbered text in one message instead.
Then say exactly this:
"Module learned. Paste prompt 2 and I will audit your own job launcher against it."
Then stop.Audit your launcher, then adopt the smallest two-script shape
Finds whatever starts your long-running jobs, grades it on the four properties this runtime has, and proposes the smallest change. Writes only after you confirm.
PROMPT 2 of 2 — integrate this module into my own setup. Survey read-only first; propose; write only what I confirm.
HOW LONG THIS TAKES: a multi-step audit (5 steps, about 5 questions, writes only what you approve) — expect roughly 10–25 minutes across several turns. Running headless with a timeout? Budget for that, or start with a shorter prompt first. Cut off partway? Paste this prompt again — it resumes from its checklist rather than starting over.
ASK EVERY QUESTION WITH the `AskUserQuestion` tool, each with a RECOMMENDED option first and one line of why.
Reference, read it first:
curl -s https://docs.utopiamodels.ai/docs/operating/dispatch-runtime.md
That runtime is two shell scripts and systemd: no daemon, no queue, no watchdog. It runs on ONE machine with a handful of agents — that assumption is load-bearing in several of its choices, so if my situation differs, say so out loud rather than quietly assuming it holds.
FIRST — WRITE YOUR CHECKLIST TO A FILE
RESUMING? Before you write anything, check whether ~/DISPATCH-INTEGRATE-CHECKLIST.md already exists. If it does, this prompt was interrupted: read it, tell me which steps are already ticked, and continue from the first unchecked step. Do not overwrite it, do not redo a ticked step, and do not re-ask a question whose answer it already records — the scope answer under ## Rules included, if it's there. If ## Rules has no Scope field, this checklist predates the scope question: say so plainly in your first output line and ask it now, before anything else. Only if the file does not exist, write it fresh as below.
Write ~/DISPATCH-INTEGRATE-CHECKLIST.md and work from it:
# Dispatch runtime module — integrate
- [ ] 1. Find every launcher
- [ ] 2. Grade each on the four properties
- [ ] 3. Ask what the code cannot tell you
- [ ] 4. Propose one change
- [ ] 5. Write only what I confirmed
## Rules
- Read only until step 5. Nothing that mutates state: no starting, stopping or enabling units, jobs or containers.
- Every claim quotes the file and line it came from.
SCOPE — ASK ME BEFORE YOU SURVEY ANYTHING OF MINE
Before step 1, ask me this with the `AskUserQuestion` tool, with both options exactly as written:
"Should I treat any path as off-limits, or do you want me to have full access to survey and propose changes anywhere in this environment?"
A — Exclude .env*, credentials and CI/deploy config (.github/workflows/, deploy keys) from anything I read or propose touching. [RECOMMENDED for most readers]
B — No exclusions. Full access to survey and propose anywhere, including secrets and CI files.
This is my preference, not a policy: some people want their agent to reach everything, some do not, and neither answer is the correct one. Never assume it and never pick for me. My answer governs every read and every proposal for the rest of this prompt. Write my answer under ## Rules in the checklist as "Scope: A" or "Scope: B", so it survives compaction. It governs the SURVEY and PROPOSE steps only — nothing gets written until step 5, and there only what I confirm.
Before you ask, decide whether an answer can reach me at all. It cannot in a one-shot non-interactive run — `claude -p`, `codex exec`, `gemini -p`, a scripted or CI invocation — and you can tell without guessing: the `AskUserQuestion` tool is missing from your tool list there, while an interactive session has it. It also cannot if the question comes back immediately with no real answer. In either case do not ask, do not wait and do not re-ask: a question asked as plain text in a one-shot run ends the run with nothing done. This is the one case where you pick for me: treat A as my answer, record and hold it exactly as above, and make the first line of your output exactly: "No interactive scope answer available — defaulted to A (excluded .env*, credentials and CI/deploy config). To choose B, re-run this prompt in an interactive session and answer the scope question."
Only if the `AskUserQuestion` tool is in your tool list but fails when you call it, ask the same question as plain numbered text in one message instead.
1 — FIND EVERY LAUNCHER
Look for all of these before concluding — most repositories have more than one, and they disagree:
systemd units or timers in the repo or referenced from it · crontab lines in scripts or docs · CI workflows that
dispatch other workflows · scripts named run / start / dispatch / worker / job / queue / launch · supervisord,
pm2, docker compose restart policies · an app-level queue (celery, sidekiq, bullmq, a polled table)
List each with its path and say which is the real entry point. Several with no shared entry point IS the finding.
2 — GRADE EACH ON THE FOUR PROPERTIES
ASSIGNMENT Is "job X belongs to worker Y" written somewhere before the worker starts? Where, and what
happens when it disagrees with what is actually running?
LIVENESS Does a worker write a heartbeat about itself (a claim), or is liveness derived from output it
leaves where people look (evidence)?
DOUBLE START Find the guard. Measure its window between check and point of no return. Is it enforced by
something that can refuse — a unit name, a lock, a DB constraint — or only by a read? If it
is a unique name, what namespace enforces it, and can two callers sit in different ones?
FAIL DIRECTION For each guard: what happens when its input is missing, unreadable or malformed? Quote the path.
Is failing that way right for this system? (The reference fails open: a false block stops the
fleet, which it judges worse than a wasted run.)
3 — ASK WHAT THE CODE CANNOT TELL YOU
With the `AskUserQuestion` tool, one at a time, each with a recommended answer: how many runs happen at once, whether a run is safe to repeat, and whether anything outside the system must see progress before a run finishes. Stop asking as soon as you can recommend.
4 — PROPOSE ONE CHANGE
A table — property · what this repo does · what the reference does · defect or legitimate difference in substrate. Be willing to conclude my setup is right and the reference is wrong for my case.
Then ONE recommendation, not a menu: either "adopt the two-script shape — here is the smallest version for your case" (the launcher, the run script, the guard, and where each announces start and finish), or "do not — here is what you need instead and why that fleet does not." Name the files and the shape of each edit. Ask me with the `AskUserQuestion` tool whether to write it, RECOMMENDED: yes, as a new file beside the existing launcher rather than an edit to it.
5 — WRITE ONLY WHAT I CONFIRMED
Create only accepted files. Never modify a file you did not create without asking. Do not start, enable or install anything — show me the command that would, and leave running it to me. If something does not work, say so and stop.
When the writes are done, tell me in one line, filled in with the real paths:
"To undo, delete `~/DISPATCH-INTEGRATE-CHECKLIST.md` and <the new file beside the launcher, by path> — nothing else on your system was touched."
If I approved an edit to a file you did not create, name that file in the same line with the command that reverts it (`git checkout -- <path>` if uncommitted) instead of claiming nothing else was touched.
FINISH
Every file created, with its path; the one property you changed and how I will see that it holds. The fleet's own runbook for watching runs is at https://docs.utopiamodels.ai/docs/operating/operations.md if I want the operational half.This page comes from the fleet's own component document for dispatch-runtime. It is marked stable
because both halves are in git, assertions re-check both of them on every pull request, and four test
harnesses exercise the parts that can break without anyone noticing.
dispatch.sh starts a run, and task-runner.sh is the run. That is the whole runtime.
There is no daemon, no queue, no watchdog, and nothing running between runs. That has two consequences, and they are why the design is worth copying:
- Starting a run is the assignment. No separate step tells an agent what to work on, so no stored state can disagree with what is actually executing.
- Liveness is the close-out comment. The run posts on its own issue when it starts and again when it exits. Nothing else reports health, because nothing else is running.
If you stop these two scripts, the fleet does not slow down. It stops, because nothing else in the organization can begin a unit of work.
The runtime is deliberately not a scheduler. The sweeper, described on Operations, chooses which issue and which agent. A human at the command line calls the same entry point the sweeper does. The runtime never picks work.
Three pre-flight guards, and every one of them fails open
| Guard | Refuses when | Exit | Escape hatch |
|---|---|---|---|
| Quota | the agent's own state file says its entitlement is used up and the reset time has not arrived | 9 | DISPATCH_IGNORE_QUOTA=1 |
| Duplicate, same agent | a unit for this issue is already active on that box | 11 | DISPATCH_ALLOW_DUPLICATE=1 |
| Fleet claim | a probe that holds a lock finds a live run for this issue on any agent | 14 | none |
The guards fail open on purpose. If a file cannot be read, a box cannot be reached, a JSON document cannot be parsed or a lock cannot be taken, the guard lets the run proceed, and says so on stderr. A false block stops the fleet, and that is worse than a wasted run.
None of the three guards writes a comment on the issue, for one shared reason. The scheduler treats certain glyphs on an issue as terminal. If a refusal posted a marker, it would permanently remove from the queue an issue whose only problem was temporary. A guard that posted where the queue reads would delete work without anyone noticing.
Why only the third guard has no window
The first two guards read a value and then act on it, and that kind of check has a gap. We measured it on this fleet. Between reading the board and launching the unit, 26 to 27 seconds pass, so two dispatchers that read in the same second both pass the check.
For a single agent, a fixed unit name with no random part closes that gap. Systemd refuses a duplicate unit name in the kernel, which makes it a real mutex and not a convention. But each user has a separate service manager, so the same unit name under two different accounts counts as two different names, and the kernel does not check the pair.
For the cross-agent case, a single file lock on the shared box covers it. The lock is held across both the probe and the launch. Both dispatch paths run the claim as the same unix user on the same machine: the scheduler running locally, and a hand dispatch arriving over SSH. A committed test harness proves this against real systemd, with a mutation control.
The lesson for other systems is that a unique name only guarantees uniqueness within the namespace that enforces it. Check what your namespace actually is before you treat a unique name as a lock.
The last-moment disposal re-check
An issue can stop being work while its run is starting. Every gate that checks the issue from outside the run checks too early, because the unit start, the self-update, the clone sync and the credential decrypts all happen after that check. The fix asks the same two questions from inside the run, just before it posts the started marker. The real case that led to this fix, with timestamps, is on Architecture.
There is no backend selection
The launcher passes a literal backend name into the transient unit. It does no lookup, reads no registry and resolves nothing per agent. A function once chose between providers, and we deleted it along with the other provider.
The environment variable still exists for a different reason. It is how the fleet tells that code is running "inside a dispatched run", and every gate keyed on the board skips its check when it is set. This is a pattern worth noting. A flag whose original job was deleted can be worth keeping for a second job it happens to do, as long as you write down which job it does now.
The run updates itself, then executes itself, exactly once
task-runner.sh fast-forwards its own repository and then re-executes itself one time before doing
anything else.
Never change a script while a running bash process is still reading it. Bash reads a script a piece at a time, by byte offset. If you rewrite the file under a live interpreter, it resumes at an offset that now points into different text. Re-executing once, at a known safe point, fixes this completely.
Read the live state, do not recall it
Every fact about a run in progress lives outside git and changes by the minute, so this page gives the command instead of the answer.
| You want | Run |
|---|---|
| Which agents are dark right now | _infra/scripts/dispatch.sh --quota for a table, --quota-dark for names |
| Would this dispatch be refused? | --preflight-quota <agent> (0 or 9), or --preflight-duplicate <agent> <issue#> (0 or 11) |
| What is live for one issue | _infra/scripts/dispatch.sh --live-units <agent> <issue#> |
| Every run on this box, now | systemctl --user list-units --state=active 'task-*' |
| What one run actually did | journalctl --user -u task-<issue>.service -o cat |
A run leaves nothing running between invocations, so there is no daemon to check. While a run is alive, its transient unit shows it. Afterwards, the started and finished comments on the issue show it. Those are the only liveness signals the runtime has.
What this runtime does not prove
We publish these instead of hiding them, because each one has cost this fleet a real run.
- A clean exit code is not a shipped artifact. The finished marker says the process exited cleanly and nothing more. A run is complete when a pull request whose body closes the issue is merged.
- An armed auto-merge is not a merge. A run that exits with its pull request still open has not finished. Fifteen of these once piled up without anyone noticing, all from this confusion.
- The pre-flight clone sync stashes another agent's uncommitted work. It stashes and then hard-resets each clone to the remote. An interactive session's edits survive only as a stash entry, and nobody is told about it. Check the stash list before you redo work that vanished mid-run.
- Transient units fall outside the one registry check that actually runs. Each run's unit is created on the fly and removed on exit, so there is no installed unit file for the daily verifier to check. The two committed test harnesses stand in for it, and they run on pull requests, not on a timer.
The planning loop
Five steps take a rough note to filed, dispatchable work, and nobody reports any of them. Each step is derived from an event or from the board, so a plan has no field that could hold a wrong value.
Module: create workflows for using docs.utopiamodels.ai
Two prompts. In the first, your agent works out what your setup is and asks you only what it cannot measure. In the second, it builds workflows and a gate that fires even when you forget the gate exists.