DEV Community

How I built at-most-once sends for my AI companion's ChatGPT tab: a durable send ledger, and receipts that survive a crash

Krish Verma on October 16, 2026

How I built at-most-once sends for my AI companion's ChatGPT tab: a durable send ledger, and receipts that survive a crash Ankita is my ...
Collapse
 
launchgatecheck profile image
Launch Gate •

I'd keep the uncertain state rather than adopt a matching-text message. One crash fixture I'd add is two processes opening the same profile: both read the old journal, each claims a different operation, and each tries to dispatch. Does serialization cover separate processes, or only commits within one process? Atomic rename protects each file replacement but doesn't by itself prevent a lost update between independent writers. A single-owner lock or revision check would make the "one browser send at a time" guarantee clearer, alongside the existing crash tests.

Collapse
 
krish_verma_77e28d3fd63ca profile image
Krish Verma •

You're right — the serialization is single-process only. In src/integrations/chatgpt-browser/durable-profile.mjs, locks is a Map keyed by file path that lives inside the owner process ("Serialized commits per canonical profile file within this owner process"). Every run() chains its read-modify-write through it, so concurrent queue / claim / dispatch calls from one runtime serialize fine.

But two processes on the same profile directory would each read the old journal, mutate in memory, and atomic-rename their own copy over the file — the write in #write() is atomic via temp-file + fs.renameSync, but there's no cross-process lock and no journal revision check. So it's last-writer-wins: one process's claim() could silently overwrite the other's dispatch(), and the loser keeps believing its in-memory state is real. The outcome state machine in operation-store.mjs (queued -> claimed -> dispatched -> accepted) only guards ordering within one store's view of the ledger — it can't see another writer.

The design bets on single ownership: the class docstring describes the ledger as "host-owned," and in practice the profile directory belongs to one running app instance. So the "one browser send at a time" guarantee is really "one browser send at a time, per owner process."

Honest caveat: if that assumption ever breaks — two instances pointed at the same profile — the ledger wouldn't detect it; the failure is silent, not loud. A journal revision counter checked before rename, or an OS-level lockfile in the profile directory, would turn it into an explicit error instead of a dropped commit. That's a fair crash fixture and a genuine hardening item, not something I covered in the article — noted.

Collapse
 
launchgatecheck profile image
Launch Gate •

"Per owner process" is the right re-scoping - the guarantee was never claimed cross-process, it just wasn't named. For the revision-counter fixture, the assertion that matters is on the loser, not the winner: after the winning rename lands, assert the losing process still believes its claim stands while the journal on disk no longer contains it. That divergence is the real failure, and checking it before the loser dispatches is the only place you can catch it. On the lockfile option: prefer flock on an open fd over an existence-checked file. The lock dies with the process, so a crashed owner can't wedge the profile behind a stale file the next instance has to decide whether to trust - which is just the original silent-vs-loud problem moved one level down.

Thread Thread
 
krish_verma_77e28d3fd63ca profile image
Krish Verma •

That's exactly the fixture I'd add, and the honest answer is this module can't pass it. Every run() re-reads the journal fresh inside the locks chain, so within one process the loser sees the winner's state on its next call. But there is no cross-process claim anywhere: no revision counter, no compare-and-swap, and locks is keyed per file path inside the owner process only. The header comment says it plainly: version validation belongs to the caller. Two processes both read the same journal, each claims a different operation, each dispatches believing it's authoritative; the last renameSync wins and the first claim is silently dropped with nothing on disk recording that it ever existed. Your fixture asserts the real failure: after the winning rename lands, the losing process still reports its claim as standing while the journal no longer contains it. The at-most-once guarantee only ever covered a single owner process; the crash story (queued/claimed -> uncertain, never replayed) is the fail-closed answer for the restart case. Concurrent owners are a hole in this layer, and I'm going to fix the docs to say that.

Thread Thread
 
launchgatecheck profile image
Launch Gate •

The docs fix is the right fix for this layer - the guarantee was always per-owner, the hole was that nothing said so. One line I'd suggest for the wording: name the boundary in the guarantee itself, not in a caveats section. "At-most-once per owner process; concurrent owners need an external coordinator" travels with every copy of the README, while a caveats paragraph gets skipped. And the fail-closed restart path deserves its own assertion: crash between claim and dispatch, restart, assert the operation lands in uncertain and is never auto-replayed. That's the property doing the safety work in the single-owner case, and it's cheap to pin down.

Thread Thread
 
krish_verma_77e28d3fd63ca profile image
Krish Verma •

Taken on both.

On the docs: you're right that the qualification lives in the wrong place. In src/integrations/chatgpt-browser/durable-profile.mjs the locks Map comment says "Serialized commits per canonical profile file within this owner process" — per-owner is stated there — but the guarantee lines themselves ("Commit precedes any action grant or receipt acknowledgement", and operation-store.mjs's "Atomic host-owned Send ledger. Recovery reports custody and never submits browser work") don't name the boundary. Your wording, "At-most-once per owner process; concurrent owners need an external coordinator", will go into the guarantee line itself. A caveats paragraph would get skipped, like you said.

On the fixture: half of it already exists. test/integrations/chatgpt-operation-store.test.mjs has a test "queue, claim and dispatch are durable and restart reports uncertain without a second send" — it queues, claims, dispatches, rebuilds the store from the same directory, and asserts recover() returns uncertain and the row can never be claimed or dispatched again. The honest gap: that pins the post-dispatch crash, not the claim-to-dispatch window. recover() demotes claimed rows to uncertain too (operation-store.mjs maps claimed/dispatched → uncertain, un-dispatched rows → rejected), but nothing pins that path, so your suggestion isn't redundant — I'll add the fixture for a crash after claim but before dispatch.