002 round
The round
A round picks one item, has an agent implement it, runs the gate, has a second agent prove the diff, and then keeps or discards the work — here is every state, every outcome, and what each one costs the item.
A round is the unit of work, and the only thing in looply that can produce a commit. One round picks one item off the queue, hands it to your coding agent with the scope and the rails attached, runs the checks, gets a second agent invocation to judge the resulting diff, and then keeps or discards the work. The agent writes the code; the round decides what it is handed and what happens to the result. The runner is packages/cli/src/round-runner.ts; the state machine it obeys is packages/core/src/round.ts. Every transition is one line appended to .looply/state/events.jsonl. The whole sequence runs end to end in scripts/round-e2e.sh, which drives one round against a stubbed agent twice: once to a commit, once to a prover rejection.
- Pick — take the topmost item in
ready, re-verify its evidence if it has aged, mark itin_progress. - Implement — one agent invocation writes the code and a test, and returns a structured result. Nothing else is read as the answer.
- Check — the gate: interlocks first, then the configured steps in order, stopping at the first failure.
- Prove — a second agent invocation, read-only, is given the item, its evidence and the diff, and judges whether the item was delivered.
- Keep or discard — commit with a generated trailer, or reset the tree and decide where the item goes.
# make sure something is pickable: check draft items against the code
looply verify
# a real run: keep going for at least 8 hours, up to run.max_rounds (default 400)
looply run --hours 8
# print the plan — branch, budget, ready count, proof on/off, gate steps —
# and exit without calling any agent
looply run --dry-runPick
Between rounds, in this order, the runner checks for a stop signal (HUP, INT or TERM, giving run.ended a stopReason of signal), then control.json for a pause or stop from the app (command), then run.max_rounds (max-rounds), then elapsed time against run.min_hours (min-hours). Budget checks sit between rounds, never inside one, so a limit reached mid-round lets that round finish. Then it picks: the lowest orderKey among items in state ready. Order is priority; the sort is the pick. Nothing else is pickable, ever. Once a run is under way, a queue with nothing left in ready ends it with queue-empty.
Before the item is handed to a round, its cached evidence is checked for age, and an item whose evidence has gone stale goes back through verifying before it can be picked — the triggers, the thresholds and what each verdict does to the item are on the queue page. A verdict that routes the item out of ready moves the pick to the next item down. Once an item survives that, the runner writes round.picked with attempt set to the item's attempt count plus one — so the first attempt is 1, not 0 — sets the item to in_progress, and the round is in state picked.
A run that finds nothing in ready before its first round does not stop there. It checks the draft items against the code first — the same pass looply verify performs on its own — and picks from whatever the verdicts made pickable. Only if nothing became pickable does the run refuse to start, printing what the queue actually holds rather than a bare count.
The runner owns git for the whole run: it checks the tree, creates the run branch, and is the only thing that commits or discards. It refuses to start if the working tree has any changed path outside .looply, and refuses a branch name that already exists. Both refusals exist because discarding is destructive: a discard is git reset --hard followed by git clean -fd -e .looply. Uncommitted work in the tree when a run starts would be folded into round one's diff and destroyed by the first discard.
Implement
The round moves to implementing and writes round.phase. The prompt — item title, cached evidence, the scope lists, the railed paths, and the previous attempt's failure reason if there is one — goes to the agent on stdin, never on a command line. In looply run the implementer gets 45 minutes. When the agent stops, the runner reads exactly one thing: the last JSON object in the transcript that validates against the result shape for this item id. Scanning from the end matters, because the prompt itself contains an example. The exit code decides nothing, and the working tree is never inspected to guess whether something happened.
{ "itemId": "itm_7f3a",
"outcome": "implemented",
"filesTouched": ["admin/src/pages/ItemForm.tsx", "admin/src/lib/item-save.spec.ts"],
"testsAdded": 3,
"note": "one sentence",
"blocked": null }| What the implementer returned | What happens to the round and the item |
|---|---|
outcome: implemented | The round goes on to the gate. |
outcome: no-op | Round abandoned. Tree discarded, item closed. "Nothing to do" is a statement the agent makes; a dirty tree does not overrule it. |
outcome: blocked | exemption.requested and item.blocked are written, round abandoned, item set to blocked with the reason. The run does not wait for an answer — it picks the next item. |
No valid result: missing, malformed, wrong itemId, or the 45-minute timeout | Round abandoned. Tree discarded, item closed with the parse failure in lastFailure. |
| The agent process itself fails (spawn error, transport down) | A critical alert with code implementer-error. Round abandoned, but the item returns to ready with attempts raised — a crashed binary says nothing about the item. |
Check: the gate
The round moves to gating. Interlocks run before any step: the full text of each file listed by rails.prod_env_files (default .env), plus every non-empty value in the live environment, is matched against rails.prod_patterns, and every changed path is matched against rails.forbidden_paths. An env file goes in as one string rather than a set of parsed variables, which has a consequence the rails page spells out. Then the configured steps run in the order you wrote them, cheapest first, each with timeout_s or a 600-second default. A step whose when: glob matches no changed path is recorded as skipped rather than silently dropped. Execution stops at the first failure. Each step is written as gate.step; the verdict and the full step list as gate.result.
| Gate verdict | What happens |
|---|---|
green | The commit permit is checked, then the round moves to proving. |
red | A step failed. Round discarded, item requeued with gate red: step <name> failed as its lastFailure. |
abort | An interlock tripped: abortCode 91 for a matched prod pattern, 92 for a railed path in the diff. Same discard and requeue, plus a critical alert with code gate-abort — the gate itself may be broken, and that should be heard tonight. |
A green gate is not yet permission to commit. mayCommit also requires that every step marked required: true actually ran and was not skipped. A required step skipped by its own when: filter leaves the gate green and still blocks the commit; the round is discarded with commit refused: in lastFailure. This is the check that the harness looply replaces did not have: 166 commits were stamped with a trailer naming an eslint pass that never once ran. The trailer is now generated from the executed step list, so it cannot claim a step that did not execute.
Prove: the second key
The round moves to proving and writes round.phase. When proof.enabled is true (the default), a second agent invocation is made with read-only instructions and exactly three inputs: the item title, its evidence, and the diff of the round's changes. It is deliberately not given the implementer's note, or it would grade the story instead of the code. The prover gets 10 minutes. Its verdict is written as proof.result with actor prover. The verdict contract, how a transcript is parsed for it, and what happens to a verdict that does not validate are on the proof page.
| Proof verdict | How the round resolves |
|---|---|
proven | The round commits. |
rejected | Round discarded, item requeued with proof rejected: <reason> carried into the next attempt's prompt. A verdict the parser cannot validate arrives here too, as a rejection at confidence 0. |
partial | Raises a keep-or-discard question. The round stays in proving until it is answered — there is no waiting state. proof.on_partial: keep commits, discard discards, ask delegates to whatever can reach a human. |
A prover invocation that fails outright — the process, not the verdict — is handled separately from a rejection: a critical alert with code prover-error, the round discarded, and the item requeued with prover error: <reason>. One round is lost rather than the night.
That is the two-key rule. Key one is the implementer's structured implemented result together with a gate that went green with every required step actually run. Key two is proven from an agent invocation that did not write the code. Both must turn, and the runner checks the gate half first so a round that can never commit does not pay for a prover call. Only then does it stage everything outside .looply, commit with the generated trailer, spend any exemption that opened a railed path, and move to committed. Setting proof.enabled: false removes key two entirely, and looply run says so on startup: rounds will commit on a green build alone.
proof.on_partial: ask has nothing wired that can actually reach a human yet. In looply run — including a run the daemon starts on the app's behalf — a partial verdict prints the reason and discards. Pick keep or discard deliberately rather than assuming ask means someone will be asked. The runner does refuse to start on wiring that cannot work: proof enabled with no prover, ask with nothing to ask, a source of exemptions with no way to spend one.
Every round state
| `RoundState` | What it means, and how it is left |
|---|---|
picked | The item is chosen and marked in_progress. Leaves to implementing; the machine also permits abandoned from here. |
implementing | The coding agent is running. Leaves to gating with a usable result, or to abandoned for a no-op, a block, an unusable result or a crashed process. The machine also permits discarded from here; the runner does not use that edge. |
gating | Interlocks and gate steps. Leaves to proving on green with the commit permit satisfied, otherwise to discarded. |
proving | The prover is running, or the round passes straight through when proof is off. Leaves to committed or discarded. A partial verdict holds the round here until an answer exists. |
committed | Terminal. The commit exists on the run branch. |
discarded | Terminal. The tree is reset and the item is back in the queue. |
abandoned | Terminal. The tree is reset and there is nothing to retry from. |
The transition table is enforced, not advisory. An illegal path — committing out of gating, for example — throws RoundTransitionError rather than shipping. Terminal states are exactly the three outcomes, and round.ended carries one of them.
Every outcome, and what it costs the item
| `RoundOutcome` | The tree, the item, the tally |
|---|---|
committed | git add -A excluding .looply, then a commit whose trailer lists the steps that ran, the proof verdict and confidence, and any exemption spent with the actor who granted it. Item goes to done. round.ended carries sha, files and testsAdded. Counted in totals.committed. |
discarded | reset --hard plus clean -fd -e .looply, and the item's cached evidence is invalidated so the next attempt does not start from the file and line that just led into a wall. Item returns to ready with attempts raised and lastFailure set. Counted in totals.discarded. |
abandoned | Tree discarded and evidence invalidated the same way, but the item does not return to ready. It is closed for a no-op or an unusable result, blocked for a railed path. Counted in totals.abandoned. The single exception is a crashed agent process, which requeues to ready. |
Why abandoned is not discarded
The rule is one line in core: shouldRequeue is true for discarded and false for the other two. A discard means the work was attempted and judged. There is something to carry forward — the failed step, the prover's reason — and the attempt counter moves, so a repeatedly failing item is visible rather than silent. An abandon means nothing was learned about the item: the agent said there was nothing to do, or returned nothing usable at all, or hit a rail it must not cross. Requeuing that produces the same non-answer next round, and the run spends the night on one item. So the item leaves the queue instead, into closed or blocked, both unpickable. The crash exception is deliberate and narrow: when the agent process fails to start, the failure is about the harness rather than the item, so the round is abandoned but the item goes back to ready with the reason attached. That costs one round, not the night.