The Sweep Ran Every Hour. Fifty Closed Tasks Still Weren't Done.
My agent fleet's task board had an hourly sweep that moved closed issues to Done, and 50 closed issues still weren't Done. One board read showed why: every stuck card sat in a status the sweep never checked. The fix widened one list, cut the sweep's API cost by 99%, and taught it which cards it must report instead of touching.

My agent fleet runs off one GitHub Projects board. Every task an agent picks up, every task I pick up, every issue from every repo the fleet works in lands there, and the rule is simple: the board's Status column is the truth about what is done. The weekly retro reads it, the planning session reads it, the daemon that claims work reads it.
So when the retro counted 40 issues closed on GitHub whose board status was not Done, and the next week's retro counted 50, that was not a cosmetic problem. It was the source of truth drifting away from reality by about ten cards a week.
The odd part: a sweep built to fix exactly this had been running every hour since July.
The sweep that was not broken
In July I had the fleet add a reconcile sweep to the daemon: once an hour, find cards whose issue is closed but whose status says otherwise, and move them to Done. It had tests, it was reviewed, it logged what it did. By every measure I had, it worked.
Before changing anything this time, I read the whole board once and counted instead of guessing:
issue cards on the board: 1,194
by status: Done 1,051 · Backlog 61 · In Progress 47 · Todo 15 · Blocked 6 · (no status) 14CLOSED on GitHub but not Done: 50
by status: Backlog 47, (no status) 3
by reason: completed 48, not planned 2
Not one of the 50 was in Todo, In Progress or Blocked, and those three were exactly the statuses the hourly sweep looked at. Its drift lane was a hard-coded list: ("Todo", "In Progress", "Blocked"). A task that was filed straight into Backlog, or added with no status at all, and then closed from GitHub never entered any column the sweep checked.
The sweep was not starved and not crashing. It was structurally blind, and it was blind in the one place where closed work piles up: Backlog is where issues wait before anyone claims them, so it is also where issues end up when they get closed without being claimed.
The lesson I keep relearning: a guard is only as good as its vocabulary. "Reconcile drifted statuses" sounded like complete coverage. It covered three named statuses out of five, plus the empty one.
The second problem: the sweep was expensive
While counting, I also measured what the old sweep cost. The Projects API is GraphQL-only, and the personal access token has a budget of 5,000 points an hour shared by everything that touches GitHub: the daemon, the review bots, my own sessions.
The old sweep did one gh issue view per candidate card, and the candidate set included every Done card too. That came to about 1,120 points per hourly run, 22% of the hourly budget, spent on bookkeeping. (The verifier measured this against the real project rather than trusting the docs: one gh issue view costs 1 point, and one 100-card page of the board query costs 1 point with or without extra fields.) On the day I filed the fix, a planning session doing its own board reads hit API rate limit exceeded halfway through.
What changed
The fix turned out smaller than the problem sounded:
" done " variant are all handled the same way.state, stateReason, updatedAt, closedAt. They add no connection, so a page still costs 1 point. The sweep now makes zero per-card lookups.updateProjectV2ItemFieldValue mutations, with every id passed as a variable and never pasted into the query text. A write cap of 40 per run bounds the damage from a mass close or a bulk import. The rest waits for the next pass, and the report says so.The cost went from about 1,120 points per run to about 15: 12 for the snapshot and 2 or 3 for the batches. That is a 99% cut, and it is now cheaper to run hourly than the old version was.
The batching removed one safety net: the old code re-read the board before each individual write (a compare-and-set check). I only let it go after checking what it actually guarded. It declined a write only when a card had left Todo, In Progress or Blocked, but a card being claimed by an agent moves into In Progress, so the check never covered the race it looked like it covered. The real guards are "the issue is closed", which does not un-happen by itself, and a list of cards the daemon is executing right now. That list is read after the snapshot, so it can only over-exclude.
Two things the sweep must not fix
Mechanically, "closed means Done" is easy. Two classes of card are decisions, not bookkeeping, and the sweep now reports them instead of touching them.
The report latches on which cards it names, not on their ages. Otherwise a stale card would re-send the message every hour as its day counter ticked. An unchanged picture stays silent, and a card joining or leaving the list re-sends the full current list.
What the adversarial pass caught
Changes to a cross-service contract (here, the GitHub GraphQL integration) do not merge on my word alone in this setup. They go through a read-only verifier agent whose only job is to refute the change. It type-checked the generated mutation against the live schema, re-counted the board itself and ran a battery of 12 mutants against a scratch copy.
11 of 12 mutants were caught. The one that survived exposed a real gap: nothing asserted that issue titles are HTML-escaped in the Telegram report, and titles routinely contain < and &. A test now covers it, and I confirmed that test fails against an unescaped copy.
It also found four things the tests did not, and settled one open question:
gh api graphql exits non-zero when a 200 response carries an errors array, even if sibling mutations succeeded. A partially applied batch is therefore treated as failed, and the cards that did land simply drop out of the next snapshot.The first live run
Before merging, I flipped one real card from Backlog to Done through the new batch writer to prove the mutation shape against the live board. Unit tests cannot prove that part.
After deploy, the first run hit the 40-card cap exactly as the arithmetic predicted (47 candidates against a cap of 40). It said so in the report and listed the 2 not-planned closes and the 10 stale In Progress cards. The next pass went through without hitting the cap. Two days later the board holds 1,270 cards, and exactly 2 closed issues sit outside Done: the two not-planned closes, left there on purpose. The number the retro had been re-deriving by hand every week is now produced by the sweep itself.
A side note on doing the work twice
This task was on the fleet's lane for the week, and the fleet built it: a separate daily module with its own batching, write cap and latch. It went through three review cycles and was approved. It still got closed unmerged, because the same fix had landed from my interactive session a few hours earlier as a much smaller change: widen the existing lane rather than add a second sweep beside it. Both versions were correct. Only one of them was needed. When I pick up a task the fleet already has, I have to take it off the fleet's lane first. That is the same kind of bookkeeping this whole post is about.
Takeaways
FAQ
Why a write cap if the sweep is idempotent?
Idempotence protects correctness, not blast radius. A mass close or a bulk import could otherwise turn one hourly run into hundreds of writes against a shared budget. A cap of 40 means a bad day spreads across a few passes instead of burning the hour.
Why report stale In Progress cards instead of moving them back?
Thirty idle days can mean abandoned, paused on purpose, or blocked on something outside the board. The sweep cannot tell these apart, and guessing would put a wrong decision into the source of truth.
How do you know the fix actually worked, not just the tests?
Two checks outside the test suite. Before merging, one real card went from Backlog to Done through the new batch writer, and the snapshot read it back as closed and completed. Two days after deploy, a fresh read of the whole board found 1,270 cards, and the only closed issues outside Done were the two not-planned closes the sweep is supposed to leave alone.
Related reading
Maksym Tytarenko
AI & SaaS Development Expert at Tytarenko AI Agency
Related Articles
Two Runner Listeners, One Work Directory: A Self-Hosted CI Post-Mortem
My agent fleet's CI started failing 3 to 18 seconds into every job. The cause was two GitHub Actions runner listeners sharing one work directory, left behind by a runner self-update and KillMode=process. The diagnosis, and the ExecStartPre fix.
11 min readAutomationFour Merge Presses, One Merge: Fixing the Approval Layer of My AI Agent Fleet
I pressed Merge on four agent pull requests and only one merged. The bug was an ack sitting upstream of the action, a serial update queue I was silently relying on, and a merge digest that could not see two PRs colliding on the same files.
14 min readAutomationFlagging Tests That Assert Nothing, When All You Can See Is the Diff
My merge-card scanner caught assertions being removed but never noticed a new test that had none. Closing that gap meant giving up on syntax trees, teaching the scanner to know when it is looking at a cropped view of a file, and measuring the false-positive rate on thirty merged pull requests before the line ever reached a human.
10 min readReady to Build Your AI-Powered Solution?
Let's discuss how we can help you leverage AI to transform your business.
Get in Touch