looply

004 queue

The queue

Where work waits: what an item is, every state it can be in, what looply queue shows you, and what verification does to it before a round is spent.

The queue is the list of things looply may hand to a coding agent. It lives at .looply/state/queue.json, and for curation it is the truth rather than a projection of anything else. Every item carries its own state, and exactly one state is pickable: ready. Which item a round is spent on is decided here, before anything is written, and an item has to survive verification before it can be picked at all.

What an item is

An item is a title, a tag, a state, a source, and a list of evidence entries pointing at the code the title is about. source is one of scan, human, loop or undo. orderKey is what the queue sorts on; a scan writes a time-ordered id there, so creation order is working order and the loop takes the top one, and reordering means writing a new key rather than renumbering the file. version is the concurrency token: every write states the version it saw, and a write against a stale version is rejected with the current number instead of silently applied. attempts counts rounds spent on the item, and lastFailure carries why the last one was thrown away into the next attempt's prompt. Three fields are set only by the situation that produces them: blockedReason, duplicateOf and parentId.

{
  "id": "itm_0abc1def2ghj3kmn",
  "projectId": "prj_0abc1def2ghj3kmn",
  "title": "[admin] ItemForm's delete mutation has no onError",
  "tag": "admin",
  "state": "ready",
  "source": "scan",
  "evidence": [
    { "path": "admin/src/pages/ItemForm.tsx", "line": 178, "quote": "const del = useMutation({" }
  ],
  "parentId": null,
  "orderKey": "itm_0abc1def2ghj3kmp",
  "version": 4,
  "attempts": 1,
  "lastFailure": "gate red: step test failed"
}

States

State What it means, and what puts it there
draftUnverified. Every item starts here: scan writes drafts, an item typed into the app is filed as a draft, and an unclear verdict sends one back. Not pickable; looply verify is what moves it on, and a run runs the same pass by itself rather than refusing.
verifyingHeld while the verifier agent runs on it. Written just before the call, left when the verdict is applied. A verifier that times out, fails to start, or answers nothing usable records unclear rather than leaving the item here. An item still sitting in verifying is one whose process died mid-call — a killed run, a closed laptop — or one whose verdict lost the version race on the way back, which the pass counts as failed and reports as "could not be written back — run it again". Neither strands it: a pass walks verifying items alongside drafts, and the version check is what makes reclaiming one safe, since a write against something genuinely still working loses and is reported rather than racing.
readyThe premise was confirmed or amended against the code as it stands. The only pickable state. A round that throws its work away for a reason worth carrying — a red or aborted gate, a refused commit permit, a rejected proof, a prover or implementer that failed to run — returns the item here with attempts incremented and lastFailure set.
in_progressA round is on it. Written after round.picked, left when the round ends. A run killed mid-round leaves the item here and nothing frees it — a verify pass walks drafts and verifying items only — so the file has to be edited by hand. Ending a run with looply stop rather than a kill is what avoids it.
blockedCannot proceed. Either a blocked verdict, or the agent reporting mid-round that the task needs a railed path. The sentence lands in blockedReason.
staleThe verifier found the premise already fixed. Nothing reopens it.
duplicateAnother open item describes the same defect; duplicateOf names it.
splitThe verifier called it too-big. item is the one command the CLI's usage still lists as unbuilt, and nothing else creates the child items, so an item here sits until someone rewrites it by hand.
doneThe round committed. The round.ended event carries the sha.
closedThe round ended with nothing learned: the agent abandoned it (timeout, or a result nothing could read) or reported no-op. It is also the state a remove from the app writes, on the server's copy of the item.

The pick is a sort, not a score. The runner filters the queue to ready, sorts by orderKey ascending and takes the first. Order is priority. Everything else in the table is either on its way to ready or out of the running.

Reading the queue

looply queue prints the file grouped by state, in triage order: ready, in progress, verifying, draft, blocked, split — what you can work on, then what needs you. Each item gets the first twelve characters of its id and its title, and under that the two facts a decision turns on: where a round would start, which is the first evidence entry as path:line, and how many attempts it has already had, that second one only once it has had any. Under that again, when there is one, the reason it is where it is — blockedReason for a blocked item, duplicate of and the id for a duplicate, otherwise last attempt: and the lastFailure the previous round left. The reason is recorded in every case, so it is printed in every case; an unexplained item in a state nobody chose is what makes a queue feel arbitrary.

