Skip to content

fix(witan): task_update can no longer leave an unnameable lease holder - #283

Merged
blarghmatey merged 4 commits into
mainfrom
fix-task-update-unnameable-holder
Aug 24, 2026
Merged

fix(witan): task_update can no longer leave an unnameable lease holder#283
blarghmatey merged 4 commits into
mainfrom
fix-task-update-unnameable-holder

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

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") stamped claimed_at (deliberately — so "started" has one representation instead of reading as instantly and permanently free) but never touched assignee. A caller that used task_update instead of task_claim to mark work started — a common substitution, since "update the status" reads as the obvious verb and the docstring only preferred task_claim rather than refusing — left the task at (in_progress, claimed_at set, assignee null). task_claim correctly 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_progress tasks across unrelated projects are in exactly this state right now (assignee: null, claimed_at set).

Implements option (a) from the task's own analysis: task_update now defaults a missing assignee via _claim_holder — the same identity task_claim would use — when status="in_progress" and no assignee is given or already recorded. Also adds a session_id parameter mirroring task_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 task in_progress to log status doesn't silently reassign it.

Acceptance criteria from the task:

  1. (in_progress, claimed_at set, assignee null) is unreachable through the tool surface — fixed as above.
  2. A test pins it — 5 new tests in test_tasks.py: defaults a missing assignee, does not clobber an existing one, respects an explicit one, qualifies the default by session_id, plus the existing regression test kept passing.
  3. Existing callers updated to the new contract — none needed. witan_core.remote.proxy._map_args already auto-injects session_id for any tool declaring the parameter (the same generic mechanism task_claim already relies on, confirmed by reading RemoteMCPProxy._map_args and witan.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.
  4. Rows already in this state — swept or left to their lease, decided and stated: left to their lease. A task without a live lease is reclaimable regardless of 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 — regenerated docs/reference/mcp-tools/tasks.md for the new session_id parameter and the expanded status docstring; just docs-check now passes clean.
  • ruff check / ruff format --check clean on both changed Python files (pre-existing unrelated findings elsewhere in server.py left untouched, confirmed none fall in the edited region).
  • just check-versions passes; 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-bbec12 covers the same session_id-for-a-deployed-agent gap on task_claim itself (not task_update) — not addressed here.

Copilot AI balanced review requested due to automatic review settings August 24, 2026 18:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents task_update(status="in_progress") from creating leases without a named holder.

Changes:

  • Defaults missing assignees using _claim_holder and optional session_id.
  • Adds regression coverage and regenerated tool documentation.
  • Bumps witan-council to 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_holder while 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.

Comment thread mcp/servers/witan/witan/server.py Outdated
Comment thread mcp/servers/witan/witan/server.py Outdated
blarghmatey added a commit that referenced this pull request Aug 24, 2026
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
blarghmatey and others added 2 commits August 24, 2026 14:42
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
@blarghmatey
blarghmatey force-pushed the fix-task-update-unnameable-holder branch from a9bef9d to 86b8f01 Compare August 24, 2026 18:46
@blarghmatey

Copy link
Copy Markdown
Member Author

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 witan-code (code graph), which fails on Install omnigraph binary — a checksum mismatch on the moving edge tag, unrelated to this diff (tracked separately by tk-automate-the-has-omnigraph-actually-released-yet-b031eb). The check that actually covers this change, witan (memory + CLI), passed cleanly — 981 tests, matching the local run. All review feedback addressed and resolved.

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
@blarghmatey

Copy link
Copy Markdown
Member Author

Addressed one more item in 7e1f7f6, from Copilot's suppressed comment on server.py:5392 — suppressed comments file no review thread, so it did not appear in the unresolved count and was not covered by the earlier pass.

It was right, and the reproduction turned out worse than the comment described. _claim_holder reads a blank assignee as missing (if assignee:) while the write path tested is not None, so an explicit assignee="" was written through AND skipped the in_progress default — reconstructing the unnameable-holder state through the very parameter meant to prevent it. And on a task that already had a holder, the blank silently cleared it:

assert '' == 'original-holder'

A live lease whose owner was erased by a call that named nobody.

Blank now normalises to None once at the top of task_update, so every later branch agrees on what "provided" means. Whitespace counts as blank (a holder is shown to humans and matched by _holder_matches). Not an error — the intent is unambiguous — and clearing an assignee is still deliberately not offered here.

Three regression tests, each confirmed to FAIL against the unfixed code before being trusted. Full witan-council suite: 984 passed (was 981). just docs-check clean — no docstring changed.

The witan-code (code graph) check is still red for the moving-edge checksum, unrelated to this diff. Investigated separately: the published digest matches CI's got exactly (tag moved, nothing corrupted), and the candidate build is #545, the Azure Blob storage preview — 82 files, +8904/-465, so a larger surface than the last refresh. Error-vocabulary check passes (9/11 classifier substrings non-zero and unchanged tree-wide; the 2 zeros are the runtime-assembled pair already documented in the pin comment), internal schema constants unchanged, and the s3 root path is behaviour-preserving. Not refreshing the pin here — that is a deliberate act and belongs in its own change.

@blarghmatey

Copy link
Copy Markdown
Member Author

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: deedb68, edge pin moved to bb0e3dc8bf (linux-x86_64 37b1333d…, linux-arm64 8c02e1c0…, macos-arm64 a272a783…), version and tag unchanged, all three tiers updated together.

It is not in this PR's diff. That commit landed on main directly — my error; the shared checkout had switched branches under me and I did not re-check before committing. main was then merged into this branch (fb3fcc6, no force-push) so the branch builds against the new pin, but the pin change itself is already on main and so does not appear in the #283 diff. This PR remains purely the task_update lease fix — six files, all under mcp/servers/witan plus the generated tool reference.

witan-code (code graph) is green. The first comment on this PR describes it as failing on the checksum mismatch; that is resolved. All 14 checks pass as of fb3fcc6. mergeable=MERGEABLE, blocked only on REVIEW_REQUIRED.

Pin verification, for the record: all three tarballs downloaded and hashed locally and cross-checked against their published .sha256 in the same sitting; upstream head, the edge ref and all asset timestamps read before and after and identical, so the triple describes one build; the install path was exercised for real with the existing binary moved aside so it could not skip; check_omnigraph_format.py reports storage format 6 as declared.

@blarghmatey
blarghmatey merged commit 3c72b93 into main Aug 24, 2026
17 checks passed
@blarghmatey
blarghmatey deleted the fix-task-update-unnameable-holder branch August 24, 2026 23:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants