Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/reference/mcp-tools/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ lease and refuses if someone else holds it); to close a task prefer
| `title` | str? | `null` | New short label for the work. |
| `description` | str? | `null` | New full description. Replaces the existing text; it is not appended to. |
| `type` | `bug` \| `feature` \| `task` \| `chore` \| `epic`? | `null` | ``bug`` \| ``feature`` \| ``task`` \| ``chore`` \| ``epic``. |
| `status` | `open` \| `in_progress` \| `blocked` \| `closed`? | `null` | ``open`` \| ``in_progress`` \| ``blocked`` \| ``closed``. Two values have<br>side effects: ``in_progress`` stamps a fresh ``claimed_at`` lease, and<br>``closed`` stamps ``closed_at`` **and unblocks this task's dependents**,<br>exactly as ``task_close`` does. Prefer ``task_claim`` / ``task_close``<br>for those two transitions — they carry the ownership checks this does<br>not. |
| `status` | `open` \| `in_progress` \| `blocked` \| `closed`? | `null` | ``open`` \| ``in_progress`` \| ``blocked`` \| ``closed``. Two values have<br>side effects: ``in_progress`` stamps a fresh ``claimed_at`` lease, and<br>``closed`` stamps ``closed_at`` **and unblocks this task's dependents**,<br>exactly as ``task_close`` does. Prefer ``task_claim`` / ``task_close``<br>for those two transitions — they carry the ownership checks this does<br>not.<br>Setting ``in_progress`` with no ``assignee`` on a task that has none<br>recorded defaults it via ``_claim_holder`` — the same identity<br>``task_claim`` would use — rather than leaving<br>``(in_progress, claimed_at set, assignee null)`` reachable: that<br>combination is a live lease with nobody named on it, which<br>``task_claim`` correctly refuses and correctly cannot say who holds.<br>A task that already has an assignee keeps it; this only fills a gap,<br>never reassigns. |
| `priority` | `p0` \| `p1` \| `p2` \| `p3`? | `null` | ``p0`` (highest) … ``p3``. Drives ``task_ready`` ordering. |
| `repo` | str? | `null` | Canonical repo URI to (re)assign this task to. Pass an explicit value to<br>correct tasks that were created without proper repo context. |
| `assignee` | str? | `null` | Holder identity to reassign the task to. Prefer ``task_claim`` to take a<br>task for yourself — it checks nobody else holds it, which this does not. |
Expand All @@ -102,6 +102,7 @@ lease and refuses if someone else holds it); to close a task prefer
| `external_uri` | str? | `null` | Reference URI — e.g. the GitHub issue or PR this task tracks. |
| `symbol_refs` | list[str]? | `null` | Code-graph symbol ids (``repo#path::Name``) this task concerns.<br>Replaces the existing list. |
| `tags` | list[str]? | `null` | Free-form tags. Replaces the existing list rather than merging into it. |
| `session_id` | str? | `null` | The calling agent session's id, used only to qualify the ``assignee``<br>this defaults when ``status="in_progress"`` leaves one to fill in —<br>see ``task_claim``'s ``session_id`` for why a deployed caller needs to<br>pass this explicitly (no shared environment to infer it from). Has no<br>effect when an explicit ``assignee`` is given, or when one already<br>exists on the task. Appended after every other parameter, not placed<br>near ``assignee``, so inserting it cannot shift what an existing<br>positional caller's later arguments bind to. |

## `task_claim`

Expand Down
46 changes: 46 additions & 0 deletions mcp/servers/witan/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/) (pre-1.0:
a MINOR bump may include breaking changes).

## [0.29.1] - 2026-08-24

### Fixed