The settled states — done, closed, stale and duplicate — are left out by default and counted on one line pointing at looply queue --all, because a queue worked through a night otherwise buries its own live items under its history. Two lines cover what the counts cannot say on their own: an empty file prints a single line pointing at looply scan, and a queue holding drafts but nothing ready says nothing is pickable yet and points at looply verify.

A run that ends between rounds leaves nothing stranded in in_progress, which is what looply stop is for. It writes a stop request to .looply/state/control.json, and the runner reads that file between rounds — so the round in flight finishes and the item it holds lands in done, closed, blocked or back in ready before the run winds down. --now deliberately kills nothing: a round cut off halfway leaves a half-written tree, and the next run refuses to start on one. When no run is live the command writes nothing at all and says so, because a run clears any pending request as it starts — a request written now would be cleared unread rather than obeyed, while looking to the next reader like an order still standing.

Only the CLI writes .looply/state/queue.json. An edit made in the app lands in the server's database and nothing pulls it back down, so against a local queue the app is a view rather than a control — and the API is narrow in any case: a PATCH may set a new title, a new orderKey, and one of ready, blocked or closed, because the rest of the lifecycle is the loop's to write. looply queue is how you read the local file, looply verify is how a draft in it becomes pickable, and an item stranded in in_progress is edited by hand, whatever looply run's refusal line says about resetting it from the app. The daemon does carry start-run, pause, stop and scan through to this machine, and leaves skip, approve and undo queued unexecuted rather than acking work that never happened.

Evidence and the evidence cache

Evidence is a list of { path, line?, quote? }. A scan finding with no usable evidence entry is dropped before it becomes an item, so every scanned item arrives pointing at at least one file. A confirmed or amended verdict replaces the item's evidence with what the verifier cited. The implementer prompt prints those lines verbatim, and that is most of what pays for verification: the agent starts at the file and line somebody already located instead of re-searching for the defect. When the list is empty the prompt says so and tells the agent to find the place itself and record what it finds.

The cache is .looply/state/evidence.json, one entry per item: the evidence plus the HEAD it was recorded against. HEAD is half the key because evidence is a claim about a tree, not about the item. src/x.ts:12 recorded at one commit may be a different line, or a deleted file, forty commits later. A re-verify replaces the item's entry rather than adding a second one, and every discard invalidates it, because the round that just failed may have mangled the paths the evidence points at.

Lookup at the current HEAD What it means
hitRecorded at exactly this HEAD. Nothing can have moved since, so the evidence is used as it stands.
movedRecorded at another HEAD, returned together with cachedHead. Never collapsed into a hit; the caller decides whether it is still usable.
missNo entry. Either the item was never verified, or a discard invalidated it.

Verification

Verification is a second agent, read-only, judging the item's premise against the code as it stands now. It is shown the item's id, title, tag and evidence, plus the id and title of every other open item (ready or draft) so a duplicate can be named rather than merely suspected. The call is capped at five minutes. looply verify walks the file in queue order, taking every draft and every item stranded in verifying, and --max N caps how many are checked. looply run runs the same pass by itself when nothing is pickable and at least one draft is waiting, with no cap; if the pass still leaves nothing pickable, the run stops before round one with nothing to do and a count of what the queue actually holds. Both routes emit one item.verified event per item on lane verify with actor system, and both apply the verdict through the same code, so whether an item becomes pickable does not depend on which route reached it. A pass is not a run: no round happens and nothing is committed.

{ "verdict": "confirmed" | "amended" | "stale" | "duplicate" | "blocked" | "too-big" | "unclear",
  "reason": "<one sentence: why this verdict>",
  "evidence": [{ "path": "<file>", "line": 123, "quote": "<the line that proves the premise>" }],
  "amendedTitle": "<amended only: the corrected title>",
  "duplicateOf": "<duplicate only: the id of the open item this duplicates>" }
Verdict Where the item lands, and what else changes
confirmedready. The cited evidence replaces the item's own and is written to the cache at the HEAD the verifier judged against.
amendedready, exactly like confirmed, and the title is replaced with amendedTitle. The verdict is rejected without one.
stalestale. The premise is already fixed; no round is spent on it.
duplicateduplicate, with duplicateOf recorded, so the two can be merged by whoever curates the queue.
blockedblocked, with the verifier's reason copied into blockedReason.
too-bigsplit. It is several rounds of work. Nothing splits it automatically.
uncleardraft. Unpickable, and the fallback for every verdict that cannot be trusted.

The parser scans the transcript from the end and takes the last JSON object that fully validates, because a transcript that echoes the prompt contains a verdict-shaped decoy. Three things degrade to unclear, which means back to draft: a verdict nothing can parse — no JSON, an unknown verdict word, a missing reason, or a verifier that hit its timeout or whose process failed; a confirmed or amended verdict citing no evidence, since a pickable item with nothing cached sends the implementer hunting all over again; and a duplicate naming an id that is not another item in the queue, self-references included. The verifier's exit code is recorded and is never decisive.

Re-verification at pick time

  1. Take the topmost ready item by orderKey.
  2. Look it up in the evidence cache at the current HEAD.
  3. On hit, hand it to the round.
  4. On moved, count the commits between the cached HEAD and this one that touch the evidence paths. Twenty or fewer reuses the evidence; more than twenty sends the item back through verification.
  5. On miss, verify again. No entry means never verified, or invalidated by a discard.
  6. A re-verify sets verifying, runs the same verifier the pass uses, applies the verdict, and refills the cache on confirmed or amended.
  7. Re-read the queue and start over, because the verdict may have moved the item out of ready and promoted the next one. An item already verified within this pick is not asked twice.

The threshold is REVERIFY_AFTER_COMMITS, fixed at 20 in the code and not configurable, on the reasoning that a tunable staleness bound is how stale evidence gets tolerated. Two edges. Evidence with no paths cannot age this way and is reused, since no commit can touch nothing. And if the count cannot be taken at all, because the cached HEAD was rebased away, garbage collected, or belongs to another clone, the item is re-verified rather than handed line numbers of unknown age. Each re-verify costs one more agent call, up to five minutes, before the round starts, and it records the same item.verified event a pass does — on the run's own lane, under actor runner, because this one happened inside a run rather than instead of one.

Shapes and max_same_shape

A shape is a normalised verb plus a target class. The title is stripped of a leading [tag], lowercased, cleared of quotes, and has file.ts:123 citations and bare numbers replaced by placeholders. Then the first matching verb (add, remove, fix, move, wire, render, surface, validate, cover, show, handle, replace, format) is paired with the first matching target (test, spec, onerror, error, toast, state, endpoint, route, label, type, guard, hook, form, modal, list). Anything unmatched becomes other. So "add onError to ItemForm.tsx:178 mutations" and "add onError to TeamList.tsx:46 mutations" are both add:onerror. A scan merges its lenses, collapses findings that share a first evidence path:line, then trims the batch so no shape appears more than max_same_shape times, keeping the first of each shape in input order. The report prints what it trimmed, and when a batch of five or more items spans fewer than three shapes it says the scan is repeating itself.

queue:
  topup_threshold: 5   # parsed and validated; nothing reads it yet
  topup_size: 10       # parsed and validated; nothing reads it yet
  max_same_shape: 3    # enforced by every scan

The top-up threshold

The rule that decides whether a queue is running low counts ready, draft and verifying items and nothing else. Blocked items are excluded deliberately: they are never ticked off, so counting them lets a queue look full while the loop starves, which is how seven permanently blocked items stopped a top-up from ever firing in the harness looply replaces. The predicate lives in core as needsTopUp, and all three keys above are validated at load and exposed as project settings. Nothing calls the predicate yet: no run drafts its own batch when the queue thins out, and nothing warns you that it has — looply queue is where you see it. Today you top the queue up by running looply scan, which writes drafts, then looply verify — or a run, which checks them itself before picking.