robot-army

State: where it lives and what survives a reboot

The layout

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.

The configuration / state split

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.

The database

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 where

One 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 dispatch

Added 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 issue

Added 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 became

Added 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 disk

Added 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 got

Added 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 produced

Added 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:

  1. in active, awaiting_review or interrupted — a pull request can appear at any moment;
  2. still holding a pull request recorded as 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;
  3. holding an empty set with a session still 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 answered

A 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:%'"

The board’s poll bookkeeping lives in poll_state

Under 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 repository

Added 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 item

Also 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 all

Added 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'

The card creation sequence, and where it can be killed

Creating an issue from a card is four steps, each in its own transaction, because every seam between them has to be separately resumable:

  1. INSERT a cards row in creating with the resolved repository and intent_at.
  2. Create the issue. Its body carries the card’s URL.
  3. UPDATE the row with the issue number and URL, state linked. This is the mapping.
  4. Comment on the card with the issue URL, then record 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.

Three things that live only in memory

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.

What a reboot does

Every session is gone; every active row still says active. On the next start:

  1. The spool drains first, so any session that exited cleanly before the reboot is recorded as awaiting_review rather than mistaken for one that vanished.
  2. Reconciliation runs before any dispatch. For each remaining active item it finds no live process and no exit record, and moves it to interrupted.
  3. Stale sockets are already gone, because /run/user is tmpfs.
  4. Worktrees are still on disk, on their branches, with whatever the session had written.

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

