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
7 changes: 6 additions & 1 deletion plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,12 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
ok = _set_status_direct(conn, task_id, "ready")
elif s == "archived":
ok = kanban_db.archive_task(conn, task_id)
elif s in ("todo", "running", "triage"):
elif s == "running":
raise HTTPException(
status_code=400,
detail="Cannot set status to 'running' directly; use the dispatcher/claim path",
)
elif s in ("todo", "triage"):
ok = _set_status_direct(conn, task_id, s)
else:
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
Expand Down
27 changes: 27 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,33 @@ def test_patch_invalid_status(client):
assert r.status_code == 400


def test_patch_status_running_rejected(client):
"""Dashboard PATCH cannot transition a task directly to 'running'.

The only legitimate path into 'running' is through the dispatcher's
``claim_task`` — which atomically creates a ``task_runs`` row,
claim_lock, expiry, and worker-PID metadata. Allowing a direct set
creates orphaned 'running' tasks with no run row or claim, which
violate the board's run-history invariants. See issue #19535.
"""
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "running"},
)
assert r.status_code == 400
assert "running" in r.json()["detail"]
# Task's status should still be its pre-request value — the direct-set
# was rejected before any mutation.
board = client.get("/api/plugins/kanban/board").json()
statuses = {
tt["id"]: col["name"]
for col in board["columns"]
for tt in col["tasks"]
}
assert statuses.get(t["id"]) != "running"


# ---------------------------------------------------------------------------
# Comments + Links
# ---------------------------------------------------------------------------
Expand Down
Loading