vibeapp.win

What broke

Three parallel writers overwrote each other's marks

Our internal engine could write several "Claude's mistakes" posts at once, but each writer saved its own stale copy of the candidate list, so "written" marks got lost and posts were written twice. The fix: refuse to write a candidate that is already written, and save the mark under a lock after re-reading the list.

The short version

What went wrongParallel writers each saved their own stale copy of the candidate list, so "written" marks were lost and the same candidates got written again. Nothing checked for an existing mark.

How we found itDuplicate drafts of the same story showed up in a busy review queue. Tracing them led to the list on disk, where marks were missing for posts that existed.

The rule nowWrite shared state under a lock and re-read the list right before saving, never from a copy loaded before slow work. Refuse to write a candidate that is already written.

This post documents something Claude got wrong. The claim rests on a commit or a file, not on memory. All of Claude's mistakes

We draft posts with an internal engine. One of its pages lists candidates: faults in Claude’s code that were found and fixed. Each candidate has a button that writes a draft post about it. The draft goes into a review queue and runs through the gates.

The page has to remember which candidates already have a post. It does that with a “written” mark on each candidate, stored in one list per repository. That mark stopped holding when more than one post was being written at the same time, and the same candidates got written again.

What went wrong

All the code here, including this bug, was written by Claude. The fix landed in a commit on 3 October 2026. Its message sums up the cause in one line:

three parallel writers overwrote each other and lost marks, which led to duplicates.

Here is how the write step worked before the fix, going by the diff:

  1. Load the candidate list for the repository.
  2. Find the candidate.
  3. Write the draft (this involves a model call), put it in the queue and run the gates on it, again with a model. All of this takes a while.
  4. Set the candidate’s “written” mark on the copy loaded in step 1.
  5. Save that whole copy back to disk.

With one writer at a time this works. With three, it doesn’t. All three load the list at roughly the same moment, so none of their copies has a “written” mark yet. Writer A finishes first and saves a list where only its own candidate is marked. Writer B finishes next and saves its copy, where only B’s candidate is marked, which wipes out A’s mark. Writer C does the same to both. Only the last mark saved survives.

In the code, a lost mark does not undo the post: the draft is put in the queue before the mark is set. But the list would then say the candidate had never been written, and the page shows the write button for any candidate without a mark. Press it, and you get a second draft of a post that already exists.

There was a second gap behind the first. The write function never checked whether a candidate already had a mark. It did keep an in-memory set of writers currently running, so the same candidate couldn’t be started twice at the same time. Once a writer had finished, nothing stopped another one from starting on the same candidate. So when a mark was lost, nothing else caught the repeat.

What the record shows

The commit message describes the result: lost marks, then duplicates. By late evening on 3 October, the task list records that the inbox was at zero, with 54 posts and 10 app pages out.

The fix

The fix commit changes three things.

Refuse to rewrite. Before doing anything, the write function now checks the candidate’s mark. If the candidate is already written, it stops with an error that names the existing post. If a mark is there, a second post can’t be written.

Re-read right before saving. Writers no longer save the copy they loaded at the start. A new step loads the list fresh from disk, sets the mark on that fresh copy and saves it. A comment in the code gives the reason: other posts may have been written in parallel since the list was first loaded.

Save under a lock. Re-reading alone still leaves a short gap between read and save. So the new step runs through a single promise chain. Each save waits for the previous one to finish before it reads the list. Within the engine process, read, mark and save now happen as one step that nothing else can interleave with. If one save fails, the chain keeps going and the next save is not blocked.

The slow work, writing the draft and running the gates, still happens in parallel. Only the short step that touches the shared list is serialised.

The same commit also changed what the page shows, which makes duplicates easier to spot. A written candidate used to show the same link no matter what had happened to the post since. Now the page looks up where each post actually is and says so: in the queue, in the repo, or rejected. Candidates whose posts are already published are hidden from the table, and a single “Published: N” line with links to the live posts takes their place. What’s left in the table is the work that’s still open.

What this does and doesn’t show

This was one incident on one page of one internal tool, and we’re not drawing broader conclusions from it. The pattern is a classic one: read, do slow work, write back the whole thing. It only breaks when writers overlap. The task list for that evening says that the engine now stops duplicates.

We have no count of how many duplicates were created. The commit doesn’t give one, so we won’t guess.

The rule

Shared state is written under a lock, and the list is re-read right before saving, never saved from a copy loaded before slow work. Writing a candidate that is already written is refused.