` reports whether the worktree has uncommitted changes, whether the branch has commits, whether the issue is closed, and whether a PR is open — computed on demand, never stored, because a stored copy would be wrong the moment I touched the directory. "Never stored" is exact: nothing is written to disk or to the database. Both halves *are* reused in memory for a short window — five seconds for the two that read the checkout, a minute for the two that ask GitHub — and every rendered value carries its own age, so a reused answer says so on the page rather than passing itself off as current. The five seconds sits below the ten-second page refresh on purpose, so a page left open still observes the checkout afresh on every refresh; what the window removes is a page on another site asking for `/interrupted` in a loop and forking `git` per card at whatever rate it can send. Acting on an item drops both halves for that item immediately. ## Interrupted at X → result on next start The full table is in [data-model.md](https://github.com/jantman/robot-army/blob/main/specs/001-minimum-daemon/data-model.md#interruption-behaviour). The summary: | Interrupted at | Result | |---|---| | After the `discovered` insert, before evaluation | Row in `discovered`; re-evaluated on the next poll | | During the launch gate — the cap, the pause, the holds | Nothing was written; the item is exactly as it was. The gate runs before the claim precisely so a refusal, and an interruption during one, both cost nothing | | Between the gate and the claim | Same: nothing written, item unchanged. Press the button again | | During the claim | One conditional `UPDATE` in one transaction carrying its own audit record. It commits or it does not; there is no state between, and no change without the record or record without the change | | During worktree creation or a preparation step | Item in `dispatching`; failed at max age with whatever output exists. The partial worktree is reported, never reused blindly | | After the session row insert, before launch | Confirmation window elapses; session `lost`, item `failed` | | After launch, before confirmation | Either the registry scan confirms it late, or the window elapses and the orphan sweep catches the live process | | During a project board read | Nothing has changed. The read is a read, the previous snapshot still stands, and the next poll re-reads it | | Between the board read and its write | The snapshot is lost, costing one rate-limit point. The previous one stays in force | | During the board write | One transaction covers the `work_items` update and the `repo_projects` upsert, so it rolls back whole — never half of one board beside half of another | | During migration 009 | `user_version` is advanced last, so the whole migration re-runs | | After the worker exits, before the spool drains | The file persists and is applied on the next tick or next startup | | After applying a spool record, before unlinking it | Reapplied; application is idempotent | | Mid-migration | The migration re-runs; `user_version` never advanced | | Mid-audit-write | A partial final line is skipped and counted by readers | | Mid-`set_dispatch_paused` | Rolled back; dispatch continues as before. The pause is never half-applied | | After a card's intent row, before its issue exists | Row in `creating`; the listing finds nothing and step 2 is retried | | After a card's issue, before its mapping | Row in `creating`, issue orphaned; the listing since `intent_at` finds it by the card URL in its body and adopts it | | After a card's mapping, before its comment | Row `linked`, `comment_posted_at` NULL; the next pass checks the board for an existing marker, then posts | | After a card move landed, before it was recorded | `pending_move_to` matches where the card actually is, which identifies the move as ours rather than mine | | Between a card write and its `last_activity` refresh | One redundant re-evaluation, which is idempotent and posts no comment because `commented_reason` is unchanged | | With the database lost entirely | Each card's marker comment restores its mapping on the next poll. The residual gap is the double failure above | | After writing a request marker, before the daemon read it | The marker persists and is consumed on the next tick or the next start | | After the daemon unlinked a marker, before running the job | The forced flag is lost and the job runs on its ordinary interval. Accepted: the cost is one interval, and unlinking *after* would risk running it twice | | Web process killed mid-request, before the transaction committed | Rolled back. The browser sees a dropped connection and the item page tells the truth on reload | | Between the worktree existing and its spec-kit baseline being recorded | Cannot happen: the baseline commits in the same transaction as `worktree_path` and `branch`, so a kill before it redoes the whole preparation | | After a `speckit.phase` line was written, before its row committed | The next pass re-derives the same phase from the same files and records it again. A duplicated line at worst, never a change with no record of it | | Mid-observation | Nothing is written until the derivation completes. The next pass re-reads the same worktree | | Web process killed while a worker thread was dispatching | The item is left mid-dispatch and reconciliation resolves it — the same path any interrupted dispatch takes. This is not a new way to produce that condition | | After a capacity snapshot, before the dispatch it authorised | Nothing written. The next pass takes a fresh snapshot; a snapshot is never stored, so it cannot be stale | | Between two dispatches in one pass | Earlier items dispatched, later ones still `ready`. The next pass re-observes and re-plans — `select_and_dispatch` holds no cross-pass state | | During a capacity hold, before its signature was recorded | No `dispatch.at_capacity` record for this hold. The next pass sees no remembered signature and writes one. Worst case is one extra record, never a missing hold | | Mid-sweep of a finished item's terminal windows, or a restart at any point | Nothing is written, because the sweep writes no state at all. Windows already closed stay closed and are simply absent from the next listing; the rest are taken next pass. The one casualty is the in-memory set of items whose windows have been dealt with, and losing it costs exactly one extra `kitty @ ls` per outstanding item — the same trade, and the same reasoning, as the capacity hold's signature above | | After `git worktree remove`, before the branch half | Worktree gone, branch present, `cleanup_state` unwritten. The next pass finds the directory already absent, treats git's "not a working tree" as a refusal about its *record* rather than about the contents, completes the branch half, and records the outcome | | After both cleanup removals, before the row is written | Both gone, `cleanup_state` still `NULL`. The next pass re-attempts, both steps refuse harmlessly, and the row is written `done` | | `worktree remove ` on a finished item, after the worktree removal and before the branch half | Worktree gone, branch present, no record, path still on the row. The next pass reports `prunable_worktree`, and running the same command again finds the directory already gone, completes the branch half and records the outcome. Before issue #113 this left a finished item reported by nothing | | `worktree remove ` on a finished item, after both removals and before the record commits | Both gone, no record. Reported the same way; the re-run finds the branch already gone, says so rather than warning that it survived, and records `done` | | `reset` after the issue was re-read, before the checkout was discarded | Content refreshed, state unchanged, checkout intact. The next `reset` re-reads and proceeds. Deliberately this way round: the read comes first so a refusal never costs the work | | `reset` after the checkout was discarded, before the item reached `ready` | Worktree and branch gone, `worktree_path` cleared, state unchanged. The next `reset` finds nothing to discard, re-reads, and requeues — or `restart` builds a fresh checkout against the refreshed content | | During the containment fetch | Nothing removed. Containment is unproven, so the branch is retained and the item is reconsidered. The failure direction is always *keep* | | After a state transition, before its notification | State committed and logged; no message sent. The state change is fully reconstructible; the lost message is the named gap in [the audit log](/robot-army/guide/audit-log.html) | | Mid-notification, after the POST left | Possibly delivered, recorded as attempted with its outcome. **No retry** — a duplicate notification is noise, and a retry loop is a Principle IV violation | | After `cancel` signalled the process, before the transaction committed | Rolled back: the item is still `active` and its session row still open, while the process is stopped. The reconciliation sweep resolves it — an open row under an item that is not `dispatching` or `active` is stale by definition, and this is why closing it at the command is belt rather than braces | | Mid-sweep of stale session rows | Rows already reclaimed are committed with their audit records; rows not yet reached stay open and are taken by the next pass. Each row is decided independently, so there is no cross-row state to lose | | After a row was reclaimed, when its exit record finally arrives | `spool.apply_record` returns `duplicate` — its terminal set already includes `lost` — and the work item is untouched, because that path only moves an item in `active`. The history keeps `lost` rather than the truer exit status. Accepted: a weaker reason in the log, never a wrong state | | Mid-sweep of an item's superseded session rows | Each superseded row is decided and committed on its own, so a killed pass leaves the rows it reached settled and the rest for the next one. The item's own state is decided by its current attempt alone and is never touched by this sweep, so a partial sweep cannot leave the item disagreeing with its sessions | | A superseded row is closed, then its worker turns out to have been alive | Cannot happen in that order: the liveness check runs *before* the decision to close, and a row whose worker can be seen is reported and left open rather than closed. The failure direction is always *leave it counting* — an under-count of running sessions is the only capacity error that does harm | ## A registry that cannot be read is not a machine with nothing on it Reconciliation decides a session is gone by failing to find it in the session registry. That reading is only sound if the registry was read. When it cannot be — the directory is missing or unlistable, the scan fell back to `/proc`, or a file carries a version this daemon refuses — an absence is indistinguishable from every session having exited, and **every `active` item used to be marked `interrupted` in one pass**, silently and wholesale. Issue #44 closed that. A pass in any of those conditions declines to conclude that anything died: items stay `active`, session rows stay open, their capacity slots stay subscribed, and the pass records how many conclusions it withheld and raises a `registry_unobservable` anomaly. The next pass that can read the registry reaches those conclusions and retracts the anomaly, so a machine that was blind for a while needs no repair. **The test is per session, not per pass**, and the distinction is load-bearing. A refused version is a *per-file* failure: one file is skipped and every other file's live entry is read in the same scan. So a pass can be blind overall while holding a directly observed entry for the very session being judged — and an entry that was read is an observation about *that* session whatever happened to other files. What is withheld is a conclusion drawn from an absence the scan cannot vouch for. Keying it on the pass instead leaked the row of every worker retirement terminated while any unrelated file was refused: process dead, row `running`, slot held for ever. What is **not** guarded is a file that could not be parsed — a truncated read, which is the ordinary result of reading while the worker writes. Treating that as blindness would switch the liveness sweep off at random on a healthy busy machine. The residual exposure is one item on one pass, `unreadable` appears in the pass summary, and `resume` recovers it. ## Disk A prepared worktree was measured at up to **499 MB** once a virtualenv exists. Automatic removal exists as of milestone 004 and is **off by default** — deleting work is irreversible, and the Operating Constraints require irreversible actions to be unreachable until asked for. `doctor` still warns when free space is low. ```bash robot-army worktree list # sizes, conditions, and cleanup state robot-army cleanup # every eligible item, under both guards df -h ~/worktrees ``` With `[cleanup] on_issue_close = true`, an item whose issue has closed has its worktree and branch reclaimed on the next reconciliation pass — unless anything in either exists nowhere else, in which case it is kept and the reason is on the row. See the `cleanup_state` table above and the README's "Cleaning up" section. Worktrees live at `~/worktrees/` rather than under `~/.local/state` so they stay out of any backup set aimed at `~/.local` — which matters at half a gigabyte each — and sit at a short top-level path I will find without looking it up. ## Backing up Worth keeping: `~/.config/robot-army/config.toml` and `~/.local/state/robot-army/state.db`. The logs are worth keeping if the point is an audit trail. The worktrees are reproducible from the branches, which live in the repositories. Copy the database with SQLite rather than `cp`, so a concurrent write cannot produce a torn file: ```bash sqlite3 ~/.local/state/robot-army/state.db ".backup /tmp/robot-army-backup.db" ```