fix(witan): task_claim backs off between CAS retries, reports contention instead of raising raw - #249
Conversation
There was a problem hiding this comment.
Pull request overview
Improves task_claim conflict handling under graph contention.
Changes:
- Adds bounded jittered retry backoff.
- Returns structured contention responses instead of raw exceptions.
- Adds contention and backoff tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
mcp/servers/witan/witan/server.py |
Implements retry backoff and contention reporting. |
mcp/servers/witan/tests/test_tasks.py |
Tests retry exhaustion and backoff behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…AS conflict Copilot review on #249 found that the new backoff sleep widens a pre-existing race: `_update_task` merges `status` from `claim` unconditionally regardless of what its own fresh read shows, so a retry that doesn't revalidate first could silently resurrect a task that was closed (or newly blocked) during the backoff window. Check the post-conflict re-read for closed/blocked and bail with the real reason before ever looping back into another write. Also softened the exhausted-contention message, which overclaimed the conflict was "unrelated"/"elsewhere" when the branch-head precondition can't actually distinguish that from same-task causes it already rules out (closed/blocked) or tolerates (a released or same-holder update, or a force-steal target). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHKMgDYCszjXM1nRg9Jm1r
|
Addressed Copilot's review: both threads fixed in fb82673 (revalidate closed/blocked before retrying a CAS conflict; softened the exhausted-contention message to not overclaim the cause) and resolved. All checks green except |
…ention instead of raising raw
task_claim's CAS retry loop fired 3 immediate, unbacked-off attempts and, on
exhaustion, re-raised the raw OmnigraphConflict straight through the MCP
boundary — leaking omnigraph's internal "write authority ... changed during
preparation" text to callers whenever an unrelated write kept colliding on a
hot table (node:Task, written by every claim/update/close). Widen the budget,
add jittered backoff between attempts, and report exhaustion as a structured
{"claimed": false, "reason": "contention"} instead.
Fixes tk-task-claim-exhausts-its-3-attempt-no-backoff-cas-674414.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHKMgDYCszjXM1nRg9Jm1r
…AS conflict Copilot review on #249 found that the new backoff sleep widens a pre-existing race: `_update_task` merges `status` from `claim` unconditionally regardless of what its own fresh read shows, so a retry that doesn't revalidate first could silently resurrect a task that was closed (or newly blocked) during the backoff window. Check the post-conflict re-read for closed/blocked and bail with the real reason before ever looping back into another write. Also softened the exhausted-contention message, which overclaimed the conflict was "unrelated"/"elsewhere" when the branch-head precondition can't actually distinguish that from same-task causes it already rules out (closed/blocked) or tolerates (a released or same-holder update, or a force-steal target). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHKMgDYCszjXM1nRg9Jm1r
Publishing is triggered by a push to main touching pyproject.toml, so this makes the task_claim contention/backoff fix (and the closed/blocked revalidation follow-up) actually ship on merge instead of silently landing unpublished until some later PR's bump picks it up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHKMgDYCszjXM1nRg9Jm1r
fb82673 to
af69135
Compare
What are the relevant tickets?
Fixes tk-task-claim-exhausts-its-3-attempt-no-backoff-cas-674414 (witan work-coordination graph).
Description (What does it do?)
Recent sessions were hitting a raw internal error when calling
task_claim:task_claim's CAS retry loop (mcp/servers/witan/witan/server.py) fires aconditional write and, on a surfaced
OmnigraphConflict, re-reads to decidewhether a rival actually holds the task (
lost_race) or the conflict wasunrelated (another write elsewhere on the graph). The bug was in the
"unrelated conflict" path:
raise, letting the rawOmnigraphConflict— whose message is the "write authority ... changedduring preparation" prose — escape straight through the
@_toolwrapper(which does no exception handling) to the MCP client.
node:Taskis one of the hottest tables in this graph (everyclaim/update/close from every session writes it), and this repo's own
write-gate investigation (tk-the-write-gate-is-sized-against-a-3-45s-solo-wri-73fc2b)
already measured loaded writes taking 17-44s under contention — three
immediate, unbacked-off retries have no chance against that.
Changes:
_CLAIM_MAX_ATTEMPTS: 3 → 5._claim_backoff, 0.25s base, 3s cap) between retryattempts, so concurrent claimers on the same task/table don't collide in
lockstep.
{"claimed": false, "reason": "contention", "remedy": ...}instead of letting the raw omnigraphexception escape. Logs
witan.task_claim.contention_exhaustedforobservability.
"contention"reason value.This does not address the underlying multi-second write contention itself —
that's the scope of the write-gate/batching work already tracked separately.
This just stops task_claim from leaking internal transport errors to callers
when it hits that contention.
How can this be tested?
mcp/servers/witan/tests/test_tasks.py:test_claim_exhausted_conflicts_report_contention_not_raise— simulatesevery retry attempt hitting an unrelated OCC conflict (no rival ever
holds the task) and asserts
task_claimreturns{"claimed": false, "reason": "contention"}rather than raising, and that the task is leftuntouched (
status: "open").test_claim_retries_back_off_between_attempts— asserts a sleep occursbetween a conflicting attempt and its retry, with the expected backoff
magnitude.
mcp/servers/witan— 835 passed (0 failed, 0skipped beyond the pre-existing omnigraph-not-on-PATH skips).
ruff check/ruff format: confirmed zero new lint debt againstmain(same 18 pre-existing rule violations, none introduced by this change);
both changed files format-clean.
Additional Context
Filed and diagnosed after a direct report: "Recent Claude sessions are
hitting a witan error when interacting with the graph: write authority
'table_head:node:Task' changed during preparation". Root-caused by reading
the CAS classification path in
witan_core/omnigraph.py(confirmed the"write authority" prose is correctly classified as retryable — the defect is
entirely in
task_claim's own retry budget, not the classifier) and cross-referencing the write-gate/mutual-exclusion investigations already tracked
under
wp-witan-multi-user-service-deployment-dcf6ee.