---
title: "A Cloudflare 530 at 10:59 PM and a tunnel that never restarted"
canonical: https://dxdev.com/blog/retiring-fork-via-agent-automation/
datePublished: 2026-09-19
---
A Cloudflare 530 showed up at 10:59 PM. A cloudflared tunnel had fired at boot before the network was up, exited, and never restarted itself. The fix took two minutes, and we filed one ticket to make it self-heal. What we had actually been hoping to see that night was a green checkmark on a cron job. The bigger story is what happened in the same session, seven hours and forty-nine minutes of it. We retired a Postgres-backed blog drafter we'd been maintaining as a fork, and replaced it with a markdown backlog and a scheduled agent.

## The fork we were babysitting

The old system worked, and that was the problem. It was a forked drafter that read topic ideas out of a Postgres table, generated a draft, and wrote the result back to another table. Every time we wanted to change the prompt, tweak the review pass, or add a field, we were editing schema and migration files in a fork of someone else's tool. The backlog itself, the list of things we wanted to write about, lived as rows with foreign keys pointing at a drafts table pointing at a status enum. To see what was queued, we ran a query. To add an idea, we used a script or wrote SQL by hand.

None of that is hard. It's overhead that has nothing to do with writing blog posts. We were maintaining a fork so we could maintain a table so we could maintain a list.

## The patch that raced itself

We started by patching the fork instead of ripping it out. We added a de-anonymization check as a second script that ran after the drafter finished. It scanned the freshly written row for things that looked like real names or internal hostnames before we'd let ourselves publish it. That meant two separate processes touching the same Postgres row. The drafter wrote status='drafted', then the checker read it, and if it found a problem it wrote status='flagged' with a reason column.

It worked for exactly one draft before it broke. The checker and the drafter had slightly different ideas about when a row was "ready." A race between the drafter's write and the checker's read left a row in status='drafted' with no flagged reason and no clean pass either, just silence. We spent part of that afternoon in psql tracing `WHERE status = 'drafted' AND flagged_reason IS NULL` to find the one row stuck in limbo. It traced back to the checker reading before the drafter's transaction had committed.

We could have added a lock, or a retry, or a status enum value for "checking." Instead we looked at what we'd built: two scripts arguing over row state in a database whose only job was to hold a to-do list. That was the moment we stopped patching and started replacing.

## What replaced it

The backlog moved to a single markdown file in our notes, one seed idea per entry. There is no schema, no migration, no foreign key. Adding an idea is a markdown edit, by hand or by another agent appending a line. That is also how we feed it new ideas from daily research notes without writing a script to insert a row.

The drafter itself is a two-pass flow, generate then review, run daily on a schedule rather than on demand:

1. **Generate.** An agent reads the next unclaimed seed from the backlog and writes a full draft to the drafts folder.
2. **Review.** A second agent pass reads that draft cold, against the same de-anonymization rule we'd bolted on as a race-prone side script: no real names, no internal hostnames, no customer identifiers. It either passes the draft through or writes back a flagged reason inline, as a note at the top of the draft file itself.

We fixed the race by removing the second process entirely and making the check a sequential pass inside the same pipeline. There is no "read before commit" because there is no second reader with its own idea of when the write finished. Pass one and pass two run one after the other, in the same job, on the same file, with no concurrent access to the document by two different actors.

The internal tracking epic that existed mostly to track the Postgres drafter's maintenance got cleaned up in the same session, since there was no more fork-specific plumbing left to track.

## What's different day to day

We don't maintain a fork anymore. There's no schema to keep in sync with upstream, and no migration to write when we want a new field on a draft. The backlog is a file we can read in any editor, diff in git, and hand to any agent with filesystem access, including ones that know nothing about our Postgres instance.

The daily run now produces a draft automatically from whatever's next in the backlog, review pass included. The next session on the list is going through the accumulated drafts and deciding what's actually ready to publish. That's a queue problem, not a fork-maintenance problem, and it's the first time in a while those have been different things.

## What the stuck row told us

If a database exists only to hold a to-do list, it's worth asking what it costs to keep it running and in sync. Here, two scripts disagreeing about row state in such a database produced a stuck record. A single sequential two-pass job working on one file removed the problem along with the fork maintenance.
