Enforce consensus protocol for concurrent agents - #1092
Conversation
Agents running in concurrent mode were exiting before participating in consensus, causing the orchestrator to hit its fallback exit-code path and killing other agents mid-work. Three complementary fixes: 1. Restructure agent prompts with a consensus lifecycle preamble that frames the agent's job as the full lifecycle (bootstrap, execute, signal, stay alive, wait for stop) rather than just the task. 2. Wrap the Claude CLI invocation in a shell script that auto-signals READY and polls for consensus if Claude exits early, keeping the container alive as a safety net. 3. Auto-register READY in the consensus evaluator when a container exits cleanly (code 0) without having signaled, preventing early exits from blocking consensus indefinitely.
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Lint/Python": 1, "Test/Security Scan": 1} |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review: Enforce consensus protocol for concurrent agents
Good approach — defense in depth with prompt-level instructions, a shell wrapper safety net, and server-side implicit READY. The layering is sound. However, there is one blocking correctness issue in the wrapper that can cause a crashed agent to be treated as successful.
Blocking: Wrapper signals READY on non-zero Claude exit
File: orchestrator/consensus_wrapper.py, lines 28-33
The wrapper auto-signals READY regardless of Claude's exit code:
egg-orch signal readiness --state READY \
--reason "Agent process exited (code $CLAUDE_EXIT), auto-signaling READY" \
2>/dev/null || trueThis is correct for exit code 0 (clean exit, agent simply forgot to signal). But for non-zero exits (crash, OOM, error), signaling READY is wrong — the agent's work may be incomplete or corrupted.
Why this is blocking: The wrapper keeps the container alive for up to 5 minutes after Claude exits. During this window, the orchestrator sees the container as RUNNING (not exited), so handle_agent_failure never runs. Meanwhile, the consensus evaluator shows this agent as READY. If all other agents are also READY, consensus is reached at line 4672 of pipelines.py. The has_failures[0] guard at line 4694 only catches failures from containers that have already exited — but the wrapper is keeping the container alive, so the non-zero exit is invisible.
Result: A crashed agent causes the phase to complete as success.
The server-side implicit READY logic (lines 4767-4794) correctly guards on exit_code == 0. The wrapper should do the same:
if [ "$CLAUDE_EXIT" -ne 0 ]; then
echo "[consensus-wrapper] Agent failed (code $CLAUDE_EXIT). NOT signaling READY."
exit $CLAUDE_EXIT
fi
echo "[consensus-wrapper] Agent exited cleanly. Auto-signaling READY..."
egg-orch signal readiness --state READY \
--reason "Agent process exited cleanly, auto-signaling READY" \
2>/dev/null || trueThe polling loop should also be gated on clean exit — there's no point keeping a failed container alive for 5 minutes polling for consensus.
Non-blocking suggestions
1. Coordinator-spawned agents lose prompt-level consensus instructions
routes/coordinator.py lines 335-341: The PR removes the old prompt-appended consensus reminder:
# Old (removed):
agent_prompt += (
"\n\nIMPORTANT: When your work is complete, signal readiness:\n"
...
)But coordinator-spawned agents don't go through _build_agent_prompt(concurrent=True), so they don't get the new 5-step lifecycle preamble either. They rely entirely on CLAUDE.md's general concurrent mode section + the wrapper safety net. This works in practice (CLAUDE.md covers it, wrapper catches stragglers), but it's a specificity regression. Consider either routing coordinator spawns through _build_agent_prompt(concurrent=True) or keeping a shorter prompt-level reminder.
2. Hardcoded 5-minute wrapper timeout vs configurable orchestrator timeout
The wrapper hardcodes TIMEOUT=300 (5 minutes), while the orchestrator's consensus_timeout_minutes is configurable (default 30, minimum 1). If the orchestrator timeout is shorter than 5 minutes, the wrapper outlives the orchestrator's timeout. This is mitigated by the orchestrator stopping containers on timeout, but making the wrapper timeout configurable (or sourced from an env var) would be cleaner:
TIMEOUT="${EGG_CONSENSUS_WRAPPER_TIMEOUT:-300}"3. Test gap: no test for non-zero exit + wrapper behavior
test_consensus_wrapper.py tests command construction but not the wrapper's behavior on non-zero exits. Consider adding a test that verifies the wrapper does NOT signal READY when Claude exits non-zero (once the fix above is applied).
4. Roles without prompts in _run_concurrent_phase
Lines 4462-4478 build prompts for only CODER, TESTER, DOCUMENTER, but get_agent_roles() returns 6 roles (also CHECKER, REVIEWER_CODE, REVIEWER_CONTRACT). The 3 missing roles get command=None and no consensus wrapper. This is pre-existing behavior (not introduced by this PR), but since the PR is addressing consensus enforcement, it's worth noting that these 3 roles still have no wrapper safety net and no concurrent preamble. They'll rely entirely on CLAUDE.md + the server-side implicit READY on clean exit.
Looks good
- The
concurrentparameter on_build_agent_promptis clean and well-integrated - The 5-step lifecycle preamble in the prompt is comprehensive and clear
- The Phase Completion rewrite for concurrent mode gives agents actionable instructions
- Server-side implicit READY (
pipelines.pylines 4767-4794) correctly guards on exit code 0 shlex.quote()usage in the wrapper prevents shell injection- Test coverage for prompt lifecycle, wrapper construction, and implicit READY is solid
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Thanks for the thorough review. Here's how I addressed each item:
Blocking: Wrapper signals READY on non-zero Claude exit — Fixed
The wrapper now checks CLAUDE_EXIT immediately after the concurrent-mode gate. On non-zero exit, it logs the failure and exits without signaling READY or entering the polling loop. This ensures crashed agents are visible to handle_agent_failure and don't silently satisfy consensus.
Non-blocking #1: Coordinator-spawned agents lose prompt-level consensus instructions
Acknowledged. This is a specificity regression but works in practice since CLAUDE.md covers the protocol and the wrapper provides a safety net. I'll leave this as-is for now — routing coordinator spawns through _build_agent_prompt(concurrent=True) is a good follow-up but would expand the scope of this PR.
Non-blocking #2: Hardcoded 5-minute wrapper timeout — Fixed
The wrapper timeout is now configurable via EGG_CONSENSUS_WRAPPER_TIMEOUT env var, defaulting to 300s.
Non-blocking #3: Test gap for non-zero exit + wrapper behavior — Fixed
Added three tests: test_nonzero_exit_does_not_signal_ready, test_clean_exit_signals_ready, and test_timeout_configurable_via_env_var.
Non-blocking #4: Roles without prompts in _run_concurrent_phase
Acknowledged. Pre-existing behavior not introduced by this PR. Agree it's worth noting but out of scope here.
— Authored by egg
There was a problem hiding this comment.
Re-review: Fix wrapper exit code handling
The blocking issue from the previous review — wrapper signaling READY on non-zero exit — is correctly fixed. The non-blocking suggestions (configurable timeout, new tests) are also addressed.
Blocking issue: Resolved
The wrapper now gates on $CLAUDE_EXIT -ne 0 before the READY signal, exiting immediately with the non-zero code. This means the container exits promptly, the orchestrator detects it via its polling loop, and handle_agent_failure fires correctly (line ~4756 of pipelines.py). The has_failures[0] guard then propagates the failure even if other agents reach consensus. The fix is consistent with the server-side implicit READY logic which already guards on exit_code == 0.
Non-blocking: Test quality
The three new tests (test_nonzero_exit_does_not_signal_ready, test_clean_exit_signals_ready, test_timeout_configurable_via_env_var) are string-matching assertions against the generated script text, not behavioral tests. The previous review suggested "a test that verifies the wrapper does NOT signal READY when Claude exits non-zero" — these verify the script contains the right strings but don't exercise the actual bash logic. This is consistent with the existing test pattern in this file, so it's acceptable, but worth noting that if someone refactors the script text while preserving semantics, these tests will break. A subprocess-based test that runs the wrapper with a failing command and asserts egg-orch signal readiness is never called would be stronger. Not blocking.
Verified
build_consensus_wrapped_commandis the sole entrypoint for bothConcurrentPhaseExecutor._spawn_agentandcoordinator.pyspawn route — both callers get the fix automatically.set -uo pipefailis correct; all env var references use${VAR:-default}to avoid unset-variable errors.shlex.quote()usage for prompt escaping is unchanged and correct.- The wrapper's
exit $CLAUDE_EXITon non-zero correctly propagates the original exit code to the container runtime.
No blocking issues found.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
Agreed. Added behavioral subprocess-based tests (
The existing string-matching tests are kept alongside since they serve as a fast structural check. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: Behavioral tests and merge conflict resolution
All issues from both prior reviews are resolved. The new behavioral tests are well-constructed.
Previous issues: Verified
- Blocking (wrapper READY on non-zero exit): Confirmed fixed —
$CLAUDE_EXIT -ne 0gate exits immediately, no READY signal, no polling loop. - Configurable timeout: Uses
EGG_CONSENSUS_WRAPPER_TIMEOUTenv var with 300s default. - String-matching test gap: Addressed with behavioral subprocess tests (see below).
New code: Behavioral tests
TestConsensusWrapperBehavior exercises the actual bash wrapper via subprocess.run with a mock egg-orch that logs invocations. Four tests cover the critical paths:
test_nonzero_exit_does_not_call_readiness: Verifiesfalse(exit 1) produces noegg-orchcalls at all — confirmed via log file absence.test_clean_exit_calls_readiness: Verifiestrue(exit 0) invokesegg-orch signal readiness --state READY— confirmed via log file contents.test_nonzero_exit_propagates_exit_code: Verifies(exit 42)produces return code 42 from the wrapper.test_non_concurrent_mode_skips_consensus: Verifies noegg-orchcalls withoutEGG_CONCURRENT_MODE=true.
The test design is solid: temp directories for isolation, PATH prepending for the mock, short timeouts (EGG_CONSENSUS_WRAPPER_TIMEOUT=2, EGG_MESSAGE_POLL_INTERVAL=1) to keep tests fast, and the mock returns is_complete: true so clean-exit tests don't block on polling.
The # nosec B103 suppression on os.chmod(mock_path, 0o755) is appropriate — test helper creating an executable in a temp directory.
Verified
set -uo pipefailworks correctly with${VAR:-default}patterns throughout the template — no unset variable risks.- No
set -e, which is intentional: the wrapper must captureCLAUDE_EXIT=$?after a potentially failing command. - Merge conflict resolution is clean — no inconsistencies introduced across
consensus_wrapper.py,pipelines.py,coordinator.py, or tests. - Both callers (
ConcurrentPhaseExecutor._spawn_agentandcoordinator.pyspawn route) usebuild_consensus_wrapped_command, so the exit-code fix applies universally.
No blocking issues found.
— Authored by egg
…n concurrent_executor.py, coordinator.py
This comment has been minimized.
This comment has been minimized.
Conflict Resolution SummaryResolved merge conflicts with
Conflict DetailsBoth conflicts stem from the same root cause: main merged #1088 (Agent SDK migration) which introduced Resolution rationale: The PR's consensus wrapper is a superset of main's approach — it wraps the Claude invocation in a bash script that auto-signals READY and polls for consensus if Claude exits early. Main's approach relied on appending a text reminder to the prompt asking the agent to stay alive. The consensus wrapper internally builds the same type of CLI command, so the Agent SDK migration's Verification
Total: 97 tests passed, 0 failed. Please review: The consensus wrapper ( — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: Merge conflict resolution with Agent SDK migration
The conflict resolution is clean. No blocking issues.
Conflict resolution: Verified
Both conflict sites (concurrent_executor.py and coordinator.py) correctly keep the PR's build_consensus_wrapped_command over main's build_agent_command. The resolution is consistent:
concurrent_executor.py: Import swapped,_spawn_agentcallsbuild_consensus_wrapped_command— correct.coordinator.py: Import swapped, old prompt-appending code removed,build_consensus_wrapped_commandused — correct.pipelines.py: Auto-merged without conflicts. The PR's changes (concurrent param, consensus preamble, implicit READY) are in different code regions from the Agent SDK migration'sbuild_agent_commandcall sites. The sequential-path callers correctly continue usingbuild_agent_commandfromegg_agent, while the concurrent path goes throughConcurrentPhaseExecutorwhich uses the consensus wrapper.
CLI argument parity: Verified
Compared build_agent_command (from egg_agent/command.py) with the claude_parts construction in build_consensus_wrapped_command — they produce identical CLI arguments in the same order. The only gap is system_prompt support in build_agent_command which no current caller uses.
Previous issues: All resolved
- Blocking (wrapper READY on non-zero exit): Still correctly fixed —
$CLAUDE_EXIT -ne 0gate exits immediately. - Configurable timeout:
EGG_CONSENSUS_WRAPPER_TIMEOUTenv var with 300s default. - Behavioral tests:
TestConsensusWrapperBehaviorexercises actual bash logic via subprocess.
Non-blocking: Command construction duplication
build_consensus_wrapped_command reconstructs the Claude CLI arguments independently rather than delegating to build_agent_command. The PR author already noted this as a follow-up. Worth tracking — if build_agent_command gains new flags (e.g., system_prompt is already supported but unused), the consensus wrapper won't pick them up automatically. A future change to have the wrapper call build_agent_command internally and wrap the result would eliminate this drift risk.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
| egg is addressing review feedback... |
|
egg feedback addressed. View run logs 13 previous review(s) hidden. |
Update docs to reflect changes from #1092: - Add consensus_wrapper.py to STRUCTURE.md orchestrator listing - Document the new shell-level consensus wrapper in concurrent-execution.md: EGG_CONSENSUS_WRAPPER_TIMEOUT env var, auto-READY on clean exit (both wrapper-side and orchestrator-side implicit registration)
Update docs to reflect changes from #1092: - Add consensus_wrapper.py to STRUCTURE.md orchestrator listing - Document the new shell-level consensus wrapper in concurrent-execution.md: EGG_CONSENSUS_WRAPPER_TIMEOUT env var, auto-READY on clean exit (both wrapper-side and orchestrator-side implicit registration) Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Enforce consensus protocol for concurrent agents
Agents running in concurrent mode were exiting before participating in
consensus — a reviewer that finishes before the coder has committed
anything would simply exit, triggering the orchestrator's fallback path
and killing other agents mid-work. The root cause is behavioral: agents
treated their task as the entire job instead of understanding the full
consensus lifecycle.
Three complementary fixes at different layers:
1. Prompt restructuring — Adds a "Concurrent Consensus Protocol"
preamble to all concurrent agent prompts via a new
concurrentparamon
_build_agent_prompt. The preamble frames the agent's job as a5-step lifecycle (bootstrap → execute → signal READY → stay alive &
react → wait for SIGTERM) and explicitly states that exiting early is
a failure. The Phase Completion section is also rewritten for concurrent
agents to include the stay-alive polling loop.
2. Shell wrapper safety net — New
consensus_wrapper.pymodulewraps the
claude --printinvocation in a bash script. If Claude exitsbefore the orchestrator stops the container, the wrapper auto-signals
READY and enters a consensus polling loop with a 5-minute timeout. Used
by both
ConcurrentPhaseExecutor._spawn_agentand the coordinatorspawn endpoint.
3. Implicit READY on clean exit — In
_run_concurrent_phase, whena container exits with code 0 and the agent hasn't signaled READY, the
orchestrator auto-registers READY in the consensus evaluator. This
prevents one early exit from blocking consensus for all other agents.
Issue: #1081
Test plan:
pytest orchestrator/tests/test_consensus_wrapper.py— 8 tests for wrapper command constructionpytest orchestrator/tests/test_concurrent_integration.py— 22 tests including 7 new tests for lifecycle preamble, wrapper usage, and implicit READYpytest orchestrator/tests/test_coordinator_routes_functional.py— 60 existing tests still passAuthored-by: egg