An eligible item does not necessarily start. Six things decide, and each has a different answer to “why is nothing happening?”:
| Reason | Scope | Lifted by |
|---|---|---|
| Dispatch is paused | everything | unpause |
| The item or its repository is held | named work | unhold |
| The issue does not carry the configured label | one item | labelling the issue, or changing the label back |
| The machine or the repository is at its session cap | one repository, or all | a session ending, or a higher cap |
wait_for_merge and the previous item has not landed |
one repository | merging the pull request |
| The board has the card parked elsewhere | one item | moving the card back |
robot-army status and robot-army capacity both say which one is in force, by name.
The cap counts every Claude session running as me, not just the ones the daemon started. My own terminal sessions share the same subscription, so a cap that ignored them protected nothing on the machine where I actually work.
[daemon]
max_concurrent_sessions = 2 # the whole machine, mine included
[dispatch]
order = "oldest-first" # or "repo-priority"
default_repo_max_sessions = 1 # per repository, unless overridden below
wait_for_merge = false # globally; see below
project_ordering = true # globally; see below
[repos."jantman/example"] # an override, not a registration
max_sessions = 2 # optional; overrides the default above
priority = 10 # optional; higher runs first under repo-priority
wait_for_merge = true # optional; overrides the default above
project_ordering = false # optional; overrides [dispatch] project_ordering
uv run robot-army capacity # total, cap, mine vs. others, per repo, order in force
uv run robot-army status # the same summary, plus the queue with positions
Set the cap higher than feels right. The first live capacity on this desktop reported
2 of 2 sessions running, 0 ours, 2 other — with the daemon having started nothing. Two
sessions of my own is my ordinary working state, so the default of 2 gives the daemon zero
slots and it never dispatches. The cap has to be my usual session count plus however many
robots I actually want; 3–4 is the realistic starting point.
The breakdown adds up to the total. capacity lists four terms under the running count,
and they always sum to it:
capacity : 6 of 7 sessions running
ours : 0
others : 2 (started outside this system)
simulated : 3 (rehearsal sessions — no process, and none will ever register)
in flight : 1 (dispatched, no registry entry yet — launching, or ended and not yet reconciled)
ours and others come from Claude’s session registry. The last two are sessions robot-army
dispatched that the registry has not seen, and they are counted because assuming they are free
is how two dispatches end up sharing one slot. They clear in very different ways. In flight
is a real launch the worker has not registered yet, which takes seconds, or a session that has
ended and which the next reconciliation pass closes. Simulated rows come from a local
rehearsal: no process ever existed, so nothing will ever register them, and they hold their
slots until they are closed or purged. A capacity that stays full with a large simulated
count is rehearsal rows left behind, not work in progress. status, the web chrome and a
global_cap hold’s reason show the same terms on one line, leaving out simulated and
in flight when they are zero. Until issue #61 they were counted and never shown, so 6 of 7
could be explained as 0 ours, 2 other.
A kept session is named inside ours. A session I have kept past its issue
closing is still one of mine and still holds
its slot, so it is counted where it always was and annotated rather than broken out — the
terms still sum: 2 of 2 sessions running (2 ours (1 kept), 0 other). The per-repository
reason says it too, because that is the cap a keep actually fills:
repository o/n: 1 of 1 sessions (configured), 1 kept. The fix it names is
robot-army unkeep <id>. With nothing kept both read exactly as before. The (N kept) in
the global breakdown counts only kept sessions that are also in ours, so it can never exceed
it: a kept row the registry cannot see (simulated, or a worker that died unseen) is left out of
it, and on the degraded /proc path, where ours is empty, the annotation disappears. The
per-repository N kept counts rows, like the per-repository count it sits beside.
Four things worth knowing:
/proc cannot be enumerated, the number of live sessions is unknown
rather than zero — so work is held, an anomaly is raised, and capacity exits non-zero.
A visible stall is a better failure than an invisible over-subscription. The middle case —
registry unusable, /proc readable — counts via /proc and says degraded. Be aware that
the /proc fallback matches on the binary name, so on this machine it also counts Claude
Desktop: 11 processes where the registry says 2. The over-count is the safe direction, but
in practice a degraded observation means “permanently at capacity”. If the registry becomes
unreliable, fix the registry.systemctl --user restart robot-army.service ran.Every surface reports the count against the cap the running daemon is enforcing, not
against the file the reporting process happens to have read. The daemon publishes it on its
heartbeat, and status, capacity and every web view take it from there.
This matters because the two can differ in both directions, and the number is the one used to answer “why is nothing dispatching?”:
robot-army serve is still holding the old, lower number. It used to print
6/5: full and then some, when the truth was 6/7 with two slots free (issue #30).robot-army capacity reads the new
number out of the file while the daemon is still enforcing the old one. The fresh reading
is the wrong one, and it looks the most trustworthy.When the two disagree, the surface says so and names both:
SESSION CAP MISMATCH: the running daemon is enforcing a cap of 7, and this process is
configured for 5. The cap shown is the daemon's, because the daemon is what enforces it.
One of the two has been running since before the configuration changed — restart that one
and they will agree.
The disagreement itself refuses nothing — unlike an effect-level mismatch, which does. It just means one of the two processes needs restarting. Which one, the notice cannot tell you: neither knows when the other read its configuration. A terminal command can narrow it and does — it read the file a moment ago, so the daemon is the one behind.
The cap is still enforced, and by more than the daemon: pressing Resume on the web, or
typing resume or restart, runs a launch gate inside that process. Those gates measure
against the same enforced cap the surfaces report, so what a page shows, what it offers and
what it refuses are one number. A gate left on the reader’s own cap would refuse a launch the
daemon would allow — or permit one past what the daemon is enforcing.
With no daemon running, or with nothing readable from its heartbeat, each surface falls back to its own configured cap and says nothing — there is then nothing to disagree with.
repo-priority drains higher-priority repositories first and breaks ties oldest-first.
There is deliberately no aging: a low-priority repository can wait indefinitely while a
high-priority one keeps producing work, which is what choosing that mode means.
max_sessions and wait_for_merge answer two different questions and I kept mixing them
up until I wrote this down.
max_sessions counts live sessions. The moment a session exits, the slot is free and
the next issue in that repository starts — from a default branch that does not yet
contain the work that just finished, because that work is sitting in a pull request I have
not looked at. For a repository where every issue touches the same files, that is the
collision, and no session cap can express the wait, because there is no session to count.
wait_for_merge waits for the work to land. With it on, a repository dispatches
nothing while it has an unfinished item — one that has been dispatched at least once and
has not reached done or abandoned. Merging the pull request closes the issue, a closed
issue makes the item done, and the next issue goes out on the following pass. Merging is
entirely mine to do: this never opens, approves or merges anything.
uv run robot-army status # the held item, and "#41 is awaiting_review and has not landed"
uv run robot-army capacity # both limits per repository, and where each one came from
Three consequences worth knowing before turning it on:
failed item holds the repository too. It is unfinished work, whatever condition it
is in. robot-army retry <id> or robot-army abandon <id> says which — and the hold names
the item, so I am not left guessing what stopped. retry re-reads the issue and re-checks
its eligibility, author included, so it refuses rather than clearing a hold it should not.max_sessions.origin/<base>, so this is for my clone rather than for theirs.Only for repositories with wait_for_merge in force, and only ever a fast-forward. It is
skipped, with the reason recorded, whenever the clone has uncommitted changes (untracked
files included), is on another branch or a detached HEAD, is mid-rebase, mid-merge,
mid-cherry-pick or mid-bisect, has no such remote, or holds commits on its default branch
that the remote does not. There is no --force anywhere in it and nothing is ever reset or
rebased. A skip never fails the dispatch — the worktree comes off the remote-tracking ref
either way.
uv run robot-army log --json | grep git.fast_forward # outcome, reason, and the shas
If a GitHub project is linked to a repository, it decides the order that repository’s issues run in — the card at the top of the ready column goes first. Drag a card, and the next poll follows. Nothing to configure: one linked project with an obvious ready column is found and used.
uv run robot-army status # which board governs each repository, and why not
uv run robot-army doctor # the token, the project, the column, the freshness
A card parked in another column is held. Moving an issue to Backlog is you saying
“not yet”, and it stops dispatching until you move it back — with the reason on screen
naming the column it is in:
demo: #48 is in 'Backlog', not the dispatch column 'Ready' — move it there, or set
project_ordering = false for this repository
A labelled issue that is not on the board at all still runs. That is not an oversight — an issue nobody put on the board is no signal either way, so it dispatches after everything the board actually ranked. The board says what to do first, not what is allowed.
Six things worth knowing:
order and priority keep doing exactly what they did. The set of queue positions a
repository holds is unchanged; only which of its issues sits at each one moves.Ready or Todo, found automatically. GitHub’s Kanban template offers Ready and
the simpler board offers Todo, so most boards need no configuration. A board with both,
or with neither, is reported rather than guessed at — set project_column.status names both and the
repository keeps dispatching in its configured order until you set project.doctor watches for it. GitHub exposes one
manual ordering per project and no per-view order, so a view sorted by Priority shows
you an order this cannot reproduce. The check fires only when that sort field actually
has values on cards in your dispatch column — the condition under which what you see and
what runs can genuinely differ.To name a board that is not linked, or override the column:
[repos."jantman/example"]
project = "https://github.com/users/jantman/projects/3" # or just: 3
project_column = "Ready"
project_ordering = false # or turn it off entirely
Turning it off restores the previous behaviour exactly: no board is contacted for that
repository, nothing is held, and the order is whatever [dispatch] order says.
uv run robot-army pause # or the control on the queue view
uv run robot-army unpause
While paused the daemon still polls, evaluates eligibility, reconciles, and heartbeats. It
starts no new session, and eligible items accumulate in ready — nothing is rejected and
nothing is lost.
The pause is durable: it survives a daemon restart and a reboot, and is cleared only by
unpause or the web control. Never by time. It appears in status, in heartbeat.json, and
on every web view, because a system that is healthy and deliberately doing nothing must not
read as one that is healthy and doing nothing for no reason.
robot-army hold 42 # one item
robot-army hold --repo owner/name # every item in a repository, present and future
robot-army unhold 42 # and the two releases
robot-army unhold --repo owner/name
robot-army holds # everything held, with ages
Pause stops the queue. A hold stops named work and leaves everything else moving — which is the case I actually hit: four items from a repository I do not care about this week sitting in front of one I do. Holding the repository takes all four out and lets the fifth dispatch in the same pass.
The target is stated, never guessed from its shape. An item id is an integer and a repository key contains a slash, so one argument could be classified by looking at it — but a mistyped key that happened to parse as something else would silently hold the wrong thing. Giving both or neither is a usage error.
A held item stays in the queue, in the position it would occupy anyway, reported with a
held reason. It ranks directly below paused, because every reason beneath it — a full
machine, an unmerged pull request, a parked card, stale failure residue — would name a fix that
cannot work while I am the one holding it. When an item and its repository are both held, the
one reason says so, and says that releasing one leaves the other in force.
Three things it does not do, each on purpose:
cancel is what
stops a session, and the two must not be confusable.
Nor does a hold, or a pause, keep one alive after its issue closes: a done item’s idle
worker is retired whatever is held or paused. Keeping a session is a separate statement,
keep, with its own release.Holds are durable — they survive a restart and a reboot, and a hold placed while the daemon is
down is honoured on its first pass. They are also runtime state, deliberately not
configuration: [repos.*].priority with order = "repo-priority" is the standing preference I
edit in a file, and a hold is the temporary statement I make from whichever surface is to hand.
The queue view carries a repositories section listing everything with queued work and everything held, each with its own hold or release control. Both halves matter. Without the first there is nothing on the page that can place a repository hold; without the second, a hold that currently matches no queued item is invisible — and a hold holding nothing looks exactly like no hold at all, right up until it silently suppresses the next issue I file.
[github] label is the label that means dispatch this, so it is checked again whenever the
queue is worked out, not only when an issue is first found. A queued item whose issue does not
carry the configured label stays in the queue, is reported not_labelled with the label named,
and does not start.
This is how I found out it was needed. Nine items were queued under robot-army. I changed the
label to a throwaway one to scope a verification round to test issues, raised the cap, and four
of the nine started — real branches in repositories I had not meant to touch. Changing the label
had stopped finding new work under the old label and left the old queue untouched.
poll.labels_refreshed in the audit log).held, above the session caps. On a full machine the queue says
not_labelled rather than global_cap, because raising the cap is the wrong fix, and it is
the one I reached for.resume and restart are not refused on the label;
retry re-reads the issue and applies the whole eligibility check, label included.daemon.label_warning record names how many and which.The labels checked are the ones stored when the issue was last read, not fetched on every pass.
So taking the label off an issue on GitHub, without changing the configuration, is not seen
here: the issue drops out of the label-filtered listing, and nothing updates its row. To stop
one issue, hold it or abandon it.
For a failed item, show’s blocked line is checked at the moment you ask, and its
failure line is history. failure is the sentence recorded when the item failed, and it
never changes. blocked is the verdict of the checks retry and reset make before they
re-read the issue, run when the item is shown: the repository resolves to a clone, the
approved clone is still where it was approved and still that repository, the workspace is
trusted, and the committed settings match.
So show, retry and reset name the same blocker in the same words, and the item page’s
blocked entry says the same thing. Before issue #63 they did not: show printed the
blocked_reason stored at the moment of failure, which goes stale the instant the thing it
names is fixed — so show went on sending me to restore a clone I had already restored,
while retry, asked at the same moment, refused for the real reason.
Four readings:
… (checked now) — this is what retry or reset would refuse for.… (checked now; not the reason recorded when it failed) — what I fixed is fixed, and this
is what is left.nothing on this machine blocks it now — the verb will get as far as reading the issue. A
reason only the issue can settle, such as the author or the label, is still checked there,
and show cannot confirm or clear it without a request.could not be checked now: … — the check itself failed. The stored sentence is not offered
in its place.Looking writes nothing. A moved clone found by show raises no anomaly; the next dispatch,
retry or reset that meets it does. Items in any other state are not checked, and a reason
stored on one is marked (recorded, not re-checked).
A terminal item is not blocked, whatever it still has recorded. The stored reason is never
cleared — abandon does not wipe it, and neither does anything else — so a done or
abandoned row goes on carrying the sentence that once refused it. The web queue’s blocked
table asks the state, not the column, and lists only items something could still be done
about. Without that it kept an item the maintainer had already given up on, offering no action
is legal for this item in its current state and so no way to make it go away. The sentence
itself stays on show and on the item page, which is where history belongs.
Next: what a session is told.