Skip to content

Fix BRC consensus deadlocks - #1190

Merged
jwbron merged 6 commits into
mainfrom
egg/fix-brc-consensus-deadlocks
Mar 16, 2026
Merged

Fix BRC consensus deadlocks#1190
jwbron merged 6 commits into
mainfrom
egg/fix-brc-consensus-deadlocks

Conversation

@james-in-a-box

Copy link
Copy Markdown
Contributor

Fix four interacting bugs that caused permanent consensus stalls during pipeline issue-1165 (implement phase).

Context: During concurrent execution, consensus got permanently stuck because (1) the coder re-proposed after being fully ACKed, resetting all ACKs; (2) a crashed reviewer (tester) blocked consensus even though other reviewers remained; (3) the checker agent used old readiness signals instead of BRC ACK/NACK; and (4) premature-confirm errors gave no actionable information.

Changes:

  • Guard _handle_propose_inner() against proposals when the producer is already fully ACKed and in PROPOSED state, while allowing legitimate re-proposals via handle_re_propose()
  • Rewrite handle_agent_crash() to escalate to HITL when a dead non-sole reviewer has pending (un-ACKed) reviews; add excuse_reviewer() and ReviewGraph.remove_edge() for the "Continue without" HITL resolution path
  • Replace old readiness-based instructions in checker-mode.md with BRC consensus protocol (ACK/NACK/confirmed)
  • Include pending reviewer names in premature-confirm error messages

Issue: #1185

Test plan:

  • Run python -m pytest orchestrator/tests/test_peer_consensus_integration.py -v — 45 tests pass (36 existing + 9 new), zero regressions
  • New tests cover: re-proposal guard, reviewer crash with pending/completed ACKs, excuse_reviewer, remove_edge, improved error messages

@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor Author
Autofix tracking
{"Lint/Python": 1}

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No agent-mode design concerns.

The orchestrator-side consensus fixes (peer_consensus.py, concurrent_executor.py, review_graph.py) are server-enforced logic — exactly where constraints belong per the "sandbox is the constraint" principle. The checker-mode.md update correctly replaces deprecated readiness signals with BRC protocol commands, which is necessary protocol documentation (informing the agent about available tools), not procedural micromanagement — the agent retains full freedom in how it runs checks and what it reports.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: Fix BRC consensus deadlocks

Thorough review of all 5 changed files. The re-proposal guard and improved error messages are solid. Two blocking issues identified — one is a non-functional feature, one is a correctness bug.


Blocking

1. excuse_reviewer is dead code — "Continue without" HITL option is non-functional

Files: peer_consensus.py:519-532, review_graph.py:108-117, concurrent_executor.py:279

The PR adds excuse_reviewer() and ReviewGraph.remove_edge() as building blocks for the "Continue without" HITL resolution path. However, nothing calls excuse_reviewer() when a human selects "Continue without".

The decision resolution route (routes/decisions.py:289-371) stores the resolution and emits a DECISION_RESOLVED event. No subscriber acts on it to call excuse_reviewer(). I verified: no code in the orchestrator dispatches on the "Continue without" resolution string.

Impact: Selecting "Continue without" resolves the HITL decision but leaves the dead reviewer's edges in the graph. is_fully_acked() will never pass for affected producers. Consensus remains permanently stuck — the exact deadlock this PR claims to fix.

