Skip to content

Atomic message consumption: prevent duplicate delivery and inject-failure loss with claim/ack #373

Description

@u-ichi

Problem

`scripts/inbox.sh` reads unread rows and marks them read as two separate SQL statements (a SELECT and an UPDATE, not a single atomic operation). This is fine for a single interactive reader, but it opens two gaps for any downstream that consumes messages non-interactively:

  1. Message loss on consumer failure. Between the SELECT and the receiver actually delivering the message (paste into a TUI, HTTP POST, whatever), any crash or inject failure loses the row silently. `inbox.sh` has already run the UPDATE.
  2. Duplicate delivery under concurrent readers. Two processes tailing the same inbox both see the same unread row; there is no primitive to atomically reserve one to a single owner.

This bites anyone building a receiver on top of agmsg: external delivery daemons, the grok-build monitor watcher (#245), the Codex Desktop wake-on-unread work (#263), the doctor/dashboard (#267), and — I hit it while wiring an external delivery daemon that pipes messages into a Grok TUI, which is what motivated the fix.

Proposal

Add a small claim / lease / ack primitive next to `inbox.sh`, so any receiver can atomically reserve a message before consuming it:

  • `agmsg_claim_next(team, agent, owner, ttl)` — inside a single `BEGIN IMMEDIATE` transaction, garbage-collect expired claims, `INSERT OR IGNORE` a claim for the next unread row belonging to (team, agent), return the claimed row. Concurrent callers with different owners can safely race here.
  • `agmsg_ack_claim(message_id, owner)` — verify the owner still holds the claim, mark the message read, delete the claim.
  • `agmsg_release_claim(message_id, owner)` — drop your own claim so another receiver can retry (used on inject failure).

Storage: a small `claims` table (`message_id` PK, `owner`, `expires_at`) added via `CREATE TABLE IF NOT EXISTS` so existing DBs upgrade in place. TTL means a crashed owner's claims become reclaimable on the next `agmsg_claim_next` call without any external janitor.

Semantics:

  • inject failure → `release`, next poll retries. No loss.
  • concurrent receivers → exactly one wins the claim. No duplicate.
  • process crash → lease expires, next receiver reclaims. Self-healing.

Scope

The primitive is orthogonal to the existing monitor watcher: nothing in-tree needs to change to consume it. Upstream's monitor watcher could adopt it later to atomicize its own delivery loop; external daemons and #263's wake-on-unread work can start using it immediately.

PR

Implemented in #372 — adds `scripts/lib/claims.sh` (52 lines), `claims` table in `scripts/internal/init-db.sh`, and 5 bats cases covering exclusion, release/reclaim, wrong-owner ack rejection, TTL expiry, newline/tab body round-trip. No existing behavior is changed.


(日本語要約)現状の `inbox.sh` は SELECT と read_at UPDATE が別 SQL のため、消費者側の inject 失敗で無音の取りこぼしが起きます。同時に複数レシーバが読むと同じ行を両方に配ります。これを防ぐ atomic な claim/lease/ack API を `scripts/lib/claims.sh` として追加する提案です。`claims` テーブルは `CREATE TABLE IF NOT EXISTS` で既存 DB を破壊せず追加、TTL で crash 耐性を持たせています。#372 で実装、既存挙動は変更しません。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions