From 917e51858dd0cd8f74846ec50399bd9c425ccc08 Mon Sep 17 00:00:00 2001 From: Drexuxux <279217086+Drexuxux@users.noreply.github.com> Date: Mon, 18 May 2026 20:17:21 -0700 Subject: [PATCH] fix(kanban): demote ready children when a parent is reopened --- plugins/kanban/dashboard/plugin_api.py | 35 ++++++++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 37 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index b06482a29b4..d8ad71e9e24 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -722,6 +722,10 @@ def _set_status_direct( return False was_running = prev["status"] == "running" + reopening_satisfied_parent = ( + prev["status"] in {"done", "archived"} + and new_status not in {"done", "archived"} + ) cur = conn.execute( "UPDATE tasks SET status = ?, " @@ -745,6 +749,37 @@ def _set_status_direct( "VALUES (?, ?, 'status', ?, ?)", (task_id, run_id, json.dumps({"status": new_status}), int(time.time())), ) + if reopening_satisfied_parent: + # A parent leaving done/archived invalidates any direct child that + # was sitting in ready solely because that parent used to satisfy + # the dependency gate. Demote those children immediately so the + # dashboard does not keep advertising stale-ready work. + for row in conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (task_id,), + ).fetchall(): + child_id = row["child_id"] + demoted = conn.execute( + "UPDATE tasks SET status = 'todo' " + "WHERE id = ? AND status = 'ready'", + (child_id,), + ) + if demoted.rowcount == 1: + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'status', ?, ?)", + ( + child_id, + json.dumps( + { + "status": "todo", + "reason": "parent_reopened", + "parent": task_id, + } + ), + int(time.time()), + ), + ) # If we re-opened something, children may have gone stale. if new_status in {"done", "ready"}: kanban_db.recompute_ready(conn) diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 21e9b483048..d9bae401c34 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -308,6 +308,43 @@ def test_patch_drag_drop_move_todo_to_ready(client): assert child_after["status"] == "ready" +def test_reopening_parent_demotes_ready_child(client): + """Reopening a completed parent must invalidate ready children immediately. + + The dispatcher re-checks parent completion on claim, but the dashboard + should not keep showing a stale child as ready after an operator drags + its parent back out of done for more work. + """ + parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "c", "parents": [parent["id"]]}, + ).json()["task"] + assert child["status"] == "todo" + + r = client.patch( + f"/api/plugins/kanban/tasks/{parent['id']}", + json={"status": "done"}, + ) + assert r.status_code == 200 + + child_after_done = client.get( + f"/api/plugins/kanban/tasks/{child['id']}" + ).json()["task"] + assert child_after_done["status"] == "ready" + + r = client.patch( + f"/api/plugins/kanban/tasks/{parent['id']}", + json={"status": "todo"}, + ) + assert r.status_code == 200 + + child_after_reopen = client.get( + f"/api/plugins/kanban/tasks/{child['id']}" + ).json()["task"] + assert child_after_reopen["status"] == "todo" + + def test_patch_reassign(client): t = client.post( "/api/plugins/kanban/tasks",