From c1a624182ca0477e54ca27f97f671f4133e65d99 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Fri, 10 Jul 2026 19:42:07 -0700 Subject: [PATCH] fix(kanban): allow respawn after explicit unblock --- hermes_cli/kanban_db.py | 19 +++++++++- tests/hermes_cli/test_kanban_db.py | 59 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 518e74eb0647..3d6b2518226e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -6795,8 +6795,10 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] ``"active_pr"`` A GitHub PR URL appears in a recent task comment (within - ``_RESPAWN_GUARD_PR_WINDOW`` seconds). A prior worker already - opened a PR; re-spawning risks a duplicate PR on the same task. + ``_RESPAWN_GUARD_PR_WINDOW`` seconds) after the most recent + explicit ``unblocked`` event, if any. A prior worker already + opened a PR for the current review round; re-spawning risks a + duplicate PR on the same task. Stale / dead claim locks are NOT a guard reason — they are handled by ``release_stale_claims`` and ``detect_crashed_workers`` which @@ -6879,7 +6881,20 @@ def check_respawn_guard(conn: sqlite3.Connection, task_id: str) -> Optional[str] return "recent_success" # 4. GitHub PR URL in a recent comment — prior worker already opened a PR. + # Ignore PR comments from before the latest explicit unblock: those belong + # to the previous review round and must not block legitimate rework on the + # same task/branch after operators resume it. pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW + last_unblocked = conn.execute( + "SELECT created_at FROM task_events " + "WHERE task_id = ? AND kind = 'unblocked' " + "ORDER BY created_at DESC, id DESC LIMIT 1", + (task_id,), + ).fetchone() + if last_unblocked is not None: + # Timestamps are second-granularity, so require comments to land after + # the unblock second to count as part of the resumed review round. + pr_cutoff = max(pr_cutoff, int(last_unblocked["created_at"]) + 1) for c in conn.execute( "SELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?", (task_id, pr_cutoff), diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index a9e61f41690a..bc8048fdfb22 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1944,6 +1944,41 @@ def test_respawn_guard_old_pr_comment_not_guarded(kanban_home): assert reason is None +def test_respawn_guard_ignores_pr_comment_before_unblock(kanban_home): + """A pre-unblock PR comment must not block the resumed review round.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="resume-pr", assignee="alice") + kb.claim_task(conn, t) + kb.add_comment( + conn, t, "worker", + "Opened https://github.com/totemx-AI/subsidysmart/pull/101", + ) + assert kb.block_task(conn, t, reason="review found regression") + assert kb.unblock_task(conn, t) + reason = kb.check_respawn_guard(conn, t) + assert reason is None + + +def test_respawn_guard_active_pr_after_unblock_still_blocks(kanban_home): + """A new PR comment after unblock still belongs to the current review round.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="resume-pr-new-comment", assignee="alice") + kb.claim_task(conn, t) + kb.add_comment( + conn, t, "worker", + "Opened https://github.com/totemx-AI/subsidysmart/pull/102", + ) + assert kb.block_task(conn, t, reason="review found regression") + assert kb.unblock_task(conn, t) + time.sleep(1.1) + kb.add_comment( + conn, t, "worker", + "Updated PR: https://github.com/totemx-AI/subsidysmart/pull/102", + ) + reason = kb.check_respawn_guard(conn, t) + assert reason == "active_pr" + + def test_dispatch_respawn_guard_defers_auth_error_without_auto_block( kanban_home, all_assignees_spawnable ): @@ -2039,6 +2074,30 @@ def fake_spawn(task, workspace): assert kb.get_task(conn, t).status == "ready" +def test_dispatch_respawn_guard_allows_resumed_pr_rework( + kanban_home, all_assignees_spawnable +): + """dispatch_once should respawn after unblock when only old PR comments exist.""" + spawned_ids = [] + + def fake_spawn(task, workspace): + spawned_ids.append(task.id) + + with kb.connect() as conn: + t = kb.create_task(conn, title="resume-round", assignee="alice") + kb.claim_task(conn, t) + kb.add_comment( + conn, t, "worker", + "Opened https://github.com/totemx-AI/subsidysmart/pull/103", + ) + assert kb.block_task(conn, t, reason="review found regression") + assert kb.unblock_task(conn, t) + res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + + assert (t, "active_pr") not in res.respawn_guarded + assert t in spawned_ids + + def test_dispatch_respawn_guard_dry_run_no_auto_block( kanban_home, all_assignees_spawnable ):