One cap for every agent run: telling a busy queue from a broken one

My task runner limits concurrent agent runs with a single global number, not a quota per project. That makes a queued project look stalled, so here are the four signals that tell saturation apart from a real failure.

Three run slots all busy with tasks #350 and #332 from dev-hub and #352 from auto-demo-video, while tasks #353, #354 and #355 from auto-demo-video wait with the reason waiting_for_slot.

I run a small local control plane called ion. It holds a kanban board per project, and a poller that dispatches tasks marked ready to a coding agent, in its own git worktree, unattended. Yesterday one of my projects had a single task running and three siblings sitting in Todo, all correctly assigned, none of them blocked. Nothing had moved in twenty-five minutes.

The question I asked was the wrong one: “is the runner stuck?” It was not. It was full — and being full and being broken looked identical from where I was standing.

One number, shared by everything

The dispatch cap is a single environment variable, DEV_HUB_MAX_CONCURRENT, defaulting to 3. There is no per-project quota anywhere in the code. Everything heavyweight the hub can start counts against that one number:

Kind of run Counts against the cap
Autonomous task runs from the poller yes
Manual “Run” button runs yes
Chat sessions and their continuations yes
Merge runs yes

The count itself is authoritative from the database, not from whatever handles the current process happens to be holding: it counts run rows marked running whose owner process is still alive, and takes the max of that and the in-memory count. That detail exists because an earlier version trusted memory, and a restart could quietly double the real concurrency.

Autonomous task runs, manual runs, chat sessions and merge runs all count against DEV_HUB_MAX_CONCURRENT, three slots with no per-project quota, where the busy count is the max of running rows with a live owner process and the in-memory count
Every heavyweight run, from any project, draws from the same three slots.

So the shape of the problem is: one project’s queue can be starved entirely by runs belonging to other projects. That is not a bug in the dispatcher. It is the dispatcher working, and me reading a global resource as a local one.

Here is what the three slots actually held at the time. One task of mine was running; the other two slots belonged to a completely different project.

#350  dev-hub          running
#352  auto-demo-video  running     ← the one project I was looking at
#332  dev-hub          running
#353  auto-demo-video  queued  (waiting_for_slot)
#354  auto-demo-video  queued  (waiting_for_slot)
#355  auto-demo-video  queued  (waiting_for_slot)

Task #352 had itself waited about twenty-five minutes before dispatching. It finished and moved to Review while I was busy investigating whether it was stuck.

Four signals that tell the difference

None of the above is visible from a board that shows one card in progress and three in Todo. What I actually needed was for “busy” to be observable. There are four places the hub says it, and they answer different questions.

Four cards: GET /api/health answers how full and for how long; the log line answers whether the poller is alive; GET /api/tasks/queue answers why a specific task is waiting; the run heartbeat answers whether the running one is actually alive
Each signal answers a different question about a queue that looks stalled.

The health endpoint answers “how full, and for how long”. GET /api/health reports active_runs, oldest_run_age_seconds, queued_tasks and oldest_queued_seconds. Active runs at the cap with a queue depth that is growing is saturation, full stop. A queue that is growing while active runs sit below the cap is the interesting case — that is the one worth debugging.

The log answers “is the poller alive”. On each change of state the hub writes one line:

All 3 run slot(s) busy; 3 task(s) queued, next up #353 'Generate the scene manifest'

Once per change, not once per poll, so it does not drown the log. If that line is there, the poller is running and deliberately waiting. Its absence during a full queue is the real alarm.

The queue endpoint answers “why this specific task”. GET /api/tasks/queue lists every waiting task with a reason, and the reasons are not interchangeable — waiting_for_slot means the system is healthy and I should be patient, while blocked_by_deps, awaiting_answers, failure_cap and no_project each mean something about that task needs a decision from me. When it’s awaiting_answers, the question can be answered from your Apple Watch. Lumping these into one “pending” state is exactly the mistake that made the board unreadable in the first place.

The queue reason waiting_for_slot means be patient, while blocked_by_deps, awaiting_answers, failure_cap and no_project each need a decision
Only one queue reason means the system is simply busy.

The heartbeat answers “is the running one actually alive”. Each run updates a heartbeat timestamp. Within the last minute means the run is genuinely working; otherwise the orphan recovery picks it up and restarts runs whose owner process died. This is the only one of the four that can distinguish a wedged run from a long one, which matters because a wedged run holds a slot forever and turns a temporary saturation into a permanent one.

Fixing it, and why I am not fixing it differently

Two levers, both immediate. Raise the cap in the service’s environment and restart it:

DEV_HUB_MAX_CONCURRENT=5
launchctl kickstart -k gui/$(id -u)/<label>

Or close idle chat sessions, which frees slots without a restart. Most of the time the second one is enough, because the slots are held by conversations I finished with an hour ago.

What I did not do is add a per-project quota, which was my first instinct and is the wrong shape. The cap is not modelling fairness between projects; it is modelling the capacity of one laptop. Three concurrent agent runs means three sets of API calls against shared rate limits, three agent processes competing for CPU and memory, and three git worktrees being written to. Splitting that budget per project does not create more of any of those. It just guarantees that an idle project reserves capacity a busy one could use, which is the failure mode of every static partition ever built.

The generalisable bit

If you are building anything that dispatches agents, even the do-it-yourself kind where a Mac mini runs Claude Code around the clock, the lesson is not about the cap. Any orchestrator has one somewhere. It is that a queue which is merely full and a queue which is broken produce the same observable: nothing appears to be happening.

Make the waiting explicit. Say how many slots are busy, say which task is next, say why each waiting item is waiting, and distinguish “the resource is contended” from “this item needs you”. I could have inferred all of that from the database in a couple of queries. I should not have had to, and now I don’t.

Get early access.

Invite-only, released in small waves. Free during early access.

Get early access.

Invite-only, released in small waves. Free during early access.