Skip to content
12 changes: 10 additions & 2 deletions orchestrator/action_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,13 +424,20 @@ def check_confirm_guard(

# --- Reviewer confirmation guards ---
if is_reviewer:
# Exclude two classes of producer from the four reviewer guards
# Exclude three classes of producer from the four reviewer guards
# below (has-reviewed / zero-proposal / stale-ACK / stale-NACK /
# unresolved-NACK):
#
# * Generic no-op producers (#3027): the producer declared it has
# no work, so there is nothing for the reviewer to review and it
# must not block the reviewer's confirm.
# * Wake-only producers (#3381 / #3382-review): the de-roled
# simplifier carries an advisory edge over the upstream producer
# PURELY as the event-pump wake-wire and casts no verdict, so it
# can never satisfy a has-reviewed guard on that edge. Excluding
# it here lets the simplifier confirm its companion without a
# verdict it will never cast — matching the pre-PR timing where
# its (now-removed) advisory ACK cleared the guard.
# * Already-CONFIRMED producers (#3043): a producer only reaches
# the confirmed set after its CRITICAL reviewers have ACKed it,
# so its work is settled. A reviewer still carrying a missing
Expand All @@ -447,10 +454,11 @@ def check_confirm_guard(
# advance_phase(force=true) tripping #2806) are tracked
# separately in #3051; this filter is the tactical root-cause
# fix only and intentionally does not patch the recovery path.
wake_only = graph.wake_only_producers_for(agent_role)
producers = [
p
for p in graph.producers_for(agent_role)
if not matrix.is_no_changes_proposal(p) and p not in confirmed
if not matrix.is_no_changes_proposal(p) and p not in confirmed and p not in wake_only
]

# Guard 1: Must have reviewed all producers
Expand Down
69 changes: 56 additions & 13 deletions orchestrator/review_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,34 @@ class ReviewEdge:
reviewer_role: str
producer_role: str
criticality: ReviewCriticality = ReviewCriticality.CRITICAL
# A "wake-only" edge is an advisory edge the reviewer NEVER votes on.
# It models the de-roled simplifier (#3381): a producer of the
# human-focused companion that retains an advisory edge over the
# upstream refine/plan producer but issues no ACK/NACK on it.
#
# It does NOT drive the wake. The simplifier is woken to write its
# companion by the ordinary producer **propose-arm**: a WORKING
# producer re-derives ``propose`` on every event-loop poll, and a clean
# orient-and-exit is a *legitimate* outcome that frees the spawn-dedupe
# key, so the orchestrator re-spawns it until it proposes (see
# ``test_event_loop.py::test_stale_exit_is_a_non_trigger_through_loop``).
# The simplifier self-gates on the upstream draft existing, so it
# orients-and-exits until the draft is committed, then proposes.
#
# ``wake_only`` exists only to NEUTRALIZE the residual advisory edge so
# it carries no review obligation: it is excluded from pending-review
# derivation (so the de-roled reviewer is never assigned a spawn-able
# ``ack`` it cannot satisfy — the regression #3381 fixed) and from the
# reviewer confirm guards (so it can confirm without a verdict it will
# never cast). A wake-only edge is always ADVISORY.
wake_only: bool = False

def to_dict(self) -> dict[str, str]:
def to_dict(self) -> dict[str, Any]:
return {
"reviewer_role": self.reviewer_role,
"producer_role": self.producer_role,
"criticality": self.criticality.value,
"wake_only": self.wake_only,
}


Expand Down Expand Up @@ -98,6 +120,19 @@ def advisory_reviewers_for(self, producer: str) -> list[str]:
if e.producer_role == producer and e.criticality == ReviewCriticality.ADVISORY
]

def wake_only_producers_for(self, reviewer: str) -> set[str]:
"""Producers a reviewer reaches via a wake-only edge.

A wake-only edge drives the event-pump wake-wire but carries no
review obligation — the reviewer never casts a verdict on it
(#3381, the de-roled simplifier). These producers must be excluded
from pending-review derivation and from the reviewer confirm guards
so the de-roled reviewer is never assigned an ``ack`` it can no
longer satisfy, and can confirm without a verdict it will never
cast.
"""
return {e.producer_role for e in self._edges if e.reviewer_role == reviewer and e.wake_only}

def get_edge(self, reviewer: str, producer: str) -> ReviewEdge | None:
"""Get a specific review edge."""
for e in self._edges:
Expand Down Expand Up @@ -179,6 +214,7 @@ def from_dict(cls, data: dict[str, Any]) -> ReviewGraph:
reviewer_role=e["reviewer_role"],
producer_role=e["producer_role"],
criticality=ReviewCriticality(e.get("criticality", "critical")),
wake_only=bool(e.get("wake_only", False)),
)
for e in data.get("edges", [])
]
Expand All @@ -203,14 +239,18 @@ def get_default_refine_graph() -> ReviewGraph:
ReviewEdge("reviewer_agent_design", "refiner", ReviewCriticality.CRITICAL),
# The simplifier produces the human-focused analysis companion
# (faithful + jargon-free), gated CRITICAL by reviewer_refine.
# It is DUAL-ROLE — like the implement-phase tester — and carries
# an ADVISORY review edge over the refiner so the BRC ``ack`` arm
# re-invokes it when the refiner proposes (the proven wake-up the
# spawn-dedupe key relies on; a pure producer's first-propose key
# is constant and would never re-spawn). Advisory => the
# simplifier's verdict never blocks the refiner's consensus.
# It is a PRODUCER ONLY (#3381): the propose-arm wakes it to
# write the companion (it self-gates on the refiner's draft
# existing), and it casts no verdict on anyone. It retains a
# WAKE-ONLY advisory edge over the refiner only as a structural
# marker; wake_only carries no review obligation — it excludes
# the edge from pending-review derivation and the confirm guards
# so the simplifier is never derived a spawn-able ``ack`` it
# cannot satisfy and can confirm its own companion without ever
# voting on the refiner. (See the ReviewEdge.wake_only docstring
# for why the propose-arm, not this edge, is the wake.)
ReviewEdge("reviewer_refine", "simplifier", ReviewCriticality.CRITICAL),
ReviewEdge("simplifier", "refiner", ReviewCriticality.ADVISORY),
ReviewEdge("simplifier", "refiner", ReviewCriticality.ADVISORY, wake_only=True),
]
)

