Ten cycles, one ready draft: the pipeline was consuming its failures

The 2 July cron fired at 08:22 BST. 150 clusters ranked. Mortgage top-scored at 20. Candidates fetched. Draft step: nothing. Exit 0, logs clean, no exception. The pipeline had run. It hadn’t produced anything.

That was the first zero-draft day in the current streak. It read as a plausible outcome: the mortgage cluster had been hit twice in two days and the ground was already covered. The pipeline had assessed that correctly and moved on. Nothing to draft. Try tomorrow.

Tomorrow produced the same outcome. Ten cycles between 30 June and 13 July: one ready draft, zero auto-publishes, skipped climbing from 2 to 7.

The pipeline hadn’t stalled. It had been busy counting failures as completions.

The index entry that shouldn’t have counted

draft.py tracks consumed clusters via an index. Before drafting, it checks the index for the target cluster. Entry found: cluster consumed, skip it, move to the next candidate.

That logic holds when every index entry represents a completed draft. It breaks when the index also contains failure states.

When a draft attempt failed (API timeout, malformed output, fetch error), draft.py wrote a failed-* entry for that cluster. The next morning, the drafter found the entry, classified the cluster as consumed, and skipped it. No retry. No flag. The cluster was permanently dead to the pipeline.

The skipped counter climbed because topics were being marked done when the underlying work had failed. The drafter had tried. The index confirmed it. Whether the attempt had produced anything usable: not part of the check.

Tried is not the same as succeeded. draft.py didn’t distinguish between them.

Why it wasn’t visible

A rising skip count is expected behaviour in a pipeline with a finite cluster set. Topics get covered, ground gets consumed, the eligible set narrows over time. skipped climbing is normal.

skipped climbing from 2 to 7 across ten cycles while the ready-draft count sat at 1 is a different pattern. A healthy pipeline produces roughly one draft per cycle, so ready-draft count should track cycle count. The gap between those two numbers is where the dead zone became legible.

Without ready-draft count in the heartbeat, nothing here reads as abnormal. The cron exits 0, logs are clean, topics cycle through, and from outside everything looks fine. Seven clusters sat in permanent bypass because their first attempt had failed and the index had recorded that failure as a conclusion.

The sequence

The 1 July cycle produced a draft on the mortgage rate-spike cluster. Its ground (BBC, Zoopla, Moneyfacts, BoE) was the same material that had underpinned the previous day’s article. A near-duplicate. Not ideal, but the pipeline had correctly ranked that cluster first and drafted it. The output was the issue, not the process.

The 2 July zero was the first signal. Cron at 08:22, 150 clusters ranked, no draft produced despite candidates being fetched. The mortgage cluster scored 20, top of the ranking, but the draft step didn’t trigger. The ground assessment: already covered. Correct for the mortgage cluster. Zero drafts from 150 candidates warranted examination.

6 July added a separate failure mode to the same window. A zombie IHT draft appeared in its third PR of the fortnight, this time with an outsized slug: uk-inheritance-tax-rate-2026-40-charge-7-75-interest-rate.mdx. PR #171 had created and deleted it within the same squash, then PR #176 had merged it live. On 6 July a third PR retired the regeneration source. Three PRs for the same broken file before the regeneration trigger was root-caused. The index wasn’t the only place where state assumptions were going unexamined.

By 10 July, a three-weekday gap covering 7–9 July with zero publishes had accumulated. The analysis on 10 July showed 7 July had triaged 8 clusters to draft. None produced auto-publishes. Two articles went out on 10 July as manual operator work. The cadence clock restarted from that date.

13 July was the diagnosis. Ten cycles. One ready draft. skipped at 7. Root cause: failed-* entries being read as permanent completions by draft.py’s consumption check.

The fix

Two changes. Strip failed-* entries from the consumption check: a cluster with only failure entries is eligible for retry, not consumed. Add those clusters back into the candidate pool, subject to normal ranking and threshold gates.

Heartbeat visibility on ready-draft count was promoted to a first-class metric. Not something to check reactively. A number in every cycle report alongside exit code and skip count.

The invariant

The implicit contract in draft.py was: an index entry means the cluster has been handled. The actual contract should be: a cluster is consumed when a ready draft exists for it — not when an entry exists, not when an attempt was made, but when usable output has been produced.

A failed-* entry records that an attempt happened. Completion is a different claim, and the consumption check should only accept completion evidence.

Those two framings are identical when every attempt succeeds. They diverge the first time one fails. draft.py didn’t account for that divergence.

What made it diagnosable

Process metrics tell you whether the pipeline ran: exit code, log cleanliness, cycle count. They don’t tell you whether it produced anything. Ten cycles of clean exits while the ready-draft count sat at 1 was invisible until ready-draft count was a tracked output.

The heartbeat had cycle outcomes and skip counts from the start. Ready-draft count was added on 13 July. Adding it closed the observability gap that allowed the dead zone to persist across ten cycles undetected.

Out of scope

Why 7–9 July produced zero publishes despite 8 clusters reaching draft state on 7 July. The cadence engine (B2) is not shipped, so publish timing in that window was manual, which means the zero-auto-publish behaviour across ten cycles was partly the dead zone and partly the absence of automated cadence.

The zombie IHT draft is a different failure mode. Related in that it exposed unexamined index-state assumptions, root-caused separately on 6 July.

All writing