{"record":{"id":"634b84a8c65ed4f2","repo":"can1357/oh-my-pi","slug":"str-exc-manualtriageconflict","errorCode":null,"errorMessage":"{str(exc)} (ManualTriageConflict)","messagePattern":"(.+?) \\(ManualTriageConflict\\)","errorType":"http","errorClass":"HTTPException","httpStatus":409,"severity":"warning","filePath":"python/robomp/src/server.py","lineNumber":628,"sourceCode":"\n        if mode == \"triage\":\n            if not isinstance(issue_ref, str) or not issue_ref:\n                raise HTTPException(400, \"triage requires 'issue' = 'owner/repo#NN'\")\n            try:\n                repo_full, number = parse_issue_ref(issue_ref)\n            except InvalidIssueRef as exc:\n                raise HTTPException(400, str(exc)) from exc\n            if not cfg.allows(repo_full):\n                raise HTTPException(403, f\"{repo_full} not in ROBOMP_REPO_ALLOWLIST\")\n            try:\n                delivery = await enqueue_manual_triage(\n                    db=db,\n                    github=github,\n                    repo_full=repo_full,\n                    number=number,\n                )\n            except ManualTriageConflict as exc:\n                raise HTTPException(409, str(exc)) from exc\n            except ManualTriageError as exc:\n                raise HTTPException(400, str(exc)) from exc\n            except GitHubError as exc:\n                raise HTTPException(502, f\"github error: {exc.status} {exc.message}\") from exc\n            pool.wake()\n            log.info(\"manual triage\", extra={\"delivery\": delivery, \"issue\": f\"{repo_full}#{number}\"})\n            return JSONResponse(\n                {\"delivery\": delivery, \"state\": \"queued\", \"mode\": \"triage\"},\n                status_code=202,\n            )\n\n        # mode == \"retry\"\n        if isinstance(delivery_id, str) and delivery_id:\n            target = delivery_id\n        elif isinstance(issue_ref, str) and issue_ref:\n            try:\n                repo_full, number = parse_issue_ref(issue_ref)\n            except InvalidIssueRef as exc:","sourceCodeStart":610,"sourceCodeEnd":646,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/python/robomp/src/server.py#L610-L646","documentation":"The manual triage endpoint raised ManualTriageConflict, which api_trigger translates into HTTP 409 with the exception's message. The server throws this when the triage operation cannot proceed because the issue or delivery is already being handled or is in a state that conflicts with a new triage run (a concurrency/state conflict, not a bad request). The suffix '(ManualTriageConflict)' in the logged message marks the exception class so operators can distinguish it from other triage failures.","triggerScenarios":"POSTing to the trigger API (with a valid trigger token and delivery payload) while the target issue/delivery is already locked by an in-flight triage run, or re-triggering triage for an event whose state conflicts with an active run. Raised by the inner triage call inside `except ManualTriageConflict as exc: raise HTTPException(409, str(exc))`.","commonSituations":"Double-clicking a 'triage' button so two requests race on the same issue; a webhook redelivery firing while the first triage is still running; automated retry scripts polling the trigger endpoint faster than triage completes; a stale UI session re-submitting an already-queued item.","solutions":["Wait for the in-flight triage run on that issue/delivery to finish, then retry the trigger request","Check the delivery/issue state (e.g. via the events/state API or logs) to confirm an active run before re-triggering","Add client-side idempotency: dedupe by delivery_id/issue key so duplicate triggers are not sent","If a previous run is wedged, cancel or reset that delivery's state, then trigger again"],"exampleFix":"// before: blind re-trigger loop\nfor await (const d of deliveries) await api.trigger(d);\n// after: skip deliveries already in an active/conflicting state\nconst active = await api.listActive();\nconst activeKeys = new Set(active.map(a => a.issueKey));\nfor (const d of deliveries) if (!activeKeys.has(d.issueKey)) await api.trigger(d.deliveryId);","handlingStrategy":"try-catch","validationCode":"// Check no active/conflicting run exists before triggering\nconst evt = await api.getLatestEventForIssue('acme/widgets#42');\nif (evt && ['queued', 'running'].includes(evt.state)) {\n  throw new Error(`triage already active for ${evt.deliveryId} (state=${evt.state})`);\n}","typeGuard":"function isConflictResponse(res: { status: number; body?: { detail?: string } }): boolean {\n  return res.status === 409 && typeof res.body?.detail === 'string';\n}","tryCatchPattern":"try {\n  const res = await api.trigger(payload);\n} catch (err) {\n  if (err.status === 409 && /ManualTriageConflict/.test(err.message)) {\n    logger.warn('triage already in progress; backing off', { payload });\n    await Bun.sleep(backoffMs); // re-check state, then decide whether to re-trigger\n  } else throw err;\n}","preventionTips":["Debounce/dedupe trigger requests by delivery_id or issue key in the client","Check event state before re-triggering an issue","Add jittered backoff to any automated re-trigger loop","Never fire trigger requests from both a webhook redelivery and a manual button simultaneously"],"tags":["http-409","conflict","triage","webhook","concurrency"],"backgroundTag":"resource-state-conflict","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}