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 open-source desktop AI companion — MIT licensed, local-first, your models. In v2.6.0 she gained a ChatGPT Browser provider: she can type messages into your already-open ChatGPT conversation in Chrome, no API key, your plan's limits apply.
Sending the message was the easy part. The hard part: what happens when the process crashes after the click but before the confirmation comes back?
A duplicate send goes out under your name, in your conversation. That's not a failed API call you can shrug off — it's user-visible damage you can't unsend. So I built delivery the way databases build transactions: a durable send ledger, where only a committed record can authorize a browser click, and crash recovery reports custody instead of resubmitting work.
One row per send, and the payload can never silently change
Every send becomes a row in BrowserOperationStore (src/integrations/chatgpt-browser/operation-store.mjs). The row is validated as immutable on write: same operation ID but different text or binding throws Operation identity payload changed. A send is admitted exactly as proposed, or not at all.
The lifecycle is a one-way state machine: queued → claimed → dispatched → accepted → completed, with side exits to stopped, rejected, and uncertain. The BrowserDelivery layer enforces the custody rule: "Host-side delivery custody; only a durable first dispatch can authorize a browser click." And poll() refuses to hand out the next send while anything is still in flight — one browser send at a time.
A receipt can't precede the dispatch that created it
Reply correlation is where I refused the easy road. A reply's identity is the native tuple (conversationId, userMessageId, turnId) — the code says it outright: "never inferred from time or a reused request ID." Completion requires an answer with role === 'assistant', channel === 'final', end_turn === true, and status === 'finished_successfully'. Per the verification doc: "commentary, private analysis, quiet time and unrelated replies cannot settle it."
The store goes further: accept() throws Receipt precedes durable dispatch if a receipt arrives for an operation that was never durably dispatched, and Native receipt owner changed if a second, different receipt shows up for the same send. The first acceptance is the only acceptance — there is no "check the newest message and assume it's ours."
Crash semantics: uncertain, and never resubmitted
On restart, recover() walks the ledger: queued rows become rejected (they never left the host — safe to discard), while claimed/dispatched rows become uncertain (the click may have happened; we cannot know). The store's own contract: "Recovery reports custody and never submits browser work."
For native tool calls the rule is stricter. PersistentToolReceipts implements "durable at-most-once admission, including crash-before-receipt. No automatic mutation replay." A started-but-unsettled action is journaled as uncertain, and the model sees: "Error: tool outcome is uncertain after restart; do not retry the side effect." An uncertain action is never executed again — I'd rather surface an honest "I don't know what happened, please check" than replay a mutation and discover the first attempt had actually landed.
There's even an upgrade guard for the receipt schema: old generation-wide admissions carry a LEGACY_NATIVE_RECEIPT marker, because an admission from before per-call tracking "cannot safely be assigned to a new native call." Receipts are capped at 1,000 — the store refuses new work before it forgets old mutations.
Stop is a fenced intent, not a suggestion
Stopping a send works the same way. stop() records an epoch fence — a SHA-256 of the full browser binding (lease, tab, conversation, thread, workspace) — into up to 1,000 stopped epochs that survive restarts, then marks every non-terminal operation stopped. The guide's rule: "Stop records cancellation before another Send or action can be admitted." A stopped epoch's queue() throws outright. The remote Stop itself has durable phases — pending → dispatched → confirmed/uncertain — because inventing a confirmation that the chat never supplied is not an option.
The journal has to be crash-safe first
None of this works if the ledger itself can tear. Every write goes through DurableProfileState: temp file with exclusive create, fsync, then atomic rename; files at mode 0o600; a realpath check refuses state that leaves its owned directory; commits to the same file are serialized; 16 MB cap. The module header states the ordering invariant: "Commit precedes any action grant or receipt acknowledgement." The click is downstream of the commit. Always.
The decision I'd argue about
The whole design is fail-closed: a dispatched-but-unacknowledged send becomes uncertain and is never resubmitted, though the tab usually shows the message plainly. The alternative I rejected was auto-reconciliation — read the tab's newest user message after restart, and adopt it as ours if the text matches. More convenient, and exactly wrong when you typed something between the crash and the restart: the companion would attribute your message to itself and report a success it didn't earn.
I chose the honest failure. A message you have to glance at and confirm beats a system that might quietly claim your words. Would you have drawn the line in the same place — or is the uncertain state just failure with better branding? Genuinely curious how others would handle it.
Ankita is open source (MIT) at github.com/akyourowngames/A.N.K.I.T.A — the ChatGPT Browser provider shipped in v2.6.0, and the send ledger lives in src/integrations/chatgpt-browser/. If you've built delivery semantics around a UI you don't control, I'd love to hear what you did.
Top comments (12)
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.
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.
"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.
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.
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.
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.
Good - and the claim-to-dispatch window is exactly where the demotion mapping earns scrutiny. Since recover() sends claimed rows to uncertain but un-dispatched rows to rejected, the fixture should assert the row lands in uncertain specifically, not just "not replayable." A claimed row that crashes pre-dispatch has arguably already spent its one send in spirit; rejected would let it be retried, uncertain holds it for a human. That distinction is the whole point of the state machine, so pin the destination, not only the absence of a second send.
Taken — and you're right that the destination is the part worth pinning. The existing crash fixture in test/integrations/chatgpt-operation-store.test.mjs only pins the dispatched→uncertain case: queue, claim, dispatch, restore, recover, assert uncertain, and that re-claim/re-dispatch fail. The gap is exactly the window you named — claimed, never dispatched. The fixture I'd add: queue → claim → fresh store instance → recover() → assert the outcome is uncertain specifically (not just non-replayable), claim() returns null, dispatch() returns false, and dispatchedAt is unset so the test pins the claim window. recover() itself is three lines in src/integrations/chatgpt-browser/operation-store.mjs: queued→rejected, claimed/dispatched→uncertain.
Why the destination matters more than "not replayable", grounded: dispatch() only fires from claimed, so once the row sits in uncertain it can never be re-dispatched — the guard does the enforcing, the demotion just chooses which bin the guards apply to. And uncertain isn't a dead end: accept() can reconcile it by reobservation ("grants no new Send"), which is the human/native-side path back.
The honest caveat: "spent its one send in spirit" is my reading too, but strictly the row was never dispatched — recover() can't distinguish crash-before-dispatch from crash-after-dispatch-without-receipt, so it bins both as uncertain. The mapping is deliberately coarse: fail-closed on the crash side, and the fixture's job is to lock that coarseness in, not pretend we can tell the two apart.
The coarseness caveat is the right call, and it's worth pinning as a property rather than treating as a limitation: run the fixture twice, once crashing after claim before dispatch and once crashing after dispatch without receipt, and assert both rows bin identically. Indistinguishability is the design - a recover() that tried to tell the two apart would be guessing from evidence it doesn't have, and a guessed distinction is worse than an honest coarse one because downstream code starts trusting it. The one assertion I'd add on the reconcile path: after accept() reobserves and closes an uncertain row, the ledger should record who reconciled and on what evidence, not just flip the state. Months later, "uncertain -> accepted" with no provenance is the row an auditor can't reconstruct. That's the same traceability standard as the crash fixture, just on the human side of the state machine.
Taken — and you're right to pin it as a property, not a limitation. In src/integrations/chatgpt-browser/operation-store.mjs, recover() maps queued→rejected and both claimed and dispatched→uncertain, so the fixture writes itself: two crashed journals, one crashed after claim but pre-dispatch, one after dispatch with no receipt — assert both bin to uncertain identically. A recover() that tried to tell them apart would be guessing from evidence it doesn't have: the journal can't say whether the native send actually happened between the dispatch commit and the missing receipt.
The honest caveat: the journal does record that much — claimed and dispatched are distinct row states — so the collapse is a deliberate choice at recover() time, and there's a real asymmetry the fixture should pin alongside it: fail() distinguishes the two (claimed→rejected, dispatched→uncertain) while recover() coarsens them. The price of the coarseness is that an uncertain row can only ever resolve via accept() reobserving the same native receipt — dispatch() only fires from claimed — so a claimed-crash send is dead forever. Fail-closed: under-delivery instead of a possible double-send.
The fail()/recover() asymmetry you found is the right thing to notice, and I think it resolves: it isn't an inconsistency, it's evidence. fail() is called by code that knows where it was in the flow - it can afford claimed->rejected because the caller witnessed the claim never reaching dispatch. recover() runs blind at restart and can't afford that precision, so it coarsens. Same crash point, different bin, and the difference is exactly the caller's knowledge. That's worth pinning as its own fixture: crash at one point, run both paths, assert the bins differ - because the day someone "harmonizes" them, they've either made fail() more conservative than its evidence allows or made recover() pretend to knowledge it doesn't have, and both directions are bugs. The dead-forever price is the honest cost of the design; the only thing I'd want alongside it is observability: an uncertain row that never reconciles should surface somewhere (a count, a stale-uncertain sweep), because under-delivery that's invisible gets mistaken for a system with no failures.
Exactly — and the call sites pin it. fail() is reached from services/chatgpt-bridge/server.mjs, where acknowledge() maps the live bridge's failure: true onto delivery.fail() for an operation the bridge was actively handling. When the row is still claimed the claim provably never reached dispatch, so claimed→rejected is sound; when it's dispatched the bridge may have acted, so dispatched→uncertain. The caller watched the operation — it has live flow knowledge. recover() in src/integrations/chatgpt-browser/operation-store.mjs runs blind at restart with nothing but the journal, so it coarsens both to uncertain.
Honest caveat on my own earlier framing: the precision lives in caller discipline, not in store.fail() itself. Nothing in fail() knows the caller witnessed anything — it's sound only because the bridge is the only live caller. If some future path called fail() blind, claimed→rejected would silently become unsound precision. The fixture pair is what pins which call path means what.