fix(kanban): active_pr respawn guard yields to deliberate follow-ups and dead PRs - #72555
fix(kanban): active_pr respawn guard yields to deliberate follow-ups and dead PRs#72555nikitaBarkov wants to merge 3 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tackling a real current-main respawn-guard limitation.
Problems
hermes_cli/kanban_db.py:7975requestsgh pr view --json state,merged, but the installed CLI rejectsmergedas an unknown JSON field. The resolver catches that failure and returnsNone, so configured live-state checks keepactive_preven for closed or merged PRs.gh pr view 72555 --json state,mergedAtsucceeds.hermes_cli/kanban_db.py:8009treats an equal integer-second timestamp as a later continuation. Current main writes both comment and event timestamps withint(time.time())(hermes_cli/kanban_db.py:3525,3822), in separate tables with no shared ordering key. A prior continuation and a later PR comment in the same second can therefore incorrectly bypass duplicate-PR protection.
Suggested changes
- Use supported
ghJSON fields and cover the real CLI response shape. - Persist or compare a reliable ordering marker, and test both same-second orderings.
Automated hermes-sweeper review.
| if shutil.which("gh"): | ||
| try: | ||
| proc = subprocess.run( | ||
| ["gh", "pr", "view", url, "--json", "state,merged"], |
There was a problem hiding this comment.
gh pr view --json does not expose a merged field: this invocation fails with Unknown JSON field: "merged", so the exception path returns None and a closed/merged PR remains guarded. Query supported fields such as state,mergedAt (or just state) and add coverage for the actual CLI response.
| placeholders = ",".join("?" for _ in _RESPAWN_GUARD_CLEAR_EVENT_KINDS) | ||
| row = conn.execute( | ||
| f"SELECT 1 FROM task_events " | ||
| f"WHERE task_id = ? AND created_at >= ? AND kind IN ({placeholders}) " |
There was a problem hiding this comment.
Equal integer-second timestamps do not prove this event occurred after the PR comment: comments and events are separate tables and both writers use int(time.time()). A prior unblock in the same second as a later PR link can bypass the live-PR guard. Use an ordering marker that establishes causality, or treat equality conservatively and test both same-second orders.
98e2519 to
632a08a
Compare
|
Pushed 1.
|
|
Pushed What was still brokenThis is the third scenario in #29458 (reported by @tuncbahreadingmaterial-ops, v0.18.2), and I verified it is not covered by this PR as it stood, nor by #46204 / #71606 — none of them can be, because all three of us only reason about signals that supersede the PR comment: A brand-new task with zero
And nothing ever clears it: an The fixBlock 4 now stands down when the task has no run history at all. Rationale in one line: the guard exists to stop a re-spawn from duplicating a PR a previous worker opened — with no run history there is no previous worker, so the link is inherited context, not ours. Two details that are deliberate:
Tests
Heads-up on a fixture change, since it touches existing tests rather than only adding new ones: the existing
Docs updated in the same commit ( Coverage of #29458 after this: dead/merged PR ✅ ( |
SummaryNine PRs address or reference the Related pull requests
Duplicates#62424 is a direct later duplicate of #46204's latest-unblock mechanism. #29492 overlaps the PR-state portions of #65948 and #72555; #42003 and #61196 are broad guard-disabling alternatives, while #71606 and #72555 extend the unblock family with distinct audited-resume, operator-clear, state-check, or first-spawn handling. Suggested consolidationKeep #72555 open with a salvage path: obtain contributor re-review of the corrected Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I29458(["issue #29458 (open)"])
I62418(["issue #62418 (open)"])
P72555["PR #72555 (open)"]
P72555 -->|best fix| I29458
P72555 -->|best fix| I62418
class I29458 open
class I62418 open
class P72555 open
class P72555 best
class P72555 best
class P72555 target
click I29458 "https://github.com/NousResearch/hermes-agent/issues/29458"
click I62418 "https://github.com/NousResearch/hermes-agent/issues/62418"
click P72555 "https://github.com/NousResearch/hermes-agent/pull/72555"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 9 pull requests and 2 issues in this complex. Each diff was read against this issue; Assessment working set: 162 kB of PR diffs, 34 kB of issue/PR text, 27 kB of discussion (24 comments), 23 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
Re-review requested — corrected head
|
dbfb4aa to
e99b01e
Compare
…and dead PRs The Kanban dispatcher's active_pr respawn guard blocked a task's respawn whenever a GitHub PR URL appeared in its comments within the last 24h. It matched the URL by regex only — it never checked the PR's real state, and there was no supported way to clear it. Deliberate follow-up / rework / unblock stayed parked behind the guard until the 24h window expired or someone edited the SQLite DB by hand. Rework block "4." of check_respawn_guard so it now: - picks the newest PR-URL comment (only the latest PR can be duplicated); - stands down when a fresher deliberate signal exists after that comment (unblocked / respawn_guard_cleared); - stands down when the PR is actually closed/merged (unknown state keeps the guard as a safe default); - otherwise still returns "active_pr", preserving duplicate-PR protection for the ordinary auto-respawn case. Also: - add a supported operator override `hermes kanban unguard` (+ /kanban), backed by the new public clear_respawn_guard() which records a respawn_guard_cleared event without touching status/claims; - add opt-in kanban.respawn_guard_check_pr_state (default off) gating the built-in gh-backed live PR-state check, so the dispatcher hot path pays nothing by default; - update the Kanban docs and add tests for every branch. Public signatures are preserved: check_respawn_guard(conn, task_id) still works (the new pr_state_resolver arg is keyword-only, default None), so the sole production caller (dispatch_once) is unchanged. Review follow-up (rebased onto current main): - Query gh for 'state,mergedAt', not 'state,merged'. 'merged' is not a 'gh pr view --json' field; gh exits 1 with 'Unknown JSON field' BEFORE looking at the PR, so the resolver returned None for every PR and the guard never stood down for a closed/merged one. Verified against gh 2.87.2. The resolver tests now go through a fake gh that validates the requested field names, so a plain JSON stub can no longer hide it. - Order the guard on a monotonic marker instead of whole-second timestamps. created_at is int(time.time()), so an unblock and a worker's PR comment can tie; '>=' resolved the tie as 'the unblock is fresher' and dropped duplicate-PR protection. clear_respawn_guard now stamps the task_comments rowid it was invoked against (cleared_through_comment_id) and the guard compares rowids for that path — order-free and exact. Other kinds keep the timestamp comparison but with '>', so a tie keeps guarding (safe default), with 'hermes kanban unguard' as the deterministic escape hatch. A PR link posted after an override re-arms the guard. Co-authored-by: Junie <junie@jetbrains.com>
Third scenario from NousResearch#29458, reported by @tuncbahreadingmaterial-ops and not covered by any of the open PRs: a task with zero task_runs still trips active_pr because its briefing comment quotes the *parent* task's PR URL. Blocks 1-3 of check_respawn_guard don't apply to a task that never ran, and block 4 looked only at task_comments, so the very first spawn was deferred forever - no unblock, no done->ready transition and no worker ever arrives to produce a fresher signal. The guard protects against a *re*-spawn duplicating a PR a previous worker opened. With no run history there is no previous worker, so the link cannot be ours and the guard stands down. Placed inside block 4 (not as an early return) so blocker_auth still fires for a spawn failure that stamped last_failure_error without creating a run. "Never ran" requires BOTH markers to be absent: no task_runs row and no 'spawned' event. task_runs alone is not enough - the pre-task_runs migration back-fills a synthetic run only for tasks that were 'running' at upgrade time, so a legacy task that had already opened a PR and gone back to 'ready' reads as zero-runs and would lose its duplicate-PR protection. The event marker is safe against gc_events (30-day retention) because the guard window is 24h. Tests: never-ran + live PR is not guarded (fails without this change); one prior run keeps guarding; a 'spawned' event with no run row keeps guarding. Existing active_pr tests described "a worker already opened a PR" while creating a task with no runs at all - an impossible state - so they now record the finished run their scenario implies. The ready-lane control in the review-lane regression test (a235d19) had the same shape - a task with a fresh PR comment and no runs at all - and now records a prior run as well, so it exercises the re-spawn its docstring describes while still pinning the lane contract. Co-authored-by: Junie <junie@jetbrains.com>
e99b01e to
c5b1636
Compare
|
Status refresh, since the sweeper review above is still the last one on this PR and predates the fixes it asked for. Both review findings were fixed and are still in the head. They were correct; I verified each against real behaviour before changing anything (details in the two comments above):
The branch has been rebased twice since ( On overlap with # 4. GitHub PR URL in a recent comment — prior worker already opened a PR.
pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW
for c in conn.execute(
"SELECT body FROM task_comments WHERE task_id = ? AND created_at >= ?",
(task_id, pr_cutoff),
).fetchall():
if c["body"] and _RESPAWN_GUARD_PR_URL_RE.search(c["body"]):
return "active_pr"No PR state, no clear path, no run history. One adaptation the rebase needed, called out because it edits an upstream test rather than adding one: |
…awn guard The active_pr respawn guard stands down when a deliberate continuation signal is recorded after the newest PR-URL comment, but the signal list only held 'unblocked' and 'respawn_guard_cleared'. The review lane's two rework verdicts were missing: request_changes (reviewer sends the card back to the implementer) and reopen_review_task (the handoff is reopened into a landing status). Both hand the card back precisely because of the PR that is already in its comments, so every review-driven rework sat guarded on each dispatcher tick until the 24h window expired or an operator ran 'hermes kanban unguard'. Add both kinds to _RESPAWN_GUARD_CLEAR_EVENT_KINDS and cover them with tests that drive the real review flow (claim -> request_review -> claim_review_task -> request_changes / reopen_review_task) rather than injecting synthetic events.
What does this PR do?
The Kanban dispatcher's
active_prrespawn guard (block "4." ofcheck_respawn_guardinhermes_cli/kanban_db.py) blocked a task's respawn whenever a GitHub PR URL appeared in its comments within the last 24h. It matched the URL by regex only — it never checked the PR's real state (open/closed/merged), and there was no supported way to clear it. So a deliberate follow-up / rework / unblock stayed parked behind the guard until the 24h window expired or someone edited the SQLite DB by hand.This reworks the guard so it blocks only the unintended duplicate-PR auto-respawn it was built for, while yielding to deliberate continuation and to PRs that are no longer live:
unblocked(fromunblock_task),respawn_guard_cleared(from the newclear_respawn_guard), or a review-lane rework verdict:changes_requested(request_changes) andreview_reopened(reopen_review_task). Both review verdicts hand the card back to the implementer because of the PR already in its comments, so treating that PR as a duplicate risk parked every review-driven rework until the 24h window expired.closed/merged. Unknown state keeps the guard (safe default — no false "all clear" whenghis unavailable).unblock/re-queue signal ever arrives to clear it."active_pr", so ordinary auto-respawn on a live PR is still de-duplicated exactly as before.It also adds a supported operator override and an opt-in live PR-state check.
Related Issue
Addresses #62418 (guard blocks legitimate rework after unblock, no bypass short of manual claim+spawn) and #29458 (no operator clear-path; ignores closed PRs).
Type of Change
Changes Made
hermes_cli/kanban_db.py— rework block "4." ofcheck_respawn_guard; add helpers_has_fresh_continuation_signal,_resolve_github_pr_state(cachedghlookup, 5-min TTL),_resolve_pr_state_check_enabled; add the publicclear_respawn_guard()(emits arespawn_guard_clearedevent, no status/claim mutation). Newpr_state_resolverarg is keyword-only (back-compat).hermes_cli/kanban.py— newunguardverb (parser + dispatch + handler) and/kanbanhelp entry.hermes_cli/config.py— opt-inkanban.respawn_guard_check_pr_state(defaultFalse) gating the built-ingh-backed live PR-state check.website/docs/user-guide/features/kanban.md— rewrote the Respawn-guard section, documentedunguard, added therespawn_guard_clearedevent.tests/hermes_cli/test_kanban_db.py,tests/hermes_cli/test_kanban_cli.py— tests for every branch (see below). The review-verdict tests drive the real lane (claim_task→request_review→claim_review_task→request_changes/reopen_review_task) instead of injecting synthetic events.How to Test
Run the touched suites:
→ 491 tests pass, 0 failed.
Behavioral coverage:
"active_pr"(dup protection intact).unblock/hermes kanban unguard→ guard yields (None) on the next dispatcher tick.closed/merged(via injectedpr_state_resolver/ opt-ingh) → guard yields; unknown state → guard held.request_changes/reopen-reviewon a card whose comments hold an open PR → guard yields, so review-driven rework re-spawns on the next tick.Checklist
Code
fix(kanban):)test_kanban_db,test_kanban_cli,test_config— 491 passed, 0 failed)Documentation & Housekeeping
website/docs/user-guide/features/kanban.md)DEFAULT_CONFIGinhermes_cli/config.py(opt-in, documented inkanban.md)CONTRIBUTING.md/AGENTS.mdghlookup degrades safely to "unknown" (guard held) whenghis missing/unauthenticated, and it is off by defaulthermes kanban unguardverb +/kanbanhelp entry