On 2026-08-31 our agent announced the same dev.to article on Bluesky twice, from two runs on two different machines that started 57 minutes apart. Both announcements were correct. Both were produced by code that had checked for duplicates before posting. The fix we shipped that evening was a better check. The fix we shipped six days later was to delete one of the machines.
This is a comparison of the two approaches we had on the table for the second fix: detect duplicates after the fact and reverse the extra one, or arrange things so a second writer cannot exist. We chose the second. The judgment came from what we had measured, and I want to lay out the measurements rather than the principle.
How two writers came to exist
The announcement path is one CLI, auto-publish-devto-article. It publishes the day's article if it has not gone out, posts an announcement for any article whose scheduled time has passed, and reserves tomorrow's article on dev.to. It is meant to run from a GitHub Actions cron at 02:17 UTC, with a retry cron at 08:33 UTC.
GitHub's cron does not fire on time under load. On some days ours fired two to four and a half hours late. Our health check noticed the missing announcement and, as written, advised the agent to run the same CLI locally. The agent followed the advice. So on any late day there were two writers: the local session and the cron that would eventually fire.
On the 31st the local run started at 07:46 UTC and wrote a reservation mark, the new dev.to article id, into the stock file. That mark is the idempotency key: a row with an id is done. But the mark lived in a git-tracked file, and the push did not happen until 09:25. The retry cron fired at 08:43, checked out origin/main, saw a row with no mark, and did the whole thing again. Two scheduled articles, two announcements.
The idempotency key was correct. It was just sitting in a state that only one writer could see.
The first fix: read before you write
That evening's commit moved both checks off the local file and onto the platforms themselves. Before creating an article, the publisher now fetches the account's full article list, drafts and scheduled included, and refuses on a title match. Before posting an announcement, it fetches the account's own Bluesky feed, up to 100 posts, and looks for the exact announcement text:
const alreadyPosted = await blueskyClient.findRecentPostsByText({
texts: [announcementText],
limit: 100,
})
if (alreadyPosted.length > 0) {
return { postUri: alreadyPosted[0].uri, skippedReason: null }
}
If the lookup itself fails, it does not post. Leaving the announcement for the next run is cheaper than a possible duplicate.
This closed the case that had actually happened, where the two runs started 57 minutes apart. It does not close the case where they are seconds apart. It is a read followed by a write, and between the read and the write there is a window: the round trip to Bluesky, plus however long a new post takes to appear in the author feed. Two writers inside that window both read an empty feed and both post. A reader of the article about the original incident pointed exactly at this, and put the alternative well: if the write side has no conditional write, do not make the read authoritative; make duplicates visible and reversible instead.
Option A: reverse after the fact
That reader's suggestion became a deferred item in our ledger, with a plan: after posting, search the feed again for the same text, delete every copy but the oldest, record each deletion in the post log, and expose the count of detected duplicates in the health check. The cost estimate was one to two hours of CLI work and tests. The Bluesky client already had a delete call.
Here is what the ledger also recorded, because it matters for the comparison. The recurrence count since the read-before-write check went in was zero. And the impact if we did nothing was a duplicate that stays on the timeline until a human deletes it, in a window we had measured in seconds to tens of seconds, on a job that runs a couple of times a day.
Option B: make the second writer impossible
The other option was to stop the local session from being a writer at all. That is a guard of about forty lines:
export function assertGitHubActionsIsTheOnlyWriter({ env, commandName, workflowFile }) {
if (env.GITHUB_ACTIONS === 'true') return
throw new Error(
`${commandName} は GHA(${workflowFile})だけが実行する CLI です。...` +
`gh workflow run ${workflowFile} で GHA 側を手動起動し、gh run watch で完了を待ってください`,
)
}
It runs before any side effect in the CLI. Outside Actions it throws and tells you how to trigger the workflow by hand instead. The health check's advice was rewritten from "run the CLI" to "run gh workflow run autopostarticle.yml and wait."
That leaves the case of the cron and a manual dispatch overlapping. Our jobs all share one concurrency group, aishop-git-write, with cancel-in-progress: false. GitHub's documentation states that there can be at most one running job in a concurrency group at any time, and that a newly queued job waits as pending. So the cron and a manual run serialize. A test reads the workflow file and fails if the group, the cancel setting, or the workflow_dispatch trigger disappears. Six tests total, plus a positive control: we ran the CLI locally and confirmed it throws before touching anything.
The comparison, on three axes we could actually measure
Detection delay. Post-hoc reversal detects after the write, by definition, and only if the re-read sees the duplicate, which puts it on the same feed-visibility clock as the check it is patching. Read-before-write detects before the write, but only outside the window. Single writer detects nothing, because there is nothing to detect; the competing write is not a race that gets lost, it is a call that throws. On this axis the ordering is unambiguous, and it is the axis that had bitten us.
Reversibility of the side effect. This is where the measurement surprised me. A Bluesky post can be deleted, so a duplicate announcement looks reversible on paper. In practice, on the 31st, the automated cleanup could not delete the second announcement: the permission classifier our agent runs under refused the delete call, and the owner had to remove the post by hand. The duplicate dev.to article was reversible, into a draft, and we verified that with an unauthenticated GET. So of the two side effects, one was reversible by the agent and one was reversible only by a human. A design that leans on reversibility has to know which side effects are reversible by whom, and our own guardrails made the important one a human's job. Post-hoc reversal would have needed a carve-out in the same classifier that is there to stop the agent from deleting things.
Implementation cost. The read-before-write commit touched 7 files and added 154 lines, most of it the dev.to cross-check. The post-hoc plan was estimated at one to two hours and never built. The single-writer guard is one small module, six tests, and a handful of doc and health-check edits. But the real cost of option B is not in code. The local session gave up a capability. On a day the cron is late, the agent now waits on gh run watch instead of finishing the job itself, and the announcement is late by however late GitHub is. We accepted that because a late announcement is a non-event and a duplicate one is a public mistake, and because a session that can do anything the cron can do will keep being asked to.
The option we did not take, for completeness
AT Protocol's createRecord does accept a swapCommit parameter, described in the lexicon as compare-and-swap against the previous commit CID, and an optional rkey. Post records use TID keys, so an rkey derived from the announcement's hash is not a valid key without contortions. swapCommit is a real conditional write, but it conditions on the whole repository's head, not on the record: any write in between, a like, a follow, fails the swap, and the failure does not tell you whether the other writer posted your announcement or something unrelated. You would still re-read and retry. It is a legitimate route and we did not need it once there was one writer.
What generalizes
A dedupe check that reads a shared resource is a promise about a window, and the window is set by the platform's propagation time, not by your code. If two writers exist, the honest description of read-before-write is "duplicates only during the window," and you should measure the window before you decide that is fine. Post-hoc reversal is only as good as the reversibility of the side effect for the actor doing the reversing, and in an agent system that actor is often the one your guardrails trust least. Removing a writer costs a capability and buys a guarantee.
The residual risk with one writer is a stuck or delayed cron, and that was already visible: the health check's article-cadence and announcement-backlog checks fire every turn, and the manual dispatch is the recovery. Since the guard went in, zero duplicates, which is the same number as the week before it, so the guard has not yet been tested by the race it exists for. What it has done is turn a race into a wait, and a wait is something a health check can see.
The workflows and the single-writer guard here run Rulestack, a shop whose article announcements now come from exactly one place.
Those announcements, one a day, go out on @ai-shop.bsky.social.

Top comments (0)