- **`task_update(status="in_progress")` could stamp a lease with nobody named
as holder.** It set `claimed_at` — deliberately, so "started" has one
representation rather than reading as instantly and permanently free — but
never touched `assignee`, so a caller that used it instead of `task_claim`
to mark work started (a common substitution: "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 state and correctly cannot say
who holds it — which is exactly right once reached, but the state should
not have been reachable through the normal tool surface in the first
place. Live-checked against the deployed graph: several tasks across
unrelated projects are in it right now, so this was not a one-off.

`task_update` now defaults a missing `assignee` the same way `task_claim`
does — via `_claim_holder`, qualified by a new `session_id` parameter for
a deployed caller the same way `task_claim`'s already is — but only to
fill a genuine gap: a task that already has an assignee keeps it, so
marking someone else's task `in_progress` to log status does not silently
reassign it. The fill is resolved by `_update_task` itself, against the
read it takes immediately before writing — not from a value `task_update`
precomputed off an earlier, separate read — so a real `task_claim` landing
in between cannot be silently overwritten by a stale default. Existing
rows already in the broken state are left to their lease rather than
swept — a `readiness.status_pickable` task without a live lease is
reclaimable regardless of `assignee`, and backfilling one for another
actor's already-in-progress work would mean guessing an identity there is
no way to know correctly.

A blank `assignee` is now normalised to "not provided" rather than written
through. `_claim_holder` had always read one that way (`if assignee:`)
while the write path tested `is not None`, so the two disagreed and an
explicit `assignee=""` slipped past both halves of the fix above — the
blank was written AND the default declined to fill it, reconstructing the
same unnameable-holder state through the very parameter meant to prevent
it. Worse, on a task that already had a holder the blank silently
*cleared* it: a live lease whose owner was erased by a call that named
nobody. Blank now means "leave the assignee alone", so it fills a gap like
any other missing value and never clears one; whitespace counts as blank,
since a holder is displayed to humans and matched by `_holder_matches` and
`" "` names nobody either. Clearing an assignee is still deliberately not
offered here.

## [0.29.0] - 2026-08-24

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions mcp/servers/witan/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "witan-council"
version = "0.29.0"
version = "0.29.1"
description = "witan — agent memory, planning, and collaboration graph (work-coordination layer + umbrella CLI)"
readme = "README.md"
license = "BSD-3-Clause"
Expand Down Expand Up @@ -213,7 +213,7 @@ packages = ["witan"]
"schema" = "schema"

[tool.bumpversion]
current_version = "0.29.0"
current_version = "0.29.1"
allow_dirty = true

[[tool.bumpversion.files]]
Expand Down
149 changes: 149 additions & 0 deletions mcp/servers/witan/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,155 @@ def test_task_update_to_in_progress_stamps_claimed_at(server):
assert node["claimed_at"] is not None


@requires_omnigraph
def test_task_update_to_in_progress_defaults_a_missing_assignee(server):
# tk-task-update-can-still-manufacture-an-unnameable--9ba86a: this used to
# stamp claimed_at without ever setting assignee, reaching exactly the
# state task_claim correctly refuses and correctly cannot name a holder
# for — a live lease with nobody on record.
from witan import server as srv

t = server.task_create(title="marked started, no assignee given", description="x")
server.task_update(t["slug"], status="in_progress")
node = server.task_get(t["slug"])
assert node["assignee"] == srv._current_author()


@requires_omnigraph
def test_task_update_to_in_progress_does_not_clobber_an_existing_assignee(server):
# Only fills a GAP. A colleague marking someone else's task in_progress to
# log status must not silently reassign it to themselves.
t = server.task_create(title="already held", description="x")
server.task_claim(t["slug"], assignee="original-holder")

server.task_update(t["slug"], status="in_progress")

assert server.task_get(t["slug"])["assignee"] == "original-holder"


@requires_omnigraph
def test_task_update_to_in_progress_respects_an_explicit_assignee(server):
t = server.task_create(title="explicit assignee", description="x")
server.task_update(t["slug"], status="in_progress", assignee="explicit-holder")
assert server.task_get(t["slug"])["assignee"] == "explicit-holder"


@requires_omnigraph
def test_task_update_to_in_progress_treats_a_blank_assignee_as_missing(server):
# The gap the first fix left, caught by Copilot as a suppressed review
# comment on #283 — which files no thread, so it did not show up in the
# unresolved count and was nearly missed.
#
# `_claim_holder` reads a blank assignee as missing (`if assignee:`) while
# the write path tested `is not None`, so an explicit "" was written
# straight through AND skipped the default: claimed_at stamped, no
# nameable holder. Exactly the state this task exists to make
# unrepresentable, reachable through the parameter meant to prevent it.
from witan import server as srv

t = server.task_create(title="blank assignee, marked started", description="x")
server.task_update(t["slug"], status="in_progress", assignee="")
node = server.task_get(t["slug"])
assert node["assignee"] == srv._current_author()
assert node["claimed_at"]


@requires_omnigraph
def test_task_update_to_in_progress_treats_a_whitespace_assignee_as_missing(server):
# Whitespace names nobody either, and a holder is both shown to humans and
# matched by _holder_matches, so " " must not survive as an identity.
from witan import server as srv

t = server.task_create(title="whitespace assignee", description="x")
server.task_update(t["slug"], status="in_progress", assignee=" ")
assert server.task_get(t["slug"])["assignee"] == srv._current_author()


@requires_omnigraph
def test_task_update_blank_assignee_does_not_clear_an_existing_holder(server):
# Blank means "not provided", not "unassign". A task that already has a
# holder keeps it — the same gap-filling-only rule the non-blank path
# follows, and the reason this normalises to None rather than erroring.
t = server.task_create(title="held, blank passed", description="x")
server.task_claim(t["slug"], assignee="original-holder")

server.task_update(t["slug"], status="in_progress", assignee="")

assert server.task_get(t["slug"])["assignee"] == "original-holder"


@requires_omnigraph
def test_task_update_defaulted_assignee_is_qualified_by_session_id(server):
# Same qualification task_claim applies — see
# test_caller_supplied_session_id_beats_the_server_environment — so a
# deployed caller marking its own work in_progress via task_update,
# without a session in the server's own environment to fall back to,
# still gets a holder two of its own parallel sessions cannot collide on.
from witan import server as srv

t = server.task_create(title="deployed caller", description="x")
server.task_update(
t["slug"],
status="in_progress",
session_id="cccccccc-9999-0000-1111-222222222222",
)
node = server.task_get(t["slug"])
assert node["assignee"] == f"{srv._current_author()}#cccccccc"


@requires_omnigraph
def test_update_task_default_if_missing_does_not_override_a_real_assignee(server):
"""`_update_task`'s `default_if_missing` checks the row's CURRENT value —
read by this same call, not decided earlier by the caller (see its
docstring: a caller that precomputed the value off its own separate read
could hand back a stale default that clobbers a real assignment made in
between). A task already claimed for real must keep that assignee even
when a `default_if_missing["assignee"]` factory is supplied.
"""
from witan import server as srv

t = server.task_create(title="already claimed", description="x")
server.task_claim(t["slug"], assignee="real-claimant")

srv._update_task(
t["slug"],
{"status": "in_progress"},
default_if_missing={"assignee": lambda: "stale-default"},
)

assert server.task_get(t["slug"])["assignee"] == "real-claimant"


@requires_omnigraph
def test_update_task_default_if_missing_fills_a_genuine_gap(server):
from witan import server as srv

t = server.task_create(title="never claimed", description="x")

srv._update_task(
t["slug"],
{"status": "in_progress"},
default_if_missing={"assignee": lambda: "filled-in"},
)

assert server.task_get(t["slug"])["assignee"] == "filled-in"


@requires_omnigraph
def test_update_task_default_if_missing_never_overrides_an_explicit_change(server):
from witan import server as srv

t = server.task_create(title="explicit wins", description="x")

srv._update_task(
t["slug"],
{"status": "in_progress", "assignee": "explicit"},
default_if_missing={"assignee": lambda: "should-not-be-used"},
)

assert server.task_get(t["slug"])["assignee"] == "explicit"


@requires_omnigraph
def test_unleased_recent_in_progress_is_not_ready_or_claimable(server):
"""A task moved to in_progress with no assignee on record (e.g. a legacy row
Expand Down
Loading
Loading