# The Sweep Ran Every Hour. Fifty Closed Tasks Still Weren't Done.

Author: Maksym Tytarenko | Date: 2026-09-23 | Category: Automation | Tags: agent fleet, github projects, graphql, post-mortem, automation, claude code
Canonical: https://www.tytarenkoagency.com/blog/hourly-board-sweep-fifty-closed-tasks-not-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:

```text
issue cards on the board:   1,194
by status:  Done 1,051 · Backlog 61 · In Progress 47 · Todo 15 · Blocked 6 · (no status) 14

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

1. **The drift lane became "anything that is not Done."** The comparison is against the Done column's name, case- and whitespace-insensitive, so Backlog, the empty status and a stray `" done "` variant are all handled the same way.
2. **One snapshot decides everything.** The board query already fetched each card's issue, so I added four scalars to that fragment: `state`, `stateReason`, `updatedAt`, `closedAt`. They add no connection, so a page still costs 1 point. The sweep now makes zero per-card lookups.
3. **Writes are batched and capped.** One GraphQL request carries many aliased `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.

- **Closed as not planned (or as a duplicate).** The July sweep moved these to Done on purpose, reasoning that the board tracks "no longer active". I reversed that. A dropped task filed under Done, next to delivered work, disappears from every view that asks "what did we actually ship". So the sweep leaves those cards where they are and lists them for me. There were 2.
- **In Progress with no activity for 30+ days.** There were 10, the oldest idle for 61 days. Some are paused projects, some are epics that outlived their momentum. Closing, dropping or restarting them is my call. The sweep's job is to stop them hiding.

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:

- The operator README still described the deleted behaviour, in some places word for word.
- The write-cap warning bypassed the report latch. If writes kept failing, I would have gotten the same warning every hour, and its text ("the rest go next pass") would have been false.
- Write failures went to a log line and nowhere else. A token that lost its project scope would have failed silently forever.
- Duplicate closes fell into the flip lane despite the rule above.
- One question it settled in the code's favour: `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

- **Count before you fix.** "The sweep is broken" and "the sweep is blind" need different fixes, and one board read told them apart.
- **Enumerated lists are where guards go blind.** If a rule is "everything except X", write it as "not X", not as a list of what you remembered.
- **Put the facts in the snapshot you already pay for.** Four extra fields on an existing query replaced a per-item lookup loop, and 22% of an hourly budget became 0.3%.
- **Separate bookkeeping from decisions.** Automate the transitions that follow from a fact ("closed as completed"). Report the ones that encode a judgment ("dropped", "stalled").
- **Make the adversarial pass able to fail.** A mutant that survives points at a missing test, and a test you have never seen fail proves nothing.

## 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

- [Four Merge Presses, One Merge: Fixing the Approval Layer of My AI Agent Fleet](https://www.tytarenkoagency.com/blog/four-merge-presses-one-merge-approval-layer)
- [Two Runner Listeners, One Work Directory: A Self-Hosted CI Post-Mortem](https://www.tytarenkoagency.com/blog/two-runner-listeners-one-work-directory-a-self-hosted-ci-post-mortem)
- [Flagging Tests That Assert Nothing, When All You Can See Is the Diff](https://www.tytarenkoagency.com/blog/flagging-tests-that-assert-nothing)