Expand Down Expand Up @@ -253,12 +293,15 @@ def get_default_plan_graph() -> ReviewGraph:
# risk_analyst reviews task_planner (critical — risk lens, #2809)
ReviewEdge("risk_analyst", "task_planner", ReviewCriticality.CRITICAL),
# The simplifier produces the human-focused plan companion,
# gated CRITICAL by reviewer_plan. Dual-role like the refine-phase
# simplifier: an ADVISORY edge over task_planner re-invokes it via
# the ``ack`` arm when task_planner proposes (the tester wake-up
# pattern). Advisory => it never blocks task_planner's consensus.
# gated CRITICAL by reviewer_plan. Wake-only like the refine-phase
# simplifier: a PRODUCER ONLY (#3381) woken to write the companion
# by the propose-arm (self-gating on task_planner's draft), casting
# no verdict. It retains a WAKE-ONLY advisory edge over task_planner
# only as a structural marker; wake_only excludes it from
# pending-review derivation and the confirm guards (see the refine
# graph above and the ReviewEdge.wake_only docstring).
ReviewEdge("reviewer_plan", "simplifier", ReviewCriticality.CRITICAL),
ReviewEdge("simplifier", "task_planner", ReviewCriticality.ADVISORY),
ReviewEdge("simplifier", "task_planner", ReviewCriticality.ADVISORY, wake_only=True),
]
)

Expand Down
13 changes: 13 additions & 0 deletions orchestrator/routes/consensus.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,26 @@ def _has_pending_peer_proposals(
producers = tracker.graph.producers_for(reviewer)
if not producers:
return False, []
wake_only = tracker.graph.wake_only_producers_for(reviewer)
pending: list[dict[str, Any]] = []
for producer in producers:
# Skip self-reviews (a dual-role agent reviewing its own producer
# role); BRC does not require self-review and including it would
# let a dual-role agent block on itself.
if producer == reviewer:
continue
# Skip wake-only edges (#3381 / #3382-review): the de-roled
# simplifier carries an advisory edge over the upstream producer
# PURELY as the event-pump wake-wire and casts no verdict. Treating
# it as a pending review would derive a spawn-able ``ack`` for the
# entire window the upstream is PROPOSED, re-invoking an agent that
# can no longer satisfy the event (and risks re-PROPOSING the
# companion, invalidating the companion reviewer's ACK). The edge
# wakes the simplifier on the upstream's PROPOSE; it never obliges a
# review. This restores the pre-PR self-terminating wake-wire
# without reintroducing a verdict.
if producer in wake_only:
continue
# Skip a generic no-op proposal (#3027): the producer declared it
# has no work in this slice, so there is nothing to review and the
# reviewer must not NACK it (that was the empty-proposal deadlock).
Expand Down
Loading
Loading