XDG variables are honoured when set; these are the defaults.
| Path | Contents | Survives reboot |
|---|---|---|
~/.config/robot-army/config.toml |
Configuration. Read-only to the daemon | yes |
~/.local/state/robot-army/state.db |
SQLite: work items, sessions, repos, anomalies, poll state, holds | yes |
~/.local/state/robot-army/logs/audit-*.jsonl |
The audit log | yes |
~/.local/state/robot-army/logs/sessions/<item>.log |
Per-session wrapper log | yes |
~/.local/state/robot-army/spool/exits/ |
Exit records awaiting the daemon | yes — deliberately |
~/.local/state/robot-army/spool/exits/rejected/ |
Quarantined malformed records | yes |
~/.local/state/robot-army/heartbeat.json |
Liveness evidence | yes (and immediately stale) |
~/.local/state/robot-army/daemon.lock |
Single-instance lock | file yes, lock no |
~/.local/state/robot-army/requests/{poll,reconcile} |
Empty markers asking the daemon to run a job now | yes — harmlessly |
/run/user/<uid>/robot-army/<item>.sock |
Session host sockets | no — deliberately |
~/worktrees/<repo>/issue-<n>/ |
Isolated checkouts | yes |
Two entries in that table are load-bearing rather than arbitrary:
Sockets live under XDG_RUNTIME_DIR because it is tmpfs and cleared on reboot. A dead
socket from a previous boot is noise that reconciliation would otherwise have to reason
about; letting the kernel delete them is free. Sockets that survive within a boot because
a session died uncleanly are detected by probing — never by trusting that the file exists.
Exit records survive on purpose. The wrapper writes them as files rather than POSTing
them to the daemon, because a POST to a daemon that is down loses the record permanently —
and the daemon is legitimately down during restarts, upgrades, and reboots. A lost record
would silently downgrade a clean completion into a phantom that reconciliation could only
ever classify as interrupted. This is the single deliberate departure from the planning
document; the reasoning is research.md R5.
The heartbeat is the only place the cap in force is written down. It carries
max_concurrent_sessions alongside the effect level, the pause flag and the board’s health,
and it is the daemon’s answer to “what am I enforcing?” rather than a copy of the
configuration file. That distinction is the point: the daemon reads the cap once at startup
and cannot change it while it runs, so the heartbeat names the right number even when the
file on disk has since been edited. Every surface that prints a session fraction takes it
from here, and says so when it disagrees with its own configuration — see
what runs next.
jq .max_concurrent_sessions ~/.local/state/robot-army/heartbeat.json
A heartbeat written before this field existed has no key for it, and readers treat that as not published rather than as a cap of zero — as they do any value that could not have come from the loader.
It also carries the ignore list the daemon is parking cards from, as ignore_lists, for
the same reason: the daemon is what parks cards, and a web interface reads its own
configuration exactly once. One started before a column was added to ignore_lists used to
count every card in that column as awaiting clarification (#74). Both robot-army cards and
/cards now judge “parked” against this list whenever the daemon holding the lock wrote the
heartbeat, and against their own configuration otherwise — see
parking a card.
jq .ignore_lists ~/.local/state/robot-army/heartbeat.json
null means the daemon has no [trello] section, and — like a heartbeat from before the
field existed, or a value that is not a list of column names — it is not published, so the
reader keeps its own list. [] is believed: that daemon is parking nothing.
The lock file survives but the lock does not. flock is released by the kernel when
the holding process dies by any means, including SIGKILL. A stale daemon.lock file
containing an old PID is normal and harmless.
Job request markers survive on purpose, and cost at most one redundant job. They are
empty files whose whole lifetime is one tick, written by robot-army poll / reconcile and
by the web controls when a daemon holds the lock, and unlinked by the daemon at the top of
its next tick. A marker left over from before a reboot causes one extra poll on the next
start — and a startup already polls and reconciles, so the cost is nil. Only poll and
reconcile are valid names; anything else in that directory is left in place and reported
once, because deleting something the system does not understand is worse than leaving it.
ls ~/.local/state/robot-army/requests/ # empty almost always; a marker lives ~5s
Why a file and not a signal: the daemon may be mid-tick, and a marker waits without needing a handler in a loop whose entire design is “one thread, no interleaving”. Signalling would also mean reading a PID out of the lock file and trusting it, which is the identify-the-process-by-weaker-evidence pattern this project has already been bitten by.
config.toml holds what I declare. The database holds what the system observed.
A repository’s base branch and preparation steps are configuration and never appear in the database. Its onboarding approval, the fingerprint of its committed permissions, and when its trust was last verified are observations and never appear in the config.
That split is what keeps the config hand-editable with no migration story, and keeps the database free of values I would expect to change by editing a file.
The clone path sits on the state side of that line, and it is the one exception worth
understanding. Milestone 005 made onboarding sufficient: a repository’s clone location is
derived from [paths] repo_root, verified against the clone’s actual origin, and the
outcome is recorded in repos.clone_path at the moment I approve it. Nothing re-derives
it afterwards. That is deliberate — a rule evaluated later can produce a different answer
than the one I approved, and the wrong answer here is not an error message, it is a real
clone of a real repository. A [repos.*] path is still configuration, but after
onboarding it is an input to the next approval rather than the answer: if it disagrees
with the recorded path, dispatch blocks and names onboard --reapprove instead of quietly
switching to either one.
SQLite, WAL mode, foreign_keys=ON, synchronous=FULL. Seven tables. Directly inspectable:
sqlite3 ~/.local/state/robot-army/state.db '.tables'
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT id, state, repo_key, issue_number, failure_reason FROM work_items'
WAL mode is what lets robot-army status read while the daemon holds a write connection.
synchronous=FULL is because the machine loses power; the throughput cost is irrelevant at
this write volume.
Schema changes are a forward-only PRAGMA user_version ladder. Each migration runs in a
transaction and advances the version as its last statement, so a process killed mid-migration
leaves the version unadvanced and the whole migration re-runs on the next start.
sqlite3 ~/.local/state/robot-army/state.db 'PRAGMA user_version'
There are no downgrades. The rollback plan is restoring the file from backup.
anomalies — two different ways a row leaves the list, and whether it was a rehearsal| Column | Meaning |
|---|---|
acknowledged_at |
I looked at this and dismissed it (robot-army anomalies --acknowledge <id>) |
resolved_at |
The system re-checked and the condition no longer holds |
dry_run |
The run that raised this was below live |
The first two are separate columns because they are separate facts, and --all has to be
able to tell them apart: “this stopped being true” and “somebody dismissed it” say very
different things about whether the underlying problem was dealt with.
Four kinds resolve themselves, and only four, because only these conditions can be
positively re-established as false. orphan_session records the pid and the process
start time, so a recycled pid answers correctly rather than reading as still-alive.
card_create_failing names a card, and linked is terminal and written in the same
transaction that records the issue — so a linked card is the failed creation, negated.
registry_unobservable says a pass could not read the session registry, and the pass that
retracts it has already read it — the most direct of these, since the retracting
observation is the retraction. orphan_worktree (issue #59) names a directory by its path,
and both ways it stops being true — the directory is gone, or a work item now claims it — are
read from the disk and this database, never from git. An anomaly whose detail carries no pid, or whose card is no
longer in the database, is left alone permanently: “I could not check” must never be stored
as “it is fine”.
One kind is restated rather than kept (issue #73). For every other kind the first
detail is evidence — a pid, a path, a card — and INSERT OR IGNORE is right to keep it
however often the condition is seen again. A board_precondition row’s detail is instead
the reason board ingestion is off, so while the row is open, a board check that fails
differently rewrites its detail and moves its detected_at to that check. detected_at on
such a row therefore means “when the reason it names was found”, not “when the board first
broke”; the earlier time is kept in the anomaly.restated record. An acknowledged or
resolved row is never rewritten — the guard is in the UPDATE itself.
dry_run is a property of the run, not of the entity named (added by migration 14,
issue #21). Deriving it from entity_id looks like one join and is six — work item,
session, card, repo, board, and none — is undefined for the four kinds that name no entity,
and changes its answer the moment purge-simulated deletes the row it depended on. So it is
written when the anomaly is raised: the nine sites whose subject is a work item, a session
or a card pass that row’s flag, and everything about the machine, the filesystem or the
network stays real, because those are real at every effect level.
It is NOT NULL DEFAULT 0, which is the opposite choice from migrations 11 and 13 and
deliberately so. There is nothing to backfill from — a pre-14 row carries no evidence of
which run raised it — and 0 reads as real, so every existing row stays visible. Showing
a real anomaly that was in fact a rehearsal is a row I dismiss; hiding a rehearsal’s anomaly
that was in fact real is a condition I never see. Only one of those is recoverable.
A row with either stamp set drops out of the partial unique index that keeps a 60-second reconciliation loop from writing 1,440 identical rows a day. That is load-bearing, and getting it wrong is silent: a resolved row left inside the index would stop the same condition from ever being reported again, with no error anywhere — the symptom would be an anomaly that simply never arrives. Migration 12 rebuilds the index for exactly this reason, and a test raises, resolves, and re-raises to prove the recurrence still lands.
Migration 14 rebuilds it again, to add dry_run. Without that, a rehearsal and a live run
reporting the same condition for the same entity collide, and the INSERT OR IGNORE keeps
whichever arrived first — so a real anomaly could be swallowed by a rehearsal and then be
invisible in the default view, which is worse than the bug that migration exists to fix.
Nothing is backfilled. Every row that existed before schema 12 comes through with
resolved_at NULL, which is correct: whether its condition still holds is a question for
the next reconciliation pass, not for a migration.
repos — what I approved, and whereOne row per onboarded repository. The row is what makes a repository known: since
milestone 005 the polled set, the dispatchable set, and what robot-army repos lists all
come from this table, and a [repos.*] section with no row here is a set of overrides for a
repository the system does not watch.
| Column | Added | Meaning |
|---|---|---|
repo_key |
001 | owner/name, primary key |
onboarded_at |
001 | first approval |
settings_fingerprint |
001 | approved committed-settings hashes; NULL means there were none |
fingerprint_approved_at |
001 | last approval of that fingerprint |
trust_verified_at |
001 | when the worker trust dialog was last seen accepted |
clone_path |
005 | the location approved, absolute and with symlinks resolved |
path_source |
005 | derived or configured — which answer produced clone_path |
verified_origin |
005 | the normalised host/owner/name found there |
origin_verified_at |
005 | when that comparison last passed |
Two things about these four are worth stating rather than inferring.
verified_origin stores the normalised identity and never a raw URL. A git remote URL
may embed credentials (https://user:token@host/owner/name), and this column is read back
into robot-army repos output and every JSON view of it. Normalisation strips the
userinfo@ component before anything else, so the stored value cannot carry a secret. The
same rule applies to the audit log and to every refusal message.
A NULL clone_path means onboarded, location never verified — a row written before
migration 005 — and not “onboarded at an unknown path”. Nothing backfills it. Writing a
path nobody approved into an approval record is the one thing this table exists not to do,
so dispatch refuses for such a repository and names onboard --reapprove as the fix. That
is one command per repository, and it is the honest price of never guessing.
An empty settings_fingerprint recorded before issue #20 was fixed is not evidence of
anything. Below effect level local the read behind the onboarding review answered “no such
file” for every path, so every approval made at plan recorded an empty mapping — including
for repositories that do commit settings, whose review screen was blank when it was approved.
Nothing backfills those rows either, and for the same reason: writing hashes into an approval
record on the strength of a code change would forge the one thing the record asserts. They are
caught instead — the settings now found at dispatch no longer match the empty approval, so
dispatch blocks naming the files as added: and onboard --reapprove shows the real review.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT repo_key, clone_path, path_source, verified_origin FROM repos'
dispatch_control — is dispatch paused?Added by migration 002. One row, and the CHECK (id = 1) makes a second one impossible
rather than merely unlikely — “which of the two pause rows is authoritative” is a question
that must never be askable.
| Column | Meaning |
|---|---|
paused |
0 or 1. Read by the daemon before every dispatch decision |
paused_at |
When it was set. NULL when not paused |
paused_by |
web or cli — so “who stopped dispatch” is answerable from state as well as from the log |
sqlite3 -header -column ~/.local/state/robot-army/state.db 'SELECT * FROM dispatch_control'
robot-army pause # and `unpause`
robot-army status # reports it, as does heartbeat.json and every web view
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, so lifting the pause needs no unwinding.
It is in the database rather than in a config key or a marker file because durability across restart and reboot is the entire point: a pause that lapses when the daemon restarts is worse than no pause, because I would believe work was held when it was not. It is also an operational act rather than something I declare, which is the line the section above draws.
item_holds and repo_holds — what I have taken out of dispatchAdded by migration 010 (issue #117). Two tables rather than one with a scope column, and the
reason is the foreign key: a hold must never outlive the thing it holds, and
ON DELETE CASCADE makes that a constraint rather than a rule every future deletion site has
to remember. A single table could not express it — its target would point at work_items for
one scope and repos for the other, and no foreign key says “one of these two depending on a
sibling column”.
| Column | Meaning |
|---|---|
work_item_id / repo_key |
The target, and the primary key — so holding something twice collides with itself and is a reported no-op rather than a second row |
held_at |
When it was placed. Never rewritten by a repeated hold |
held_by |
web or cli. NOT NULL, unlike dispatch_control.paused_by, because a hold has no cleared state: the row exists or it does not |
sqlite3 -header -column ~/.local/state/robot-army/state.db 'SELECT * FROM item_holds'
sqlite3 -header -column ~/.local/state/robot-army/state.db 'SELECT * FROM repo_holds'
robot-army holds # both, with ages, including ones holding nothing
robot-army hold 42 # and `unhold 42`
robot-army hold --repo owner/name # and `unhold --repo owner/name`
A held item stays in the queue, in the position it would occupy anyway, reported with a held
reason that ranks directly below paused — every reason beneath it would name a fix that
cannot work while I am the one holding it. Holding a repository holds that repository’s work
and never the queue: every other repository dispatches in the same pass.
Nothing sweeps a hold. It is released by unhold and by nothing else — not by time, and
not by the item finishing. Clearing on a state transition would be expiry under another name,
and expiry would silently restart work I stopped. A hold left on a finished item is visible in
robot-army holds, which shows each held item’s state, rather than being tidied away where I
could not see it. What does remove one is the cascade: purging simulated rows takes their
holds with them, so purge-simulated needs no knowledge of holds at all.
item_keeps — sessions I have kept past their issueAdded by migration 016. One row per work item whose session should outlive its issue closing
(see keeping a session after the merge).
Its own table, not a scope column on item_holds, and with its own column names: a hold
stops work starting and a keep stops a session ending, and a row here reading held_at would
be exactly the confusion between the two the design refuses. Otherwise it is shaped like
item_holds, for that table’s reasons.
| Column | Meaning |
|---|---|
work_item_id |
The kept item, and the primary key: keeping it twice is a reported no-op. REFERENCES work_items(id) ON DELETE CASCADE, so purging simulated rows takes their keeps with them |
kept_at |
When it was placed. Never rewritten by a repeated keep |
kept_by |
web or cli |
sqlite3 -header -column ~/.local/state/robot-army/state.db 'SELECT * FROM item_keeps'
robot-army keeps # every keep, with each item's state and session
robot-army keep 42 # and `unkeep 42`
Nothing sweeps a keep, for the reason nothing sweeps a hold: no expiry, and no clearing
when the item finishes or its session ends. A keep whose session is gone is inert — it holds
no slot and blocks no cleanup — and keeps shows it as no live session. No dry_run
column: a keep applies to a simulated item as to a real one.
Across a reboot the keep survives and the session does not. The worker is gone, so the next pass’s stale-session sweep finds its row’s process dead and closes the row as it would any other, keep or no keep — a kept row whose worker is dead is never left holding a slot. The keep is left in place, inert, until I release it.
cards — the intake board, and the mapping to what each card becameAdded by migration 003. A table of its own rather than columns on work_items, and the
reason is concrete: work_items.repo_key is NOT NULL REFERENCES repos(repo_key) and
issue_number is NOT NULL, while a card awaiting clarification has neither by definition.
Accommodating it there would have meant rebuilding the central table to weaken an invariant
every other row depends on. The mapping also has to outlive any work item — a card’s issue
may sit unlabelled for weeks, and may be refused at onboarding and never become a work item
at all.
repo_key here is deliberately not a foreign key into repos. A card may name a
repository that is configured but not onboarded (such a card still gets an issue, because
creating one is not dispatching), and may name one that is configured and later removed. A
foreign key would either forbid the row or delete the mapping — and deleting a mapping is how
a duplicate issue gets created.
| Column | Meaning |
|---|---|
board_id, card_id |
The card’s identity on the board |
state |
discovered → needs_info | creating → linked, or dropped. See below |
repo_key, issue_number, issue_url |
The mapping, once one exists |
reason |
Why this card is held, or why creating its issue keeps failing |
commented_reason |
The last reason actually written onto the card |
last_activity |
The board’s activity stamp, as the rescan baseline |
origin_list_id |
Where the card was before we ever touched it |
placed_list_id |
Where we last put it |
pending_move_to |
Where we were about to put it |
current_list_id, current_list_name |
Where it is now, refreshed every poll (migration 006) |
comment_posted_at |
When the marker comment was posted |
intent_at |
When creation was attempted — the bound on crash recovery |
Four list columns, because they answer four different questions. Three of them are about
the past — where the card started, where we put it, where we were about to put it — and only
current_list_id is about the present. Milestone 006 needed the present one: a tagged card in
a column named by [trello] ignore_lists is not intake, and robot-army cards has to be able
to say so with the board unreachable. NULL means tracked before migration 006 and not yet
re-polled, which is treated as not parked.
The name is stored beside the id because its two consumers cannot share one representation. The intake gate runs inside the poll, where the board’s id-to-name map is in hand, and wants an id: an equality check that is duplicate-safe (Trello permits two columns of the same name) and survives a rename mid-run. The listing commands run where the board is not available at all and can only compare against the names in the configuration. Both values are written by the same statement from the same poll, so they cannot disagree.
The §11 invariant is two unique indexes, not a rule the create path follows.
idx_cards_identity is unique on (board_id, card_id, dry_run), and the partial
idx_cards_issue is unique on (repo_key, issue_number, dry_run) where issue_number IS NOT
NULL. A create path that skipped its mapping check does not produce a duplicate; it produces
an IntegrityError, which is loud. That is the difference between an invariant and a
convention. dry_run is part of both keys, exactly as it is for work_items, so a simulated
run and a later live run of the same card coexist rather than the rehearsal suppressing the
real thing.
reason versus commented_reason is the whole of the one-comment rule: comment when
they differ, stay silent when they do not. A card held for weeks accumulates one comment, not
one per poll — and a card whose problem changes gets a second comment saying so, which a
simple “have we commented?” flag would get wrong.
Three list columns, not one. origin_list_id is what an abandoned card is returned to;
placed_list_id is what detects a move by me; pending_move_to is written before a move is
attempted, so a move of ours that was interrupted after it landed is not mistaken for one of
mine on the next pass. Getting that backwards would freeze a card’s lifecycle at the first
interruption.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT card_id, state, repo_key, issue_number, reason FROM cards'
robot-army cards # the same thing, readably
robot-army rescan --all-needs-info # look again at everything held
work_items cleanup columns — what happened to a finished item’s diskAdded by migration 004. Three nullable columns rather than a table: one row per item, one shot, no lifecycle of its own, so a table would have been a join for nothing.
| Column | Meaning |
|---|---|
cleanup_state |
NULL | done | branch_retained | retained | skipped. See the table below |
cleanup_reason |
Why — git’s own refusal message, the containment evidence, or the session that was live |
cleaned_at |
When the decision was recorded |
cleanup_state |
Worktree | Branch | What the next pass does |
|---|---|---|---|
NULL |
— | — | Considers it, if eligible. Every pre-migration row reads this way, and so does every row while [cleanup] on_issue_close is false |
done |
removed | removed | Nothing |
branch_retained |
removed | kept | Nothing — a retained branch is a decision, not a pending step |
retained |
kept | kept | Nothing automatically; robot-army cleanup <id> reconsiders |
skipped |
kept | kept | Reconsiders it — a session was live, which means “not yet” |
skipped is the only non-NULL value the automatic pass revisits, and that is the entire
point of distinguishing it from retained: one means “not yet”, the other means “we looked
and decided no”.
work_items.state is untouched and WORK_ITEM_TRANSITIONS gains no entries. done is
terminal and means the work is finished; whether its disk has been reclaimed is a different
axis — the same separation this project already makes between work state and session state.
Adding a cleaned state would have made every existing query that treats done as terminal
subtly wrong.
Two writers. Cleanup, and — since issue #113 — robot-army worktree remove <id> on a
finished item, which records done or branch_retained with a reason that ends by naming
robot-army worktree remove. The disk is in the same state whoever removed it, so the
values are the same; the reason says who.
worktree_path and branch are kept after a successful removal of a finished item, by
either writer. The record has to retain what was removed — “what was at this path?” is exactly
the question a branch_retained row has to answer months later — and _sweep_worktrees tells
a removed worktree from a hand-deleted one by cleanup_state alone: done or
branch_retained accounts for a missing directory, anything else does not. The one exception
is worktree remove <id> on an unfinished item, which clears the path and writes no record:
such an item can still be given a fresh worktree by retry, and a record describing the old
one would silently exempt the new one from the sweep and from cleanup.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT id, state, cleanup_state, cleanup_reason FROM work_items WHERE cleanup_state IS NOT NULL'
work_items spec-kit columns — how far a Spec Kit run has gotAdded by migration 007. Four nullable columns, no table, no state machine: a phase is not a
state and WORK_ITEM_TRANSITIONS gains no entries, deliberately — nothing decides anything
on these (FR-016), they only display.
| Column | Meaning |
|---|---|
speckit_baseline |
JSON array of the feature directory names present in the worktree when it was created. NULL means none was recorded |
speckit_phase |
NULL | specify | plan | tasks | implement — the last rung derived |
speckit_feature_dir |
The worktree-relative directory it was read from, e.g. specs/007-speckit-extensions |
speckit_phase_at |
When the transition was recorded |
speckit_baseline is the one that earns its place. A fresh worktree of a repository that
uses Spec Kit contains every feature it has ever shipped — in this repository, six
directories, each with a spec, a plan, and a tasks.md full of ticked boxes. Deriving a
phase from “which artifacts exist” would therefore report implement the instant a worktree
was created, on every item, forever. Recording what was there at the start makes “a directory
that was not here before” mean “this session’s feature”, with no heuristics and no
timestamps, because /speckit-specify always creates a new one.
NULL and [] are different answers and must not be conflated. [] is a Spec Kit worktree
with no features yet, so every directory that appears belongs to this item. NULL is no
baseline was recorded — a row predating the migration, or a preparation that died before its
transaction committed — and such an item never reports a phase at all. robot-army show says
so in as many words rather than leaving the silence to be puzzled over.
Observation never clears these columns. A worktree removed by milestone 004’s cleanup, a deleted artifact, an unreadable directory: all of them leave the last recorded phase standing. It is history at that point, and the log has no way to restore what clearing would delete.
The phase is a cache whose only job is transition detection — “did this change since I last looked” is unanswerable without the previous value. The worktree stays the source of truth and every reconciliation pass re-derives from it, so a daemon that was down for hours reports the right phase on the next pass without having watched a single intermediate step.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT id, state, speckit_phase, speckit_feature_dir FROM work_items WHERE speckit_phase IS NOT NULL'
work_items pull-request columns — what the work producedAdded by migration 013. Two nullable columns, no table and no state machine, for the same
reason the spec-kit columns above have neither: nothing decides anything on these. They
display, and the wait_for_merge gate and the closed-issue sweep are untouched by them.
| Column | Meaning |
|---|---|
pull_requests |
JSON array of {number, url, state} sorted by number, where state is open, merged or closed |
pull_requests_at |
When that set was last successfully confirmed |
NULL and [] are different answers, and the whole feature turns on it.
| Value | Means | Shown as |
|---|---|---|
NULL |
never looked up — a row predating the migration, an item never dispatched and so without a branch, or a simulated one nothing may ask GitHub about | not checked |
'[]' |
GitHub was asked, and there is no pull request | none |
'[{…}]' |
these, as of pull_requests_at |
the links |
Collapsing the first two would put “there is no pull request” on the page on the strength of
never having asked — the same distinction speckit_baseline above and author below draw,
and drawn here because a surface acts on it.
pull_requests_at advances only on success. A failed lookup writes neither column, so
the stored answer stands and the age shown beside it keeps growing, which is the truth: the
age of the answer, not of the last attempt. The failure goes to the log as
reconcile.pull_requests_check.
The refresh is not routed through the general column updater, and that is deliberate:
updated_at must not move. This runs every reconciliation pass for every live item and almost
every run confirms an unchanged set, so the general updater would push updated_at forward
once a minute for every item in the system — turning a column that means “when this item last
changed” into “when the daemon last looked”.
Which items are re-checked, and why it stops. One query, db.list_pull_request_candidates,
asking whether the answer can still change. An item with a branch and not simulated, and any
one of:
active, awaiting_review or interrupted — a pull request can appear at any moment;open — because an issue can be closed by hand
while its pull request is open, which would otherwise freeze the page at open for ever;starting or running — because a worker
that has not opened a pull request yet may still open one, and an empty set is not settled
the way merged is.Clause 3 closes a race clause 2 alone loses permanently: close the issue by hand mid-session
and the item goes done holding [], after which a pull request opened by the still-running
session would never be seen and the page would read none for ever.
Each clause runs out on its own — the state changes, the pull requests settle, the session
ends — which is what lets this ship with no interval, no cap and no configuration key. Nothing
is backfilled: an item that finished before migration 013 keeps its NULL.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT id, state, pull_requests, pull_requests_at FROM work_items WHERE pull_requests IS NOT NULL'
poll_state.etag_request — which request an ETag answeredA GitHub poll’s ETag is only good for the request that earned it. The request is
label-filtered, so for a while poll_state held one ETag per repository and nothing about
which query it came from. Changing [github] label then sent every repository’s old ETag
against a different query. GitHub answered 304, the poll read that as “unchanged”, and
discovery went blind across every repository at once. status, doctor, health and the
web all reported normal (issue #60).
etag_request holds the whole request line: the path, then every query parameter sorted by
key.
/repos/jantman/robot-army/issues?direction=desc&labels=robot-army&per_page=100&sort=updated&state=open
The stored ETag is sent only when that line is identical to the request about to go out.
Otherwise it is dropped, the request goes out unconditionally, and the github.poll record
says why (audit log). The ETag and its line are written in
one statement, so a kill leaves either the old pair or the new one, never a mix. A failed poll
writes the stored pair back unchanged.
It is text rather than a hash so the row explains itself. It never includes a header, so it never holds the token.
NULL means never recorded, and it does not match. That covers every row from before
migration 015, and the Trello board row. Nothing was backfilled, deliberately. On the machine
that found this, nobody can say which label those ETags answered, and writing today’s request
into the column would claim they all answer it. That claim is exactly the one that caused the
blindness. So each repository makes one unconditional request after the upgrade, and nothing
needs editing by hand.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
"SELECT repo_key, etag, etag_request FROM poll_state WHERE repo_key NOT LIKE 'trello:%'"
poll_stateUnder the synthetic key trello:board:<board_id>. poll_state has no foreign key and no
consumer that renders its rows as repositories, so a non-repository key is safe and a second
identically shaped table would have been a table added to satisfy a naming preference.
The etag and etag_request columns stay NULL for that row. Trello offers no usable conditional request on
the endpoint the board poll needs, so the ETag economy that makes a 60-second GitHub poll free
does not exist here — which argues for a longer interval rather than a cleverer mechanism.
The board poll defaults to 300 seconds against GitHub’s 60.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
"SELECT * FROM poll_state WHERE repo_key LIKE 'trello:%'"
repo_projects — which project board governs a repositoryAdded by migration 009 (issue #48). One row per repository, written by the poll and deleted by nothing.
A table rather than columns on repos, and the reason is the same one that shaped cards:
repos is an approval record. It stores what a human approved at a verified location and
nothing re-derives after approval. This is the opposite — discovered, refreshed every poll,
carrying its own failure state — so putting it there would blur the one record whose value is
that it does not change on its own. It is not poll_state either, whose columns are fixed
(etag, last_status, backoff) with nowhere to hold a project id, a column name, or which
of the two the author chose.
resolved_at and last_read_at answer different questions and both are load-bearing. A
project can be resolved and never yet read — the pass that resolved it failed, or has not run
— and in that state nothing is reordered and nothing is held. Only last_read_at opens the
gate, and it records the last success, never the last attempt: a failed read leaves the
previous snapshot in force and visibly stale rather than discarding it. A board nobody can
currently read still governs; a board nobody has ever read governs nothing.
unresolved_reason is non-NULL exactly when resolved_at is NULL, and holds the sentence
robot-army status prints. That is why “why is this repository not ordered by its board?” is
answerable with the network unplugged.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT repo_key, project_number, column_name, resolved_at, last_read_at,
unresolved_reason, consecutive_failures FROM repo_projects'
work_items board columns — where the board puts each itemAlso migration 009. Two columns, and the pair must stay a pair, because four states have to remain distinguishable and collapsing any two of them is a real bug:
board_column |
repo_projects.last_read_at |
Means |
|---|---|---|
NULL |
NULL |
No board knowledge. Nothing is gated, and the repository orders exactly as it always did |
NULL |
set | Read, and this item is not on the board: dispatchable, ordered after everything the board ranked |
| the dispatch column | set | board_position is its rank, 1-based |
| anything else | set | Parked by the author. Held, with board_position NULL |
The difference between the first two rows lives in repo_projects, not here, so “never read”
stays one fact about a repository instead of a fact repeated on every one of its rows.
board_position is NULL for everything outside the dispatch column and is never written
as 0 to mean “unknown”. boundaries/__init__.py records what that mistake cost the last time
it was made — commits_ahead folding “could not determine” into 0 — and here it would
silently promote every item of an unread board to the head of its queue.
Nothing is backfilled. Every pre-009 row means no board knowledge, which is exactly what
NULL says; an upgrade that wrote board facts would be changing dispatch order from
information it does not have.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT id, issue_number, state, board_column, board_position FROM work_items
WHERE board_column IS NOT NULL'
work_items.author — who wrote the issue, and why the column exists at allAdded by migration 011 (issue #119, RA-01). One nullable column, no table, no state.
| Value | Means | Written by |
|---|---|---|
| a login | who wrote the issue, as the last read of it reported | the poller at discovery; retry and reset on every successful re-read |
NULL |
never recorded — a row predating migration 011 | nobody; there is no backfill |
Why it exists. The author check is the control that stops “anyone may open an issue on a
public repository” becoming “anyone may run an agent in my checkout”. It was enforced in
exactly one place — poll.evaluate — and retry returned an author-rejected item to the
queue without re-running it, because dispatch.check_gates takes a RepoConfig and cannot
see an issue at all. _dispatch_item then built its Issue with
author=config.github.author: a value asserted, never read, which made the code look as
though a check happened downstream and removed the last natural place to notice that none
did. This column is what turns that assertion into a comparison.
The alternative was a second HTTP read on the dispatch path, which today makes none and would have had to grow its own timeout, retry and backoff to gain one. A nullable column costs one string compare and behaves identically on a redispatch.
NULL is not “no author”. It is this row’s provenance cannot be established, and that
is a real state: a ready row from before migration 011 may have reached ready through the
very defect the migration exists to close, and no query answers which. So NULL refuses the
dispatch, and the refusal names robot-army retry <id> as the recovery — which re-reads the
issue and writes the column for the first time. The upgrade heals itself along the path the
fix already hardens.
Nothing is backfilled, and the contrast with migration 008 is the argument. 008
backfilled sessions.transcript_checked_at because it could derive the right answer — those
sessions really had been judged. Writing config.github.author here would be the opposite:
an unverified claim in the one column whose entire purpose is to hold a verified one, which
is what migration 005 refuses to do with clone paths, in the same words.
retry refreshes title, body, labels and author together from the read it performs,
on the refused path as well as the allowed one, so a blocked item on the queue describes the
issue as it currently is rather than as it was at discovery. Since issue #179 reset does the
same, through the same code: both verbs put an item back in the queue, both must answer the
author question first, and one implementation of that question is the whole reason reset was
written as a composition rather than as a second read of its own.
One upgrade wrinkle, worth knowing before it surprises me. resume and restart reach
the launch through dispatch_item too, so a pre-011 item sitting in interrupted is
refused into failed the first time I try to resume it. The named recovery still works —
retry applies to a failed item, re-reads the issue and writes the author — but the item
comes back as ready, so the next dispatch is a fresh session rather than a resumed one.
The worktree survives; the previous transcript’s context does not.
That is the right trade rather than an oversight. Exempting an in-flight item would be trusting it precisely because it is already in flight, which is the reasoning that produced RA-01 — and an item interrupted after the old bug queued it is exactly the one that must not be resumed. It costs at most one session’s context, once, on items that were mid-flight at the upgrade.
sqlite3 -header -column ~/.local/state/robot-army/state.db \
'SELECT id, state, author FROM work_items WHERE author IS NULL'
Creating an issue from a card is four steps, each in its own transaction, because every seam between them has to be separately resumable:
INSERT a cards row in creating with the resolved repository and intent_at.UPDATE the row with the issue number and URL, state linked. This is the mapping.comment_posted_at.The dangerous window is between 2 and 3: the issue exists and nothing local knows it. Recovery
for a row found in creating lists issues in the target repository created since intent_at,
authored by me, and looks for the card’s URL in the body. Listing, never search — GitHub’s
search index lags by minutes, so an issue created two seconds before a crash may be invisible
to it, producing exactly the duplicate the mechanism exists to prevent.
The known limit, stated rather than papered over. A crash between steps 2 and 3 combined with total loss of the database leaves an issue nothing can find: no mapping, no intent row, and no card comment. The next poll creates a second issue. This is a double failure, it is recoverable by hand — the stray issue is visible in the repository and is unlabelled, so it dispatches nothing — and closing it would mean scanning every configured repository’s recent issues before every creation, a cost paid on every card forever. If it ever actually happens, revisit it then.
Database loss on its own is covered: each card carries a marker comment naming its issue, and the next poll restores the mapping from it one card at a time. The marker is a recovery marker, not the primary key — with a mapping row present the card’s comments are never read at all.
Deliberately, and each for the same reason: the thing they save is cheaper to re-derive than a table is to keep correct.
| Lost on restart | Cost of losing it | |
|---|---|---|
| The capacity hold’s signature | yes | one extra dispatch.at_capacity record |
| The notifier’s per-cycle counter | yes | a handful of extra messages in one cycle |
| Which finished items’ windows have been dealt with | yes | one extra kitty @ ls per outstanding item, once |
None of them can be wrong in a way that matters: each is a memory of having already done something harmless, so forgetting it repeats the harmless thing and nothing else. That is the test a candidate for this list has to pass — and it is why “which windows are closed” qualifies while “which worktrees were removed” would not.
Every session is gone; every active row still says active. On the next start:
awaiting_review rather than mistaken for one that vanished.active item it finds no
live process and no exit record, and moves it to interrupted./run/user is tmpfs.None of this raises an error-level record. A reboot legitimately produces one interrupted
item per session that was running, and treating that as an error would train me to ignore
errors.
A pause survives all of this. If dispatch was paused before the reboot it is still paused
after it, and only robot-army unpause or the web control clears it — never time, never a
restart. That is the whole reason it lives in the database.
Nothing resumes automatically. robot-army status lists what is waiting; `robot-army show