{"record":{"id":"d0c9f3700bd9f0ea","repo":"can1357/oh-my-pi","slug":"manualtriageconflict-delivery-state","errorCode":null,"errorMessage":"ManualTriageConflict(delivery, state)","messagePattern":"ManualTriageConflict\\(delivery, state\\)","errorType":"exception","errorClass":"ManualTriageConflict","httpStatus":null,"severity":"error","filePath":"python/robomp/src/manual_triage.py","lineNumber":115,"sourceCode":"    delivery = manual_delivery_id(repo_full, number)\n    existing = db.get_event(delivery)\n    if existing is not None and existing.state in (\"queued\", \"running\"):\n        raise ManualTriageConflict(delivery, existing.state)\n\n    payload = await build_issues_opened_payload(github, repo_full, number)\n    replaced = db.replace_event_if_state_in(\n        delivery_id=delivery,\n        event_type=\"issues\",\n        repo=repo_full,\n        issue_key=issue_key(repo_full, number),\n        payload=payload,\n        state=\"queued\",\n        allowed_existing_states=INACTIVE_EVENT_STATES,\n    )\n    if not replaced:\n        current = db.get_event(delivery)\n        state = current.state if current is not None else \"active\"\n        raise ManualTriageConflict(delivery, state)\n    return delivery\n\n\n_TERMINAL_STATES: tuple[str, ...] = (\"done\", \"failed\", \"skipped\")\n\n\nasync def await_terminal_state(\n    db: Database,\n    delivery_id: str,\n    *,\n    poll_interval: float = 2.0,\n    timeout: float | None = None,\n) -> EventRow | None:\n    \"\"\"Block until the event row reaches a terminal state, vanishes, or times out.\n\n    Pure DB polling — the caller MUST NOT spawn its own ``WorkerPool``; the\n    long-lived ``serve`` process is the only owner of the dispatcher loop.\n    Returns the final row, or ``None`` if the row was deleted while waiting.","sourceCodeStart":97,"sourceCodeEnd":133,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/python/robomp/src/manual_triage.py#L97-L133","documentation":"ManualTriageConflict raised when the optimistic replace_event_if_state_in fails — the event row was concurrently changed out of the allowed inactive states between the earlier check and the write, so the enqueue lost a race. `state` is the row's current state (or \"active\" if the row vanished).","triggerScenarios":"Two concurrent enqueue_manual_triage calls racing: one replaces the row to \"queued\" between the other's get_event check and replace_event_if_state_in write.","commonSituations":"Parallel API triggers or dashboard/API simultaneous manual triage for the same issue.","solutions":["Retry enqueue_manual_triage after the competing run finishes (handle the conflict and re-enqueue later)","Serialize triggers per repo#issue with a lock or single dispatcher","Inspect the event's current state to decide whether to wait or abandon"],"exampleFix":"// before: bare call, races possible\nawait enqueue_manual_triage(github, db, repo, num)\n// after: tolerate the race with one retry\ntry:\n    await enqueue_manual_triage(github, db, repo, num)\nexcept ManualTriageConflict:\n    await asyncio.sleep(1)\n    await enqueue_manual_triage(github, db, repo, num)","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"try:\n    delivery = await enqueue_manual_triage(github, db, repo_full, number)\nexcept ManualTriageConflict:\n    await asyncio.sleep(poll_interval)\n    delivery = await enqueue_manual_triage(github, db, repo_full, number)","preventionTips":["Route manual triage through a single serialized dispatcher per repo#issue","Use idempotent trigger IDs client-side","Treat conflicts as 'another run won' and monitor that run instead of retrying immediately"],"tags":["conflict","race-condition","concurrency"],"backgroundTag":"optimistic-lock-conflict","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}