One ready draft in fourteen days: the failed-cluster dead zone

The skipped counter was at 7. Two weeks earlier it had been 2. Between those two numbers the cron had fired ten times, triage had run, candidates had been fetched, draft.py had been called. One ready draft. Zero auto-publishes. Every run exited clean.

The earlier article named the consumption pattern before I confirmed the root cause. The thirteenth July sprint pinned the mechanism and applied two changes: a type check on how draft.py reads its index, and a heartbeat metric that makes future gaps visible before they run for a fortnight.

The mechanism

draft.py consults an index before drafting. The index tracks which clusters have been processed. If a cluster ID appears in the index, the script treats it as consumed and skips it. Correct for clusters that produced a shipped draft. Incorrect for clusters whose attempt failed.

A failed-* entry is not a shipped draft. It records an attempt that didn’t complete. But draft.py was checking for the presence of any entry, not the kind. A cluster with a failed-* record was as permanently excluded as one that had reached production.

The consequence: any topic that had failed once would never be retried. As failed-* entries accumulated across ten cycles, the effective candidate pool shrank. Triage still ranked 150 clusters each morning. The top candidates were still fetched. Then draft.py checked the index, found prior failed entries for the highest-scored topics, and moved them to skipped. The counter climbed from 2 to 7.

One draft made it through the gap: the mortgage rate-spike cluster on first July, before its own failure record existed. BBC, Zoopla, Moneyfacts, Bank of England ground. US-Iran framing. £232 and £66 regional figures. A real draft from real sources. One of ten.

What the cron was reporting

The cron ran on schedule every morning. Triage completed, candidates were fetched, draft.py was invoked, exit code 0. By every process-level metric, the pipeline was healthy.

Process health and output health are not the same thing. The cron’s success metrics reported on process; they said nothing about output. For fourteen days, thirtieth June to thirteenth July, those two measures had diverged completely.

The skipped counter climbing from 2 to 7 was the only visible signal, and reading it as a warning required knowing what skipped meant. That’s archaeology, not monitoring. Nothing in the run logs flagged an anomaly. The process was running. The pipeline had quietly stopped producing publishable work.

The gap in full

The tenth July devlog reconstructs the anatomy. Seventh July: success, no publish. Eight clusters triaged to draft, packs fetched, all blocked at the index check. Eighth and ninth: the same pattern. Three weekdays, no articles.

Two publishes landed on the tenth: a rescued stamp-duty cron draft and a Lifetime ISA pillar. Operator-published, not cron-delivered. The pipeline hadn’t produced them; I had.

The zombie IHT draft ran through the same period. The long-slug file appeared in PR #171, got deleted in the same squash, came back in PR #176 merged live, then reappeared a third time, because the regeneration source had never been root-caused. Sixth July’s fix closed it.

The failed-* dead zone and the zombie IHT file were separate faults with different surfaces and different fixes. They shared a root pattern: failure records being read as completion records. The index was being treated as a log of work done. It was a log of work attempted.

The fix

Two changes landed on thirteenth July.

First: draft.py now inspects the entry type, not just its presence. A failed-* record marks the cluster as available for retry, so topics that failed previously re-enter the candidate pool on the next cycle. The dead zone closes.

Second: ready-draft count was added as a pipeline heartbeat metric. The existing telemetry reported process health: whether steps completed, whether exceptions were thrown. The new metric reports output health, meaning how many ready drafts sit in the queue. Ten clean runs producing one ready draft is a different signal than ten clean runs producing twelve. Without the counter, the gap between those two states was invisible.

Why the counter is the right signal

Exit code 0 gives you one bit of information: the process didn’t crash. For a content pipeline, that’s necessary but not sufficient. Useful work is a publishable draft in the queue. The only reliable signal for that is a count measured at output, not at process.

One draft across ten cycles is not a rhythm — it’s a stall. The difference is legible from the ready-draft counter. It was not legible from exit codes.

The skipped counter reaching 7 was a lagging indicator, readable only in retrospect. Ready-draft count is a direct measure of pipeline output. When it stays flat across multiple cycles, the anomaly is immediate and unambiguous. The fix to draft.py stops the current gap. The instrumentation makes the next gap detectable before it runs for a fortnight.

Out of scope

Alerting on the ready-draft heartbeat is a subsequent sprint. The intent was to make the anomaly measurable. What triggers on a low count, and how the operator gets notified, is deferred.

The fourteen-day cadence clock is separately broken. The tenth July entry notes it restarted from that date after the gap. Cadence mechanics are tracked, not in scope here.

The zombie IHT fix is its own entry, sixth July. Same root pattern, different surface, closed independently.

The build log

The relevant devlog entries run from first July through thirteenth July under the ukcalculators tag. Thirtieth June to thirteenth July is the full gap window; tenth July records the anatomy, and thirteenth July is where both fixes land.

One ready draft. Ten cycles. skipped at 7. Ten green exits. The pipeline was reporting success throughout.

The cost of not having a ready-draft counter from the start was fourteen days of silence and a pair of operator publishes to cover the gap. The fix was a type check on index entries and a counter. Neither was difficult once the root cause was clear. Finding it took the fourteen days.

All writing →