Skip to content

Commit e11c13b

Browse files
committed
fix: route review blockers by owner boundary
1 parent 0a9ba08 commit e11c13b

2 files changed

Lines changed: 145 additions & 21 deletions

File tree

hermes_cli/kanban_db.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3132,6 +3132,33 @@ def edit_completed_task_result(
31323132
return True
31333133

31343134

3135+
_REVIEW_REBASE_BLOCKER_MARKERS = (
3136+
"rebase-required",
3137+
"branch-staleness",
3138+
"merge-conflict",
3139+
"merge conflict",
3140+
"conflict-resolution",
3141+
"conflict resolution",
3142+
"mergeable_state=dirty",
3143+
"mergeable state dirty",
3144+
"not current with latest origin/main",
3145+
"origin/main advanced",
3146+
"branch needs a rebase",
3147+
)
3148+
3149+
3150+
def _review_block_is_rebase_or_conflict(reason: Optional[str]) -> bool:
3151+
"""Return True for Review-origin blockers that belong in Blocked.
3152+
3153+
Normal Review cycles (Bugbot findings, requested tests, PR-body/check
3154+
cleanup) should go back to the implementation assignee in Ready. Blocked
3155+
is reserved for branch-staleness / rebase / merge-conflict work that the
3156+
self-recovery lane can repair and then hand back to Review.
3157+
"""
3158+
text = (reason or "").casefold()
3159+
return any(marker in text for marker in _REVIEW_REBASE_BLOCKER_MARKERS)
3160+
3161+
31353162
def block_task(
31363163
conn: sqlite3.Connection,
31373164
task_id: str,
@@ -3142,10 +3169,11 @@ def block_task(
31423169
) -> bool:
31433170
"""Transition ``running -> blocked``.
31443171
3145-
When the required Review gate is enabled and the active run started from
3146-
the Review column, ``kanban_block`` means "Bugbot/review rejected this PR".
3147-
In that case the task is automatically returned to the original
3172+
When the active run started from the Review column, ``kanban_block`` is
3173+
usually a review-cycle rejection and the task is returned to the original
31483174
implementation assignee in ``ready`` instead of getting stuck in Blocked.
3175+
The exception is a branch-staleness / rebase / merge-conflict blocker;
3176+
those remain sticky-blocked for the narrow self-recovery handoff lane.
31493177
"""
31503178
with write_txn(conn):
31513179
task_row = conn.execute(
@@ -3155,26 +3183,33 @@ def block_task(
31553183
if not task_row:
31563184
return False
31573185
run_for_gate = expected_run_id if expected_run_id is not None else task_row["current_run_id"]
3158-
review_rejection = review_gate_enabled() and _run_started_from_review(
3186+
review_origin = _run_started_from_review(
31593187
conn, task_id, int(run_for_gate) if run_for_gate else None
31603188
)
3161-
handoff = _latest_review_handoff(conn, task_id) if review_rejection else {}
3189+
review_rebase_blocker = review_origin and _review_block_is_rebase_or_conflict(reason)
3190+
review_rejection = review_origin and not review_rebase_blocker
3191+
handoff = _latest_review_handoff(conn, task_id) if review_origin else {}
31623192
implementation_assignee = handoff.get("implementation_assignee")
31633193
effective_metadata = metadata
3164-
if review_rejection:
3194+
if review_origin:
31653195
if effective_metadata is None:
31663196
effective_metadata = {}
31673197
else:
31683198
effective_metadata = dict(effective_metadata)
3169-
effective_metadata.setdefault("review_rejection", True)
3170-
if implementation_assignee:
3171-
effective_metadata.setdefault("implementation_assignee", implementation_assignee)
3199+
if review_rebase_blocker:
3200+
effective_metadata.setdefault("review_rebase_blocker", True)
3201+
if implementation_assignee:
3202+
effective_metadata.setdefault("implementation_assignee", implementation_assignee)
31723203
else:
3173-
effective_metadata.setdefault("missing_implementation_assignee", True)
3174-
effective_metadata.setdefault(
3175-
"review_rejection_reason",
3176-
"Review-origin block could not be returned to an implementation assignee because the latest Review handoff did not record one.",
3177-
)
3204+
effective_metadata.setdefault("review_rejection", True)
3205+
if implementation_assignee:
3206+
effective_metadata.setdefault("implementation_assignee", implementation_assignee)
3207+
else:
3208+
effective_metadata.setdefault("missing_implementation_assignee", True)
3209+
effective_metadata.setdefault(
3210+
"review_rejection_reason",
3211+
"Review-origin block could not be returned to an implementation assignee because the latest Review handoff did not record one.",
3212+
)
31783213
if review_rejection and implementation_assignee:
31793214
if expected_run_id is None:
31803215
cur = conn.execute(
@@ -3255,7 +3290,11 @@ def block_task(
32553290
summary=reason,
32563291
metadata=effective_metadata,
32573292
)
3258-
payload = {"reason": reason}
3293+
payload: dict[str, Any] = {"reason": reason}
3294+
if review_rebase_blocker:
3295+
payload["review_rebase_blocker"] = True
3296+
if implementation_assignee:
3297+
payload["implementation_assignee"] = implementation_assignee
32593298
if review_rejection:
32603299
payload["review_rejection"] = True
32613300
if implementation_assignee:
@@ -5549,6 +5588,17 @@ def _default_spawn(
55495588
prompt = f"work kanban task {task.id}"
55505589
env = dict(os.environ)
55515590

5591+
# Review-gate policy is board/root-level process state, not profile-local
5592+
# persona state. Workers switch HERMES_HOME to their assignee profile
5593+
# below, so copy the dispatcher's effective review config into env first;
5594+
# otherwise a merge-captain profile missing the kanban stanza can treat
5595+
# Review-column blocks as sticky Blocked and skip the Review protocol text.
5596+
env.setdefault(
5597+
"HERMES_KANBAN_REQUIRE_REVIEW_BEFORE_DONE",
5598+
"1" if review_gate_enabled() else "0",
5599+
)
5600+
env.setdefault("HERMES_KANBAN_MERGE_CAPTAIN_PROFILE", merge_captain_profile())
5601+
55525602
# Inject HERMES_HOME so the worker reads the profile-scoped config.yaml
55535603
# (fallback_providers, toolsets, agent settings, etc.) instead of the root
55545604
# config. Without this, `env = dict(os.environ)` copies only the parent's
@@ -5813,8 +5863,8 @@ def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str:
58135863
lines.append(f"Branch: {task.branch_name}")
58145864
lines.append("")
58155865

5816-
if review_gate_enabled():
5817-
review_run = _run_started_from_review(conn, task_id, task.current_run_id)
5866+
review_run = _run_started_from_review(conn, task_id, task.current_run_id)
5867+
if review_gate_enabled() or review_run:
58185868
if review_run:
58195869
handoff = _latest_review_handoff(conn, task_id)
58205870
implementation_assignee = handoff.get("implementation_assignee") or "(unknown)"
@@ -5826,9 +5876,11 @@ def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str:
58265876
)
58275877
lines.append(
58285878
f"Original implementation assignee: {implementation_assignee}. If Bugbot "
5829-
"finds issues or the branch needs a rebase because another PR merged, call "
5830-
"kanban_block with the exact required fixes/rebase instructions; Hermes will "
5831-
"automatically return the card to that assignee in Ready."
5879+
"finds issues or normal review changes are needed, call kanban_block with "
5880+
"the exact required fixes; Hermes will automatically return the card to "
5881+
"that assignee in Ready. Only use a rebase/merge-conflict/blocker reason "
5882+
"when the branch is stale or conflicted; those cases may enter Blocked for "
5883+
"narrow self-recovery and then return to Review."
58325884
)
58335885
lines.append(
58345886
"Only call kanban_complete after the PR is clean, up to date, merged, and the "

tests/hermes_cli/test_kanban_db.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1928,6 +1928,12 @@ def test_dispatcher_spawn_injects_kanban_db_and_workspaces_root(
19281928
# `-p <profile>` flag rewrites HERMES_HOME.
19291929
default_home = tmp_path / ".hermes"
19301930
default_home.mkdir()
1931+
(default_home / "config.yaml").write_text(
1932+
"kanban:\n"
1933+
" require_review_before_done: true\n"
1934+
" merge_captain_profile: merge-captain\n",
1935+
encoding="utf-8",
1936+
)
19311937
self._set_home(monkeypatch, tmp_path, default_home)
19321938

19331939
captured = {}
@@ -1967,6 +1973,8 @@ def __init__(self, cmd, **kwargs):
19671973
)
19681974
assert env["HERMES_KANBAN_TASK"] == "t_dispatch_env"
19691975
assert env["HERMES_KANBAN_BRANCH"] == "wt/t_dispatch_env"
1976+
assert env["HERMES_KANBAN_REQUIRE_REVIEW_BEFORE_DONE"] == "1"
1977+
assert env["HERMES_KANBAN_MERGE_CAPTAIN_PROFILE"] == "merge-captain"
19701978

19711979

19721980
# ---------------------------------------------------------------------------
@@ -3108,7 +3116,7 @@ def test_required_review_gate_block_returns_to_original_worker(kanban_home, monk
31083116
assert kb.block_task(
31093117
conn,
31103118
t,
3111-
reason="Bugbot found issue; rebase after recent merge and fix failing test.",
3119+
reason="Bugbot found issue; fix failing test and update the PR body.",
31123120
expected_run_id=review.current_run_id,
31133121
)
31143122
task = kb.get_task(conn, t)
@@ -3124,6 +3132,70 @@ def test_required_review_gate_block_returns_to_original_worker(kanban_home, monk
31243132
assert any(e.kind == "review_rejected" for e in events)
31253133

31263134

3135+
def test_required_review_gate_rebase_block_stays_blocked_for_recovery(
3136+
kanban_home, monkeypatch
3137+
):
3138+
monkeypatch.setenv("HERMES_KANBAN_REQUIRE_REVIEW_BEFORE_DONE", "1")
3139+
monkeypatch.setenv("HERMES_KANBAN_MERGE_CAPTAIN_PROFILE", "merge-captain")
3140+
with kb.connect() as conn:
3141+
t = kb.create_task(conn, title="stale PR", assignee="worker-a")
3142+
impl = kb.claim_task(conn, t)
3143+
assert impl is not None
3144+
assert kb.complete_task(conn, t, summary="PR ready", expected_run_id=impl.current_run_id)
3145+
review = kb.claim_review_task(conn, t)
3146+
assert review is not None
3147+
assert kb.block_task(
3148+
conn,
3149+
t,
3150+
reason=(
3151+
"rebase-required: PR is exact-head clean, but origin/main "
3152+
"advanced and GitHub reports mergeable_state=dirty."
3153+
),
3154+
expected_run_id=review.current_run_id,
3155+
)
3156+
task = kb.get_task(conn, t)
3157+
events = kb.list_events(conn, t)
3158+
run = kb.latest_run(conn, t)
3159+
3160+
assert task is not None
3161+
assert run is not None
3162+
assert task.status == "blocked"
3163+
assert task.assignee == "merge-captain"
3164+
assert run.outcome == "blocked"
3165+
assert run.status == "blocked"
3166+
assert (run.metadata or {}).get("review_rebase_blocker") is True
3167+
assert events[-1].kind == "blocked"
3168+
assert (events[-1].payload or {}).get("review_rebase_blocker") is True
3169+
3170+
3171+
def test_review_origin_block_routes_even_when_profile_config_lacks_gate(
3172+
kanban_home, monkeypatch
3173+
):
3174+
monkeypatch.setenv("HERMES_KANBAN_REQUIRE_REVIEW_BEFORE_DONE", "1")
3175+
monkeypatch.setenv("HERMES_KANBAN_MERGE_CAPTAIN_PROFILE", "merge-captain")
3176+
with kb.connect() as conn:
3177+
t = kb.create_task(conn, title="profile gap", assignee="worker-a")
3178+
impl = kb.claim_task(conn, t)
3179+
assert impl is not None
3180+
assert kb.complete_task(conn, t, summary="PR ready", expected_run_id=impl.current_run_id)
3181+
review = kb.claim_review_task(conn, t)
3182+
assert review is not None
3183+
monkeypatch.setenv("HERMES_KANBAN_REQUIRE_REVIEW_BEFORE_DONE", "0")
3184+
assert kb.block_task(
3185+
conn,
3186+
t,
3187+
reason="Bugbot found an issue; fix it and resubmit.",
3188+
expected_run_id=review.current_run_id,
3189+
)
3190+
task = kb.get_task(conn, t)
3191+
events = kb.list_events(conn, t)
3192+
3193+
assert task is not None
3194+
assert task.status == "ready"
3195+
assert task.assignee == "worker-a"
3196+
assert events[-1].kind == "review_rejected"
3197+
3198+
31273199
def test_required_review_gate_block_without_implementation_assignee_is_explicit(
31283200
kanban_home, monkeypatch,
31293201
):

0 commit comments

Comments
 (0)