affaan-m/ECC · error · ClaimError

dispatch transition lost

Error message

dispatch transition lost

What it means

begin_dispatch raises this when the guarded UPDATE ... WHERE token=? AND state='claimed' affected a rowcount other than 1. It is a belt-and-braces check after the earlier state read: the claim vanished or changed state between the SELECT and the UPDATE inside the transaction. Under BEGIN IMMEDIATE this indicates a competing writer or schema-level interference.

Solutions

  1. Ensure only one process dispatches a given token; serialize token usage (single worker per claim).
  2. Use a dedicated connection per process (connect() per caller) and never share the sqlite3 connection across threads without serialization.
  3. Inspect triggers on obligation_delivery_claims — recursive_triggers=ON means triggers can fire on the UPDATE and alter state; remove conflicting triggers.
  4. Retry the whole operation: because the state remains 'claimed', a fresh begin_dispatch call may succeed once the competing writer is removed.

Example fix

// before: two threads, one shared connection
thread_a = Thread(target=dispatch, args=(db, token))
thread_b = Thread(target=dispatch, args=(db, token))  # races -> 'dispatch transition lost'

// after: one owner per token, own connection per thread
thread_a = Thread(target=dispatch, args=(claims.connect(path), token))
# never give the same token to two workers
Defensive patterns

Strategy: retry

Validate before calling

if db.in_transaction:
    raise RuntimeError("begin_dispatch requires a top-level committed transaction")
# verify no triggers alter the claim table
for t in db.execute("SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='obligation_delivery_claims'"):
    warn(f"trigger {t['name']} may interfere with guarded updates")

Try / catch

try:
    payload = claims.begin_dispatch(db, token, now=ts)
except claims.ClaimError:
    time.sleep(backoff)
    retry_begin_dispatch(token, attempts=attempts + 1)  # bounded retry, single owner

Prevention

When it happens

Trigger: Concurrent code updating/deleting the claim row inside the window between _claim_row's SELECT and the guarded UPDATE; a database trigger (recursive_triggers are enabled) modifying the row during the UPDATE; the claim row being deleted by another connection despite the immediate transaction.

Common situations: Multiple processes/threads sharing one SQLite connection or token and racing begin_dispatch; another tool writing to obligation_delivery_claims outside this module's state machine; trigger side effects changing 'claimed' rows.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/43dd5770aa4b9a3b. Report an issue: GitHub.

Appendix: source

Thrown at skills/operator-approval-loop/references/approval_claims.py:103

            VALUES (?,?,?,'claimed',?,?)''', (obligation_id, decision_id, token, now, now))
    return token


def begin_dispatch(db, token, *, now):
    """Return bound payload once, only after dispatching state has committed.

    A crash after this boundary is uncertain even if transport has not started.
    Do not cache/reuse this return value for another attempt.
    """
    with _transaction(db, now):
        row = _claim_row(db, token)
        if row['state'] != 'claimed':
            raise ClaimError('claim cannot grant another dispatch')
        payload = _snapshot(db, row['obligation_id'], row['decision_id'])
        changed = db.execute('''UPDATE obligation_delivery_claims SET state='dispatching',updated_ts=?
            WHERE token=? AND state='claimed' ''', (now, token)).rowcount
        if changed != 1:
            raise ClaimError('dispatch transition lost')
    return payload


def cancel(db, token, *, now):
    """Cancel only a not-yet-dispatched claim. Never reopen its decision key."""
    with _transaction(db, now):
        row = _claim_row(db, token)
        if row['state'] != 'claimed':
            raise ClaimError('only a pre-dispatch claim can be cancelled')
        db.execute("UPDATE obligation_delivery_claims SET state='cancelled',updated_ts=? WHERE token=?",
                   (now, token))


def mark_unknown(db, token, *, now):
    """Record uncertainty, including a restarted worker's dispatching claim."""
    with _transaction(db, now):
        row = _claim_row(db, token)
        if row['state'] == 'unknown':

View on GitHub (pinned to 8321021c54)