@@ -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+
31353162def 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 "
0 commit comments