{"record":{"id":"43dd5770aa4b9a3b","repo":"affaan-m/ECC","slug":"dispatch-transition-lost","errorCode":null,"errorMessage":"dispatch transition lost","messagePattern":"dispatch transition lost","errorType":"exception","errorClass":"ClaimError","httpStatus":null,"severity":"error","filePath":"skills/operator-approval-loop/references/approval_claims.py","lineNumber":103,"sourceCode":"            VALUES (?,?,?,'claimed',?,?)''', (obligation_id, decision_id, token, now, now))\n    return token\n\n\ndef begin_dispatch(db, token, *, now):\n    \"\"\"Return bound payload once, only after dispatching state has committed.\n\n    A crash after this boundary is uncertain even if transport has not started.\n    Do not cache/reuse this return value for another attempt.\n    \"\"\"\n    with _transaction(db, now):\n        row = _claim_row(db, token)\n        if row['state'] != 'claimed':\n            raise ClaimError('claim cannot grant another dispatch')\n        payload = _snapshot(db, row['obligation_id'], row['decision_id'])\n        changed = db.execute('''UPDATE obligation_delivery_claims SET state='dispatching',updated_ts=?\n            WHERE token=? AND state='claimed' ''', (now, token)).rowcount\n        if changed != 1:\n            raise ClaimError('dispatch transition lost')\n    return payload\n\n\ndef cancel(db, token, *, now):\n    \"\"\"Cancel only a not-yet-dispatched claim. Never reopen its decision key.\"\"\"\n    with _transaction(db, now):\n        row = _claim_row(db, token)\n        if row['state'] != 'claimed':\n            raise ClaimError('only a pre-dispatch claim can be cancelled')\n        db.execute(\"UPDATE obligation_delivery_claims SET state='cancelled',updated_ts=? WHERE token=?\",\n                   (now, token))\n\n\ndef mark_unknown(db, token, *, now):\n    \"\"\"Record uncertainty, including a restarted worker's dispatching claim.\"\"\"\n    with _transaction(db, now):\n        row = _claim_row(db, token)\n        if row['state'] == 'unknown':","sourceCodeStart":85,"sourceCodeEnd":121,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/operator-approval-loop/references/approval_claims.py#L85-L121","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: two threads, one shared connection\nthread_a = Thread(target=dispatch, args=(db, token))\nthread_b = Thread(target=dispatch, args=(db, token))  # races -> 'dispatch transition lost'\n\n// after: one owner per token, own connection per thread\nthread_a = Thread(target=dispatch, args=(claims.connect(path), token))\n# never give the same token to two workers","handlingStrategy":"retry","validationCode":"if db.in_transaction:\n    raise RuntimeError(\"begin_dispatch requires a top-level committed transaction\")\n# verify no triggers alter the claim table\nfor t in db.execute(\"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='obligation_delivery_claims'\"):\n    warn(f\"trigger {t['name']} may interfere with guarded updates\")","typeGuard":null,"tryCatchPattern":"try:\n    payload = claims.begin_dispatch(db, token, now=ts)\nexcept claims.ClaimError:\n    time.sleep(backoff)\n    retry_begin_dispatch(token, attempts=attempts + 1)  # bounded retry, single owner","preventionTips":["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"],"tags":["race-condition","sqlite","concurrency","state-machine"],"backgroundTag":"internal-invariant-violation","analyzedSha":"8321021c54d670126ce3b2969d5deb880b4b0c2a","analyzedAt":"2026-09-16T10:08:13.343Z","contentChangedAt":"2026-09-16T10:08:13.343Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}