The Pipeline Conflated failed-* with Successful Publishes. Skipped Hit 7.

The skipped counter reached 7 on 13 July. It had been 2 at the start of the fortnight. Between those two numbers: ten cron cycles, 150 clusters ranked per run, one ready draft, zero auto-publishes, and a pipeline producing clean log exits throughout.

The pipeline wasn’t erroring. That’s what made the two weeks take until the 13th to diagnose.

What the cron was doing

Each cycle runs the same sequence. Triage: rank clusters by a composite calculator score. The 2 July run topped the board with mortgage at 20. Fetch ground. Score candidates. Then draft.py checks the index before drafting anything.

The index records which clusters the pipeline has already handled. An entry means draft.py skips. Don’t redraft something that shipped last week.

The bug was in how “already handled” was being read.

A cluster that had failed verification on a previous cycle had a failed-* entry in the index. draft.py saw the entry and treated the cluster as consumed. Permanently. No retry, no cooldown window, no re-evaluation when new ground arrived. The distinction between “tried and didn’t pass” and “tried and done” wasn’t in the code. Both states looked identical at the index check.

So the mortgage cluster, the highest-scoring candidate on 2 July at score 20 with good ground, had a failed-* entry from an earlier run and never reached the drafting stage. Neither did anything else that had hit a verification failure on a previous cycle. The cron ranked them, fetched for them, then draft.py turned them away before producing anything.

Ten cycles. One draft cleared because it was genuinely new to the index: a re-run on the same mortgage rate-spike cluster from the previous day, same BBC and Zoopla ground, which shouldn’t have been in scope at all. The one draft that escaped the dead zone was itself a near-duplicate. Everything with a prior failure entry stayed locked.

Two signals that didn’t connect

The zombie IHT draft ran alongside this. A long-slug file, uk-inheritance-tax-rate-2026-40-charge-7-75-interest-rate.mdx, appeared in three separate PRs across the same fortnight. PR #171 created and deleted it within the same squash. PR #176 merged it live. The file kept being regenerated because the pipeline had a stale regeneration source that treated the cluster as still live; that source wasn’t retired until 6 July. Three PRs for one article that had already shipped.

The three-weekday gap from 7–9 July was the other signal. 7 July: eight clusters triaged to draft, six packs prepared, nothing published. Zero on the 8th and 9th. Manual rescues on 10 July produced two publishes, a stamp-duty cron draft and a Lifetime ISA pillar, and restarted the cadence clock. The devlog describes the clock as “broken and restarts from today”. Accurate: the 14-day publish cadence target had stopped and been restarted by hand.

At the time these looked like separate problems: the zombie a stale source issue, the July gap a sourcing shortfall. The failed-* conflation was the common substrate. The logs didn’t surface that connection; each failure looked locally explicable from its own output.

Why it took ten cycles

There was no heartbeat on ready-draft counts.

The cron logged what it executed: triage complete, ground fetched, draft.py exited clean. What it didn’t log: how many clusters had reached the draft stage before being turned away at the index check. That count, candidates the index rejected versus candidates that actually drafted, was absent from every cycle output.

The skipped counter is an aggregate. It climbs slowly. A counter at 3 or 4 doesn’t trigger concern; it reads as normal cadence variation. It took reaching 7, then reviewing ten cycle logs manually, to produce the root cause.

This is the monitoring failure that compounds the logic bug. When a pipeline reports clean execution on every step it runs, and those steps don’t include “how many candidates did the index reject this cycle,” the absence of drafts looks like a sourcing problem, a quality-gate issue, or a scheduling decision. It doesn’t look like a one-bit semantic error in how two different index entry types are being handled.

The skipped counter at 7 is how the post-mortem started. It shouldn’t be the only mechanism for detecting a fortnight-long draft drought.

The fix

Two changes landed on 13 July.

draft.py now distinguishes between a failed-* entry and a successful one. Failed entries return a cluster to the candidate pool on the next cycle; successful entries stay consumed. The semantic distinction between tried-and-failed and tried-and-done is now in the code.

The cycle runner now logs ready-draft counts explicitly: how many clusters reached the draft stage, how many passed verification, how many are queued for publish. If that count is zero across consecutive cycles, it appears in the logs without requiring a manual retrospective across ten runs.

Both changes are small in diff size. Their effect on cron output is not.

What the fortnight cost

Ten cron firings from 30 June to 13 July produced one ready draft and zero auto-publishes. The one draft was itself a near-duplicate. Two manual rescues restarted the cadence clock. The pipeline had been running correctly by its own internal logic throughout. “No errors” and “producing outputs” are different claims.

Improving on the earlier failed-cluster dead zone piece: that article named the symptom and counted the silence. The specific mechanism wasn’t in scope when it was written: failed-* entries being read as permanently consumed, the same as successful publishes. The 13 July fix wasn’t either. This is the explanation and the resolution.

The monitoring change matters as much as the logic fix. A pipeline that produces accurate step-level logs while silently locking out its best candidates will take just as long to diagnose the next logic error. The ready-draft heartbeat doesn’t prevent the next failure mode; it reduces the window between a failure occurring and it being noticed.

Out of scope: the full sourcing anatomy of the 7–9 July gap and the cadence-engine work planned for Phase B2.

All writing