From d2c6eceed98d2f276240553269f630c952e022c9 Mon Sep 17 00:00:00 2001 From: daixin1204 Date: Mon, 4 May 2026 20:18:40 +0800 Subject: [PATCH] fix(kanban): prevent child task dispatch when parent is not done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parent dependency guard to _set_status_direct so dragging a task to the ready column is rejected (409) when its parents are not all done. Previously the guard only existed in recompute_ready, allowing direct status writes via the dashboard API to bypass the dependency engine. Root cause: after reclaiming stale workers, both T3 and T4 were set to ready via dashboard status writes in quick succession, causing the writer to be spawned while the analyst was blocked — upstream work wasn't done yet. --- plugins/kanban/dashboard/plugin_api.py | 16 +++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 34 ++++++++++++++----- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 2b5bcd0dad..d1bd227253 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -662,6 +662,22 @@ def _set_status_direct( ).fetchone() if prev is None: return False + + # Guard: don't allow promoting to 'ready' unless all parents are done. + # Prevents the dispatcher from spawning a child whose upstream work + # hasn't completed (e.g. T4 dispatched while T3 is still blocked). + if new_status == "ready": + parent_statuses = conn.execute( + "SELECT t.status FROM tasks t " + "JOIN task_links l ON l.parent_id = t.id " + "WHERE l.child_id = ?", + (task_id,), + ).fetchall() + if parent_statuses and not all( + p["status"] == "done" for p in parent_statuses + ): + return False + was_running = prev["status"] == "running" cur = conn.execute( diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 580b187ecc..893e9f15cf 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -203,7 +203,10 @@ def test_patch_block_then_unblock(client): def test_patch_drag_drop_move_todo_to_ready(client): """Direct status write: the drag-drop path for statuses without a - dedicated verb (e.g. manually promoting todo -> ready).""" + dedicated verb (e.g. manually promoting todo -> ready). + + Promoting a child whose parent is not done is rejected (409). + Promoting a child whose parent IS done is accepted (200).""" parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"] child = client.post( "/api/plugins/kanban/tasks", @@ -211,12 +214,23 @@ def test_patch_drag_drop_move_todo_to_ready(client): ).json()["task"] assert child["status"] == "todo" + # Rejected: parent not done yet. r = client.patch( f"/api/plugins/kanban/tasks/{child['id']}", json={"status": "ready"}, ) + assert r.status_code == 409 + + # Complete the parent. + r = client.patch( + f"/api/plugins/kanban/tasks/{parent['id']}", + json={"status": "done"}, + ) assert r.status_code == 200 - assert r.json()["task"]["status"] == "ready" + + # Now child auto-promoted by recompute_ready — already ready. + child_after = client.get(f"/api/plugins/kanban/tasks/{child['id']}").json()["task"] + assert child_after["status"] == "ready" def test_patch_reassign(client): @@ -433,13 +447,17 @@ def test_board_progress_rollup(client): "/api/plugins/kanban/tasks", json={"title": "b", "parents": [parent["id"]]}, ).json()["task"] - # Children start as "todo" because the parent isn't done yet; promote - # them to "ready" so complete_task will accept the transition. + # Children start as "todo" because the parent isn't done yet. Set the + # parent to done so children auto-promote to ready via recompute_ready. + r = client.patch( + f"/api/plugins/kanban/tasks/{parent['id']}", + json={"status": "done"}, + ) + assert r.status_code == 200 + # Verify children are now ready. for cid in (child_a["id"], child_b["id"]): - r = client.patch( - f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"}, - ) - assert r.status_code == 200 + t = client.get(f"/api/plugins/kanban/tasks/{cid}").json()["task"] + assert t["status"] == "ready", f"{cid} should be ready after parent done" # 0/2 done. r = client.get("/api/plugins/kanban/board")