robot-army

② What runs next

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.

How many sessions run at once

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:

Which cap you are being shown

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?”:

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.

Working a repository serially

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:

The clone fast-forward

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

Ordering work from a project board

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:

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.

Pausing dispatch

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.

Holding specific work

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:

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.

When the label changes

[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.

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.

Why an item is blocked, checked now rather than remembered

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:

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.