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
- Ensure only one process dispatches a given token; serialize token usage (single worker per claim).
- Use a dedicated connection per process (connect() per caller) and never share the sqlite3 connection across threads without serialization.
- Inspect triggers on obligation_delivery_claims — recursive_triggers=ON means triggers can fire on the UPDATE and alter state; remove conflicting triggers.
- 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
- Never share a token between two workers or threads
- Use one sqlite3 connection per process
- Audit triggers on obligation_delivery_claims that could mutate state during updates
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
- Another Nasiko lifecycle operation won lock acquisition
- Another Nasiko lifecycle operation won stale-lock recovery
- baseline is not the active harness configuration
- capsule.lock_lost
- claim cannot grant another dispatch
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)