fix: the operational layer, exercised end to end across a multi-item run - #218
Merged
Conversation
The queue, the leases, the graph, the holds, the audit store and the reaper had each been tested alone. All of them at once -- over one SQLite file, with dependencies, a worker killed mid-item, retries, a question waiting on a person and threads competing for one backlog -- had never been run. Writing that run found five defects and the leak recorded as #206. 1. `claim` scanned one page and stopped. `ORDER BY attempts, item_id LIMIT 200` made "does the queue have ready work" and "does the FIRST PAGE have ready work" the same question. A project whose first page was entirely dependency-blocked was handed nothing at all, permanently, for every worker, while the item that would have unblocked it sat one row past the limit. A stalled fleet with a full queue and nothing saying why. The scan now walks the pages, by keyset rather than offset because rows are retired inside the loop; the limit still bounds what is in memory at once. 2. `POST /api/work/{id}/retry` called `release`, not `requeue`. `requeue` exists for exactly this -- "a retry that left the count alone put the item back to pending and watched it return to exhausted before any worker saw it, while reporting success" -- and the fix had never reached the API, which is the only lever an operator has over a wedged row. It also dropped `last_error`, the one record of why the item stopped, and left the durable attempt position, so the retry resumed into the verdict it was retrying. 3. Nothing ever cancelled a hold. `holds.CANCELLED` was declared, documented as "the item was blocked, retried or requeued out from under the hold", and written by no code at all. So a question stayed `open` after its item moved on: it sat in the operator's inbox where answering could no longer affect anything, it refused the item's NEW owner a question of its own, and it later expired as "nobody answered", which is not what happened. 4. `GET /api/holds` -- the inbox, the whole answer to #103 -- returned entries with no item id. `Hold.as_dict` supplies one; `HoldView` had no field for it, so pydantic dropped it silently. Every question was readable and none was answerable: the route that answers one needs exactly the id the list withheld. 5. `AuditStore.record_baseline` wrote a human's free text into the one store that has no way to remove anything, going around the redaction every other write path goes through -- into the database that is retained after `maintenance` thins the primary. 6. #206. `with self._connect() as conn` reads like a closing block and is not one: a sqlite3 connection's context manager manages a TRANSACTION. `WorkQueue`, `SQLiteCommandJournal` and `AuthorityStore` each left a live handle on every call, and the queue holds a reference cycle, so the collector only found them on a gc pass. The two journals get a `_connection` helper that keeps the `with conn` commit and adds the close, because both open an explicit `BEGIN IMMEDIATE` and depend on it. No gate is weakened. What the tests found SOUND is asserted alongside: the lease returns a killed worker's item, the attempt ceiling retires an item that reliably kills its worker, a late report from a lapsed owner is discarded, fifty items across four real threads are never claimed twice, a dependency is never claimed before its target, D12's hold keeps the claim through the worker dying, and deleting the queue database changes no audited answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
tests/test_operational_e2e.py: the operational layer run end to end across amulti-item run. The queue and its leases, the dependency graph, the holds, the
audit store and the reaper had each been tested alone; all of them at once —
over one real SQLite file, with dependencies, a worker killed mid-item,
retries, a question waiting on a person and real threads competing for one
backlog — had never been run.
Written in the shape of
test_agent_loop_e2e.py: real SQLite files on disk,real threads where concurrency is the point, a real git repository where a
worktree is, and the clock injected — the one thing that must not be real,
because
AGENTS.mdforbids sleeping in tests and a lease measured against asleep is a measurement of the machine's load.
Writing it found five defects and the leak recorded as #206. Every one has a
test here that is red against
mainand green with the fix. Verified bystashing the
src/changes and re-running: 8 tests fail, 0 after.The defects
1.
claimscanned one page and stopped.ORDER BY attempts, item_id LIMIT 200made "does the queue have ready work" and "does the first page haveready work" the same question. A project whose first page was entirely
dependency-blocked was handed nothing at all — permanently, for every worker —
while the one item that would have unblocked all of it sat one row past the
limit. A stalled fleet with a full queue, and nothing in the queue saying why:
the unattended-operation failure this layer exists to prevent. The scan now
walks the pages, keyset rather than offset because rows are retired to
exhaustedinside the loop;CLAIM_SCAN_LIMITstill bounds what is in memoryat once.
2.
POST /api/work/{id}/retrycalledrelease, notrequeue.WorkQueue.requeueexists for precisely this — "a retry that left the countalone put the item back to
pendingand watched it return toexhaustedbefore any worker saw it, while reporting success" — and the fix went into the
queue and never reached the API, which is the only lever an operator has
over a wedged row. It also cleared
last_error, the one record of why the itemstopped, and left the durable attempt position, so the retry resumed into the
verdict it was retrying.
3. Nothing ever cancelled a hold.
holds.CANCELLEDwas declared,documented as "the item was blocked, retried or requeued out from under the
hold", and written by no code anywhere. So a question stayed
openafter itsitem moved on. Three consequences, all asserted: the operator's inbox showed a
question answering could no longer affect; the item's new owner was refused
a question of its own (
already has an unanswered question, about an attemptthat no longer exists); and the abandoned hold went on to expire and be
recorded as "nobody answered", which is a different fact from "somebody
cancelled it" and is not the one that happened.
4.
GET /api/holdsreturned entries with no item id. The inbox — the wholeanswer to #103 — listed its questions without saying which item each was about.
Hold.as_dictsuppliesitem_idandattempt;HoldViewhad no field foreither, so pydantic dropped them without a word. Every question readable, none
answerable:
POST /api/work/{item_id}/answerneeds exactly the id the listwithheld.
AGENTS.mdtreats the API as a contract and the schema as thedocumentation, so this is a contract defect, not a cosmetic one.
5.
AuditStore.record_baselinebypassed redaction. It writes a human'sfree text (
label,notes) straight into the store that has no way to removeanything and that is retained after
maintenancethins the primary. Themodule's own reasoning is that the filter sits on the only way in "so nothing
added later can route around it" — and this was a second way in.
6. #206.
with self._connect() as connreads like a closing block and isnot one: a sqlite3 connection's context manager manages a transaction.
WorkQueue,SQLiteCommandJournalandAuthorityStoreeach left a livehandle on every call, and
WorkQueueholds a reference cycle (its graph,attempt log and holds all carry its bound
_connect), so the collector onlyfound it on a gc pass — in a process meant to run for weeks. Proved by counting
/proc/self/fdentries pointing at the database, which is evidence rather thanan assertion about an implementation. The two journals get a
_connectionhelper that keeps the
with conncommit and adds the close, because both openan explicit
BEGIN IMMEDIATEand depend on it.Closes #206
What was found SOUND, and is now asserted
Reported because "what holds" is worth as much as "what breaks":
exhausted, not cycling;reclaim_dead_workersdoes not touch it), and answering hands the item back to the worker that asked with a fresh lease;blockedand never toready, with the question preserved;Pinned, not fixed — said out loud
Both are named in the test module's docstring rather than left in a commit
message nobody reads:
/api/work/{id}/retryand/blockcompare a storedlease_untilagainsttime.time()rather thanqueue.now(), unlike/api/holds, which uses thequeue's clock and says why. Identical in production, so the fixture starts
its clock at the wall clock rather than paper over the difference.
holdsis keyed on(project, item, attempt, asked_at)withasked_atafloat, so two questions from one attempt within the same clock tick raise a
bare
sqlite3.IntegrityErrorout ofWorkQueue.holdrather than aHoldError. Reachable with an injected clock, effectively unreachableagainst a real one.
One further observation, not fixed because it is a taxonomy change and D8 is
open: an item retired to
exhaustedby the claim scan gets nodispositionand no
reason_kind, and empty means "nobody has finished with it yet" —which is exactly wrong for the one state that says the harness gave up.
Gates
uv run ruff format .,uv run ruff check .,uv run mypy,uv run pytest— all pass. No gate is weakened; nothing in
EXECUTION_PATHlearns about aproject, a vendor or a workload.
🤖 Generated with Claude Code