From 827abceb4263249723ef805bdb6874014b76e111 Mon Sep 17 00:00:00 2001 From: luyao618 <364939526@qq.com> Date: Mon, 4 May 2026 14:47:13 +0800 Subject: [PATCH 1/2] fix(kanban): reject direct status transition to 'running' via dashboard API The PATCH /tasks/:id endpoint allows setting status='running' via _set_status_direct(), bypassing the dispatcher/claim path that creates run rows, claim locks, expiry, and worker process metadata. This can leave tasks stuck in 'running' with no active worker. Fix: reject status='running' with HTTP 400, requiring all transitions to 'running' to go through the canonical claim_task() path. Closes #19535 --- plugins/kanban/dashboard/plugin_api.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index d80296b888a8..1c25f372e61f 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -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}") From cec12bd5b40de6901b333a881241ddb0df9a7514 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 4 May 2026 04:46:26 -0700 Subject: [PATCH 2/2] test(kanban): regression for status=running rejection at dashboard PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporter of #19535 explicitly asked for a regression test — covers it here so a future refactor of _set_status_direct can't silently re-enable the direct ready/todo -> running bypass. Asserts both: (a) HTTP 400 with 'running' in the detail message, and (b) the task's status is unchanged after the rejected PATCH (pre-request status preserved, no partial mutation). --- tests/plugins/test_kanban_dashboard_plugin.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 4bbc621f1aa9..0055fc80f048 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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 # ---------------------------------------------------------------------------