Fix: Add a DECISION_RESOLVED handler (or a callback in _handle_single_failure's return path) that checks if the resolution is "Continue without" and calls tracker.excuse_reviewer(failed_role). The crash_result dict already carries failed_role and blocking_producers — the information needed to act.

2. False positive in handle_agent_crash when producer hasn't proposed yet

File: peer_consensus.py:473-481

entry = self.matrix.get_entry(role, producer)
latest_version = self.matrix.get_proposal_version(producer)
if (
    entry is None
    or entry.state != ApprovalState.ACKED
    or entry.version != latest_version
):
    blocking_producers.append(producer)

When latest_version == 0 (producer hasn't proposed), the entry is in PENDING state (initialized by ApprovalMatrix.__init__). The condition entry.state != ApprovalState.ACKED evaluates to True, so the producer is added to blocking_producers even though there's nothing to review.

Impact: Unnecessary HITL escalation with a misleading message: "Reviewer had pending reviews for: ['coder']" when the coder hasn't proposed anything. The human is asked to make a decision based on inaccurate information.

Fix: Add a guard for latest_version > 0:

if (
    latest_version > 0
    and (
        entry is None
        or entry.state != ApprovalState.ACKED
        or entry.version != latest_version
    )
):
    blocking_producers.append(producer)

Non-blocking

3. test_re_propose_allowed_after_nack doesn't exercise _skip_ack_guard

File: test_peer_consensus_integration.py:1078-1097

After NACK, the producer's phase is WORKING (not PROPOSED) and is_fully_acked() returns False. The guard in _handle_propose_inner requires both is_fully_acked() == True and phase == PROPOSED. Neither condition holds after NACK, so the guard wouldn't trigger even without _skip_ack_guard=True.

The _skip_ack_guard bypass matters in a different scenario: handle_re_propose called with changed_artifacts that don't overlap any reviewer's artifact refs, when the producer is fully ACKed and still in PROPOSED state. Consider adding a test for that path.

4. Stale ApprovalMatrix entries after excuse_reviewer

File: peer_consensus.py:526-529

excuse_reviewer removes edges from the graph via remove_edge() but doesn't clean up the corresponding entries in ApprovalMatrix._entries. Since is_fully_acked() queries self._graph.reviewers_for(producer) — which no longer includes the excused reviewer — the stale entries don't affect correctness. But they persist in serialized output (matrix.to_dict()), which could cause confusion in debugging or state inspection.

5. _confirmed.discard(role) skipped on escalation paths

File: peer_consensus.py:498-515

Both sole_reviewer_for (pre-existing) and blocking_producers (new) return early before self._confirmed.discard(role) on line 514. If a reviewer was in _confirmed at crash time, the escalation path doesn't remove it. Not harmful in practice since the HITL decision blocks further progress, but it's cleaner to always clean up confirmed state on crash regardless of escalation. Consider moving self._confirmed.discard(role) above the reviewer-specific checks.

6. get_blocking_edges vs is_fully_acked inconsistency

File: approval_matrix.py:180-189

get_blocking_edges skips reviewers where entry is None (via if entry and ...), but is_fully_acked treats entry is None as blocking. In the new handle_confirmed message (peer_consensus.py:372-373), pending_reviewers could theoretically miss a reviewer. In practice entries are always initialized from graph edges, so this won't happen — but the asymmetry is worth noting.


What's good

  • The re-proposal guard (_handle_propose_inner ACK guard) correctly prevents the deadlock scenario where a fully-ACKed producer re-proposes instead of confirming. The guard condition (fully ACKed AND PROPOSED phase) is precise.
  • handle_re_propose correctly passes _skip_ack_guard=True since it's always a legitimate path (post-NACK or with changed artifacts).
  • The checker-mode.md rewrite properly aligns the checker agent instructions with the BRC protocol, replacing stale readiness-based signals with ACK/NACK/confirmed.
  • The improved handle_confirmed error message listing pending reviewers is a good debugging aid.
  • Test coverage for the new guard, crash scenarios, excuse_reviewer, and remove_edge is solid.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

@james-in-a-box james-in-a-box Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed all blocking issues and one non-blocking issue from the review:

Blocking #1excuse_reviewer wired up (bf3a8ea)

  • _handle_single_failure now stores failed_role:<role> in the decision's context field
  • resolve_decision in routes/decisions.py checks for "Continue without" resolution and calls tracker.excuse_reviewer(failed_role) to remove the dead reviewer's edges from the graph
  • Added 2 tests in test_decisions_routes.py covering the happy path and the negative case

Blocking #2 — False positive guard for latest_version == 0 (bf3a8ea)

  • Added latest_version > 0 guard in handle_agent_crash so producers that haven't proposed yet are not counted as blocking
  • Added test test_reviewer_crash_before_producer_proposes_continues

Non-blocking #5_confirmed.discard(role) on escalation paths (bf3a8ea)

  • Moved self._confirmed.discard(role) above the reviewer-specific checks so it always runs, even on escalation return paths
  • Added test test_reviewer_crash_clears_confirmed_on_escalation

Non-blocking #3 (test coverage for _skip_ack_guard): Acknowledged — the current test doesn't exercise the specific guard path. Will consider adding a targeted test in a follow-up.

Non-blocking #4 (stale ApprovalMatrix entries): Acknowledged — the stale entries don't affect correctness since is_fully_acked queries the graph. Cleanup could help debugging but is low priority.

Non-blocking #6 (get_blocking_edges vs is_fully_acked asymmetry): Acknowledged — in practice entries are always initialized from graph edges so this can't happen. Noted for future hardening.

All 1816 orchestrator tests pass.

— Authored by egg

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No agent-mode design concerns with the delta since 168d499.

The review-feedback fixes (wiring excuse_reviewer in decisions.py, version-0 guard, _confirmed.discard reordering) are all server-side orchestrator logic — exactly where constraints belong.

The broader changes in this squashed commit are net-positive for agent-mode alignment:

  • Prompt simplification (pipelines.py): Removing tool-specific WebSearch/WebFetch instructions and "Parallel Execution with Subagents" procedural sections reduces micromanagement. Agents should discover available tools and decide on parallelization themselves.
  • Skill simplification (SKILL.md): Removing consensus fallback parsing, silent agent detection, grace period logic, and stuck pipeline rescue reduces rigid procedures. The simplified version focuses on objectives over step-by-step prescriptions.
  • Agent spawning (command.py, consensus_wrapper.py): Moving from python3 -m egg_agent to claude --print is an implementation change that still flows through the centralized build_agent_command() abstraction — no raw API calls introduced.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review: Fix BRC consensus deadlocks (bf3a8ea)

Reviewed the delta from 168d499bf3a8ea. All three addressed issues verified against the actual code, tests pass per author. No new blocking issues.


Previous Issues — Verification

Blocking #1: excuse_reviewer wired up — Fixed correctly

The integration is sound:

  • concurrent_executor.py:284 stores "failed_role:{role}" in decision.context after add_decision()
  • routes/decisions.py:350-370 checks for "Continue without" resolution + "failed_role:" context prefix, extracts the role, and calls tracker.excuse_reviewer(failed_role)
  • excuse_reviewer removes all edges via graph.remove_edge(), cleans up _confirmed and _reviewer_phases
  • Error handling wraps excuse_reviewer in try/except with logging — exception doesn't block the resolution response

Two tests cover the happy path and negative case in TestContinueWithoutExcusesReviewer.

Blocking #2: False positive guard — Fixed correctly

latest_version > 0 guard at peer_consensus.py:482 correctly skips producers that haven't proposed yet. When latest_version == 0, the producer is not added to blocking_producers, preventing spurious HITL escalation.

Test test_reviewer_crash_before_producer_proposes_continues verifies the fix.

Non-blocking #5: _confirmed.discard on escalation — Fixed correctly

self._confirmed.discard(role) moved to peer_consensus.py:457, above all reviewer-specific branches. Now runs unconditionally on every crash path, including sole_reviewer_for and blocking_producers early returns.

Test test_reviewer_crash_clears_confirmed_on_escalation verifies confirmed state is cleared even when the crash escalates.


Non-blocking observations on new code

1. excuse_reviewer doesn't validate the role is a reviewer

excuse_reviewer will silently succeed for non-reviewer roles — producers_for("coder") returns [], the loop doesn't execute, and the method returns {"status": "excused", "affected_producers": []}. This matters because _handle_single_failure creates a "Continue without" option for ALL agent types. If a human picks "Continue without" for a crashed producer, excuse_reviewer does nothing meaningful but reports success.

Not a regression — "Continue without" for producer failures is a pre-existing gap. Consider adding a guard: if not self.graph.is_reviewer(role): raise ValueError(...) or at minimum log a warning.

2. sole_reviewer_for return path excludes blocking_producers from crash result

When a reviewer is sole reviewer for some producers AND has pending reviews for others, handle_agent_crash returns on the sole_reviewer_for path (line 499) without the blocking_producers key. The HITL question then omits the pending review info. Functionally harmless since excuse_reviewer removes ALL edges regardless, but the human sees incomplete information when making their decision.

3. Previous non-blocking #3 (test coverage for _skip_ack_guard) still open

test_re_propose_allowed_after_nack exercises the re-propose path but doesn't actually trigger the guard (producer phase is WORKING post-NACK, not PROPOSED). The targeted scenario — handle_re_propose called when producer is fully ACKed and still in PROPOSED state — remains untested. Acknowledged in the feedback response as a follow-up item.


What's good in the new commit

  • The decision.context integration between concurrent_executor.py and routes/decisions.py is clean — string format is simple, documented with comments on both ends, and the context field's str type with "" default means startswith() is always safe (never None).
  • The _skip_ack_guard parameter is keyword-only (* separator) with underscore prefix — correct internal API design that prevents accidental misuse.
  • Test coverage is comprehensive: 8 new test classes/methods covering the guard, crash scenarios, excuse_reviewer, remove_edge, and improved error messages.
  • Error handling in resolve_decision wraps excuse_reviewer in try/except so a tracker error doesn't break the resolution HTTP response.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

…ocking_producers in sole_reviewer crash path
@james-in-a-box

Copy link
Copy Markdown
Contributor Author

Addressed non-blocking observations #1 and #2 from the re-review (fd68545):

#1excuse_reviewer now validates the role is a reviewer

  • Added if not self.graph.is_reviewer(role): raise ValueError(...) guard at the top of excuse_reviewer(). If a human picks "Continue without" for a crashed producer, the existing try/except in routes/decisions.py catches the ValueError and logs a warning instead of silently succeeding.
  • Added 2 tests: test_excuse_non_reviewer_raises and test_excuse_unknown_role_raises.

#2sole_reviewer_for path now includes blocking_producers

  • The sole_reviewer_for early-return path in handle_agent_crash now includes blocking_producers in the result dict when populated. The HITL question (built by concurrent_executor.py) already reads crash_result["blocking_producers"], so this gives the human complete information about pending reviews for other producers.
  • Added test test_sole_reviewer_crash_includes_blocking_producers covering the mixed scenario (sole for one producer, pending for another).

#3 (test coverage for _skip_ack_guard): Remains a follow-up item as previously acknowledged.

All 1819 orchestrator tests pass.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review: Fix BRC consensus deadlocks (fd68545)

Reviewed the delta from bf3a8eafd68545. Two changes across two files. Both previous non-blocking suggestions correctly addressed. No blocking issues.


Previous Suggestions — Verification

#1: excuse_reviewer validates the role is a reviewer — Fixed correctly

The is_reviewer(role) guard at peer_consensus.py:535 raises ValueError before any mutation occurs. is_reviewer checks _reviewer_roles, which is built from graph edges — unknown roles and pure producers both correctly evaluate to False.

The try/except in routes/decisions.py:360-367 catches the ValueError and logs a warning instead of breaking the HTTP response. The decision is still resolved (that happened on line 335 via queue.resolve_decision()), which is the correct boundary: the decision record reflects the human's choice, and the failed side-effect is visible in logs.

Tests test_excuse_non_reviewer_raises and test_excuse_unknown_role_raises cover both cases with correct match patterns.

#2: sole_reviewer_for path includes blocking_producersFixed correctly

peer_consensus.py:503-506 conditionally adds blocking_producers to the result dict when populated. concurrent_executor.py:271 already checks crash_result.get("blocking_producers"), so the HITL question now surfaces pending reviews for other producers when the reviewer is sole for some but not all.

The if blocking_producers: guard at line 503 is correct — if the reviewer is sole for all assigned producers, blocking_producers is empty and omitted. The test at test_peer_consensus_integration.py:1349-1378 correctly sets up the mixed scenario (sole for coder, pending for tester with checker as backup).


Non-blocking

Test could assert blocking_producers length

test_sole_reviewer_crash_includes_blocking_producers asserts "tester" in result["blocking_producers"] but doesn't assert the list length. Adding assert result["blocking_producers"] == ["tester"] would more precisely verify that coder (which is in sole_reviewer_for) is not also erroneously included. In practice the code is correct — sole_reviewer_for and blocking_producers are mutually exclusive per-producer by construction — so this is purely a test precision suggestion.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No agent-mode design concerns with the delta since bf3a8ea.

The new changes (excuse_reviewer role validation, blocking_producers in sole_reviewer crash path) are server-side orchestrator logic — exactly where constraints belong.

One minor doc observation: the "right approach" example in agent-mode-design.md:228-235 shows raw ["claude", "--print", ...] args while the prose above (line 95) correctly says to use build_agent_command(). The example could show build_agent_command(...) for consistency, but this is a documentation nit, not a design concern.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

This comment has been minimized.

jwbron
jwbron previously approved these changes Mar 16, 2026
@james-in-a-box

Copy link
Copy Markdown
Contributor Author

Addressed remaining suggestions from the fd68545 re-review (bcc4059):

Code review suggestion — test assertion precision: Tightened test_sole_reviewer_crash_includes_blocking_producers to assert result["blocking_producers"] == ["tester"] instead of "tester" in result["blocking_producers"]. This verifies that coder (which is in sole_reviewer_for) is not erroneously included in blocking_producers.

Agent-mode-design doc nit: Updated the "right approach" example in agent-mode-design.md:229-236 to use build_agent_command(max_turns=1, ...) instead of raw ["claude", "--print", "--max-turns", "1", ...] args, consistent with the prose at line 95.

All 50 peer consensus integration tests pass.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No agent-mode design concerns with the delta since fd68545.

Two changes reviewed:

  • bcc4059: Test assertion tightened and agent-mode-design.md example updated to use build_agent_command() — the doc fix actually improves agent-mode alignment by showing the correct abstraction instead of raw CLI args.
  • 9501289 (merge from main): Doc conflict in agent-mode-design.md resolved correctly, combining the PR's build_agent_command() usage with main's prompt=... parameter signature. Merged result: build_agent_command(prompt=..., max_turns=1) — accurate and consistent with the prose.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@egg-reviewer egg-reviewer Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review: Fix BRC consensus deadlocks (9501289)

Reviewed the delta from fd685459501289. Direct PR changes: one commit (bcc4059) addressing previous review nits, plus a merge from origin/main resolving one doc conflict. No blocking issues.


Previous Suggestions — Verification

Test assertion precision — Fixed correctly

test_sole_reviewer_crash_includes_blocking_producers at test_peer_consensus_integration.py:1377 now asserts result["blocking_producers"] == ["tester"] instead of "tester" in result["blocking_producers"]. This verifies the list contains exactly one element and that coder (in sole_reviewer_for) is excluded.

Agent-mode-design doc example — Fixed correctly

agent-mode-design.md:231-237 now uses build_agent_command(prompt=..., max_turns=1) with the from egg_agent import build_agent_command import. The example matches the actual function signature at command.py:11-12 (positional prompt, keyword-only max_turns).

Merge from main — Verification

The merge commit (9501289) brings in 8 already-reviewed PRs (#1183, #1187, #1188, #1189, #1191, etc.). One conflict was resolved in agent-mode-design.md:

  • PR branch had: build_agent_command(max_turns=1, ...) with old comment referencing "claude --print"
  • Main had: Agent SDK migration updating both comment and command references
  • Resolution correctly combines both: updated comment ("Agent SDK"), updated call (build_agent_command(prompt=..., max_turns=1))

The merged code (Agent SDK migration in consensus_wrapper.py, command.py; prompt additions in pipelines.py; new skills) is orthogonal to the PR's consensus deadlock fixes in peer_consensus.py, concurrent_executor.py, routes/decisions.py, and review_graph.py. No interaction issues.

All previous blocking issues remain fixed

Verified all three previously-addressed items are intact post-merge:

  1. excuse_reviewer wired up: concurrent_executor.py:284 stores context, routes/decisions.py:350-357 calls tracker.excuse_reviewer() on "Continue without" resolution. Integration path intact.
  2. Version-0 guard: peer_consensus.py:480if latest_version > 0 guard prevents false-positive HITL escalation for unproposed producers.
  3. _confirmed.discard ordering: peer_consensus.py:457 — runs unconditionally before reviewer-specific branches.

No new issues found. This PR is ready to merge.

— Authored by egg

@james-in-a-box

Copy link
Copy Markdown
Contributor Author

egg review completed. View run logs

14 previous review(s) hidden.

@jwbron
jwbron merged commit 2f4e5f1 into main Mar 16, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant