robot-army

① Where work comes from

Two routes in. Both end at the same gate.

The label is the gate

The ordinary route: I write an issue in an onboarded repository and put the label on it — robot-army by default, [github] label to change it. The next poll sees it, checks the issue is mine, and queues it.

Changing the label needs no manual step. Each repository’s saved ETag belongs to the query built from the old label. The poll sees that the query has changed, skips the ETag, and makes one full request, so issues with the new label show up on the next poll. It used to send the old ETag anyway, and GitHub’s 304 hid every new issue without any error. The github.poll record now says when this happens (audit log).

Work already queued under the old label does not start. Each queued item’s issue is checked against the label that is configured now. One that no longer carries it is held until I label the issue or change the label back (when the label changes).

Two things are checked and neither can be turned off:

Being on a project board’s ready column is not an admission — see what runs next. The board narrows and orders; it never admits.

The intake board

Optional, and absent by default — an installation with no [trello] section makes no board request at all and behaves exactly as it did before this existed.

I put a card on a private Trello board from my phone, tag it, and it becomes a GitHub issue in the repository the card names. That issue is unlabelled, so nothing runs: labelling it is still the human gate, and the board cannot reach past it. The card then follows its issue — into the in-progress list while a session runs, into the done list when the issue closes, and back where it came from if the work is abandoned. If I have moved the card by hand since it was last placed, it stays where I put it, and gets a comment naming — by the column’s name — the list it would have gone to and why.

[trello]
board_id         = "5f3a..."        # required when the section is present
label            = "AI-task"        # the tag that marks a card as work
in_progress_list = "In Progress"
done_list        = "Done"
ignore_lists     = ["Icebox"]       # columns whose cards are NOT intake; empty by default
poll_seconds     = 300              # slower than GitHub's 60, deliberately

key_env          = "TRELLO_API_KEY"     # the NAME of a variable, never the value
token_env        = "TRELLO_API_TOKEN"   # or key_file / token_file, mode 0600
export TRELLO_API_KEY=...     # https://trello.com/power-ups/admin
export TRELLO_API_TOKEN=...
uv run robot-army doctor      # now checks the board too — every check must be green
uv run robot-army cards       # what is on the board and what became of it

doctor verifies at startup, and refuses to ingest — not to run — if any of these fail:

The board’s member list is recorded and never gated on. Who else may see my own private board is my decision, and a second member can at most cause an unlabelled issue to be filed — only I can cause one to run.

Parking a card

ignore_lists names columns whose cards are not intake. A tagged card sitting in one produces no issue, no comment and no move. Drag it out and it is picked up on the next poll — no re-tag, no rescan, no restart.

That reversibility is the whole point. Before this, the only way to stop a tagged card being filed was to remove the tag: a one-way answer about what the card is, to a question that is almost always about when. Columns are where I already say when. So an icebox column is a parking space, and parking spaces have to work in both directions.

It gates intake only. A card that already has an issue is untouched in either direction — its mapping, its session and its remaining board moves all continue — which is also why listing in_progress_list or done_list here is harmless rather than contradictory: by the time the daemon puts a card in either, that card is already linked.

That includes the one thing a linked card most often gets when it is dragged into an ignored column: the “robot-army did not move this card” comment (003’s FR-030). Dragging a card with work in flight into the icebox is the natural way to say stop, so the daemon still declines to drag it on to Done or back to where it came from — and still says so on the card, exactly as it would for any other column. Parking stops a card from becoming work; it does not stop the daemon explaining a decision about work that already exists. #82 reported this comment as missing; the daemon’s log from that run shows it posted, and test_ignored_lists.py now pins it.

A parked card shows as parked in 'Icebox' in robot-army cards and on /cards, alongside whatever else it is rather than instead of it: a card can be awaiting clarification and parked at once, which is exactly what writing a vague card and shelving it produces. The poll record counts them ({"tagged": 140, "ignored": 100, ...}) and logs one line when a card is parked and one when it is released — not one per card per cycle, which on a full icebox would be the majority of the log saying nothing happened.

Whether a card is parked is the running daemon’s call, not the listing’s. The daemon is what excludes the card, so both listings judge it against the ignore list the daemon publishes on its heartbeat, and fall back to their own configuration only when no daemon is running. That matters because robot-army web reads its configuration once: before #74, a web interface started before Icebox was ignored counted every card in it under awaiting clarification, offered it a rescan, and never said it was parked. On /cards a parked card is left out of that count and has no rescan button — moving it out of the column is what brings it back.

When the two lists differ, both listings say so in one line:

IGNORE LIST MISMATCH: the running daemon is parking cards in ['Icebox'], and this process is
configured for []. …

It does not say which process is behind, because either can be — a web interface started before the edit, or a daemon nobody restarted after it. Restart whichever that is.

When a card doesn’t say enough

A card that names no onboarded repository, or names two, is held rather than guessed at. It gets one comment saying what is missing, it appears in robot-army cards and on /cards with its reason, and editing the card to name a repository — or adding the robot-army: line — resolves it on the next pass with no further action.

One hold is never the card’s fault. When no repository is onboarded at all — a fresh installation, or one whose state database was lost, which takes every onboarding with it — no card can resolve however well it is written. Those cards are held with a reason that says so and names robot-army onboard, their comment says nothing on the card needs to change, and doctor fails its onboarded repositories check. Onboarding the repository is the whole fix: a card held for that reason is re-evaluated on the first pass after something is onboarded, without anyone touching it. Until issue #83 these cards were told to edit themselves, with onboarded: none buried mid-sentence as the only clue.

uv run robot-army cards --state needs_info
uv run robot-army rescan <card-id>          # or --all-needs-info

A repository reference only counts if it is already onboarded. A card description is often pasted from a log, and src/robot_army and docs/roadmap.md both look exactly like an owner/name; filtering against the onboarded set means an unknown reference cannot select anything, so the worst case is a held card rather than an issue filed somewhere I never named.

The reference can sit anywhere in the title or the description, and a card is prose: ending a sentence with the repository is the ordinary way to write one. Punctuation after the reference ends it rather than joining it, so Names jantman/demo. counts, as do a trailing comma, colon, bracket, question mark, and a clone URL’s .git suffix on either spelling. Until issue #71 the full stop was read as part of the name, and such a card was held asking for the repository it had just named — silently, since nothing can be filed in the wrong place by it.

Saying which repository outright

Plenty of cards mention two or three onboarded repositories on purpose — a traceback from one and a link to the other is the ordinary shape of a card worth writing. Editing that text down until one reference survives throws away the context. Instead, put a line on the card that is nothing but this:

robot-army: jantman/demo

The reference takes any of the three spellings the rest of the card accepts — a GitHub URL, an owner/name, or the local clone path — and the line may be written in backticks, in any case, with whatever spacing. It decides, and every other repository reference on the card is disregarded. It is an override rather than a tie-breaker on purpose: a line that only worked when the system happened to be confused could never be tested, because I cannot see the confusion until the card is already held.

Three things it deliberately does not do:

see robot-army: jantman/demo for context is prose and does nothing — though the mention of jantman/demo still counts as a mention. Only a whole line counts.

The audit log records which way each card was decided: trello.evaluated carries "source": "declaration" or "source": "scan" — or "source": "onboarding" when nothing was onboarded to match against, and neither had a say.

One card, one issue

Enforced by two unique indexes rather than by code that has to remember, so a path that skipped its check fails loudly instead of quietly duplicating. Creation is four steps with the intent written first, and every seam between them is separately resumable — including the dangerous one, where the issue exists and nothing local knows it yet.

If the database is lost entirely, each card’s own comment names its issue, and the next poll rebuilds the mapping from it rather than filing a second one. The one gap left open is a crash between creating the issue and recording it combined with losing the database, and it is written down in state rather than pretended away.

Onboarding does not come back with it, and that is on purpose: onboarding is consent, and re-granting it from anything recovered would defeat the point of asking. Run robot-army onboard again for each repository. Until then nothing is dispatched, doctor fails, and every card is held saying so rather than asking to be edited.

Below live the number in that index is invented rather than GitHub’s — it starts at 900001, high enough that no rehearsal number can be mistaken for a real issue in a log. It is allocated above the highest already recorded for that repository, so a rehearsal files each card on its first pass however many simulated cards the repository already holds, and restarting the daemon continues the numbering rather than replaying it. purge-simulated clears those rows, and the next simulated card is 900001 again.


Next: what runs next.