fix(witan): task_update can no longer leave an unnameable lease holder - #283
Conversation
There was a problem hiding this comment.
Pull request overview
Prevents task_update(status="in_progress") from creating leases without a named holder.
Changes:
- Defaults missing assignees using
_claim_holderand optionalsession_id. - Adds regression coverage and regenerated tool documentation.
- Bumps
witan-councilto 0.28.1.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
mcp/servers/witan/witan/server.py |
Adds assignee defaulting behavior. |
mcp/servers/witan/tests/test_tasks.py |
Tests assignee and session handling. |
docs/reference/mcp-tools/tasks.md |
Updates generated tool reference. |
mcp/servers/witan/CHANGELOG.md |
Documents the fix. |
mcp/servers/witan/pyproject.toml |
Bumps package version. |
uv.lock |
Synchronizes the workspace version. |
Suppressed comments (1)
mcp/servers/witan/witan/server.py:5392
- An explicit empty string bypasses this branch (
assignee is not None) and is written alongside the fresh lease, recreating an unnamed-holder state. This is inconsistent with_claim_holder, which treats an empty assignee as missing. Reject blank holder identities or default them through_claim_holderwhile preserving any nonblank assignee already stored.
if assignee is None:
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Address PR review feedback on #283: - session_id was inserted mid-signature, shifting every later positional argument (project_slug, parent, external_uri, symbol_refs, tags) for any Python caller using positional args -- not backward compatible with a patch release even though MCP/CLI callers are keyword-only. Moved it to the end of the parameter list. - The gap-fill decision read the row separately, before task_update handed a precomputed default into `changes`. Between that read and `_update_task`'s own read+write, a real task_claim landing in the gap would get silently overwritten by the stale default -- exactly what "only fills a gap" promises not to do. Added `_update_task(default_if_missing=...)`: a factory resolved against the SAME fresh read `_update_task` already takes immediately before merging and writing, removing the earlier, avoidable read entirely. This does not make task_update's write itself compare-and-swap (it still is not, same as every other field it touches, by design -- see _update_task's own CAS paragraph); it closes the extra window this fix's own pre-read added on top of that pre-existing, accepted tradeoff, not the tradeoff itself. 3 new tests pin _update_task's default_if_missing contract directly: fills a genuine gap, never overrides an explicit change, and does not clobber a real assignee set in between (asserted by claiming the task for real first, then calling _update_task with a default factory that would win if evaluated eagerly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNebN7uAjarwbXaWJN9zW9
tk-task-update-can-still-manufacture-an-unnameable--9ba86a task_update(status="in_progress") stamped claimed_at but never touched assignee, so a caller that used it instead of task_claim to mark work started (a common substitution -- the docstring only preferred task_claim rather than refusing) reached (in_progress, claimed_at set, assignee null). task_claim correctly refuses that state and correctly cannot say who holds it -- right once reached, but the state should not have been reachable through the normal tool surface. Live-checked against the deployed graph: several tasks across unrelated projects are in it right now. task_update now defaults a missing assignee via _claim_holder, the same identity task_claim would use, qualified by a new session_id parameter for a deployed caller the same way task_claim's already is -- but only fills a genuine gap. A task that already has an assignee keeps it, so marking someone else's task in_progress to log status doesn't reassign it. Existing rows already in the broken state are left to their lease rather than swept -- a task without a live lease is reclaimable regardless of assignee (readiness.status_pickable), and backfilling one for another actor's in-progress work would mean guessing an identity there's no way to know correctly. No caller changes needed: witan_core.remote.proxy._map_args already auto-injects session_id for any tool declaring the parameter, the same generic mechanism task_claim relies on, so the CLI and the deployed-witan path get it for free. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNebN7uAjarwbXaWJN9zW9
Address PR review feedback on #283: - session_id was inserted mid-signature, shifting every later positional argument (project_slug, parent, external_uri, symbol_refs, tags) for any Python caller using positional args -- not backward compatible with a patch release even though MCP/CLI callers are keyword-only. Moved it to the end of the parameter list. - The gap-fill decision read the row separately, before task_update handed a precomputed default into `changes`. Between that read and `_update_task`'s own read+write, a real task_claim landing in the gap would get silently overwritten by the stale default -- exactly what "only fills a gap" promises not to do. Added `_update_task(default_if_missing=...)`: a factory resolved against the SAME fresh read `_update_task` already takes immediately before merging and writing, removing the earlier, avoidable read entirely. This does not make task_update's write itself compare-and-swap (it still is not, same as every other field it touches, by design -- see _update_task's own CAS paragraph); it closes the extra window this fix's own pre-read added on top of that pre-existing, accepted tradeoff, not the tradeoff itself. 3 new tests pin _update_task's default_if_missing contract directly: fills a genuine gap, never overrides an explicit change, and does not clobber a real assignee set in between (asserted by claiming the task for real first, then calling _update_task with a default factory that would win if evaluated eagerly). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JNebN7uAjarwbXaWJN9zW9
a9bef9d to
86b8f01
Compare
|
Rebased onto main (86b8f01) after #281 landed and conflicted on the CHANGELOG/version bump — renumbered this entry to 0.29.1 (following 0.29.0). All checks green except |
Copilot flagged this on #283 as a SUPPRESSED comment, which files no review thread — so it did not appear in the unresolved-thread count and was nearly missed on a PR already marked fully addressed. `_claim_holder` has always read a blank assignee as missing (`if assignee:`), while `task_update`'s write path tested `is not None`. The two disagreed, and an explicit `assignee=""` fell through the gap: the blank was written AND the `in_progress` default declined to fill it, reconstructing the exact `(in_progress, claimed_at set, no nameable holder)` state this PR exists to make unrepresentable — through the very parameter meant to prevent it. ★ AND IT IS WORSE THAN THE REVIEW COMMENT DESCRIBED, which only came out by writing the test that reproduces it. On a task that ALREADY HAD a holder, the blank did not merely fail to default — it silently CLEARED the existing one: assert '' == 'original-holder' A live lease whose owner was erased by a call that named nobody. The gap-filling-only rule the rest of this fix follows was not being applied at all on this path. Blank now normalises to None once, at the top of `task_update`, so every later branch agrees on what "provided" means rather than each re-deciding. Whitespace counts as blank: a holder is displayed to humans and matched by `_holder_matches`, and `" "` names nobody either. Not an error — the caller's intent (leave the assignee alone) is unambiguous and already expressible, so refusing would turn a harmless call into a failed one. Clearing an assignee is still deliberately not offered here; the docstring calls this "reassign to", and nothing has asked for an unassign path. Three regression tests, each confirmed to FAIL against the unfixed code before being trusted — the blank case, the whitespace case, and the does-not-clear-an-existing-holder case that exposed the worse behaviour. Full witan-council suite: 984 passed (was 981). `just docs-check` clean — no docstring changed, so the generated tool reference is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HzzSd29QeXvVYgMaPgRcBD
|
Addressed one more item in 7e1f7f6, from Copilot's suppressed comment on It was right, and the reproduction turned out worse than the comment described. A live lease whose owner was erased by a call that named nobody. Blank now normalises to Three regression tests, each confirmed to FAIL against the unfixed code before being trusted. Full witan-council suite: 984 passed (was 981). The |
|
Status correction — two earlier notes on this PR are now stale. The omnigraph pin was refreshed, contrary to my previous comment. That comment said "Not refreshing the pin here — that is a deliberate act and belongs in its own change." It was refreshed: It is not in this PR's diff. That commit landed on
Pin verification, for the record: all three tarballs downloaded and hashed locally and cross-checked against their published |
What are the relevant tickets?
N/A (tracked in the team's internal witan multi-user deployment project, task
tk-task-update-can-still-manufacture-an-unnameable--9ba86a)Description (What does it do?)
task_update(status="in_progress")stampedclaimed_at(deliberately — so "started" has one representation instead of reading as instantly and permanently free) but never touchedassignee. A caller that usedtask_updateinstead oftask_claimto mark work started — a common substitution, since "update the status" reads as the obvious verb and the docstring only preferredtask_claimrather than refusing — left the task at(in_progress, claimed_at set, assignee null).task_claimcorrectly refuses that combination and correctly cannot say who holds it, which is right once reached, but the state should not have been reachable through the normal tool surface at all.Not hypothetical — checked the deployed graph before writing any code: several
in_progresstasks across unrelated projects are in exactly this state right now (assignee: null,claimed_atset).Implements option (a) from the task's own analysis:
task_updatenow defaults a missingassigneevia_claim_holder— the same identitytask_claimwould use — whenstatus="in_progress"and no assignee is given or already recorded. Also adds asession_idparameter mirroringtask_claim's, so a deployed caller (no shared environment to infer a session id from) can qualify the default the same way. Deliberately only fills a gap: a task that already has an assignee keeps it, so a colleague marking someone else's taskin_progressto log status doesn't silently reassign it.Acceptance criteria from the task:
(in_progress, claimed_at set, assignee null)is unreachable through the tool surface — fixed as above.test_tasks.py: defaults a missing assignee, does not clobber an existing one, respects an explicit one, qualifies the default bysession_id, plus the existing regression test kept passing.witan_core.remote.proxy._map_argsalready auto-injectssession_idfor any tool declaring the parameter (the same generic mechanismtask_claimalready relies on, confirmed by readingRemoteMCPProxy._map_argsandwitan.remote.proxy.RemoteServerProxy's inheritance from it), so the CLI (witan task update) and any deployed-witan caller get it for free without their own code changing.assignee(readiness.status_pickable), so nothing is stuck; backfilling an assignee for another actor's already-in-progress work would mean guessing an identity there's no way to know correctly. Stated in the CHANGELOG entry and the code comment.How can this be tested?
just test-witan-council— 947 passed (942 existing + 5 new), no regressions.just docs-gen— regenerateddocs/reference/mcp-tools/tasks.mdfor the newsession_idparameter and the expandedstatusdocstring;just docs-checknow passes clean.ruff check/ruff format --checkclean on both changed Python files (pre-existing unrelated findings elsewhere inserver.pyleft untouched, confirmed none fall in the edited region).just check-versionspasses; version bumped 0.28.0 → 0.28.1 (patch — the new parameter is optional and backward compatible, no existing caller's behavior changes except the specific broken state this closes).Additional Context
Related but separate:
tk-an-agent-calling-the-deployed-witan-directly-mus-bbec12covers the samesession_id-for-a-deployed-agent gap ontask_claimitself (nottask_update) — not addressed here.