Skip to content
Open
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
19 changes: 17 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both add_comment() and _append_event() persist int(time.time()) timestamps, so adding + 1 excludes a PR comment written after the unblock within the same second. That permits the duplicate-work case this guard is meant to prevent. Keep this cutoff inclusive at the unblock second and add a deterministic same-second regression test.

for c in conn.execute(
"SELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?",
(task_id, pr_cutoff),
Expand Down
59 changes: 59 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down Expand Up @@ -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
):
Expand Down
Loading