Skip to content

Enforce consensus protocol for concurrent agents - #1092

Merged
jwbron merged 6 commits into
mainfrom
egg/consensus-enforcement
Mar 14, 2026
Merged

Enforce consensus protocol for concurrent agents#1092
jwbron merged 6 commits into
mainfrom
egg/consensus-enforcement

Conversation

@james-in-a-box

Copy link
Copy Markdown
Contributor

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 concurrent param
on _build_agent_prompt. The preamble frames the agent's job as a
5-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.py module
wraps the claude --print invocation in a bash script. If Claude exits
before 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_agent and the coordinator
spawn endpoint.

3. Implicit READY on clean exit — In _run_concurrent_phase, when
a 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 construction
  • pytest orchestrator/tests/test_concurrent_integration.py — 22 tests including 7 new tests for lifecycle preamble, wrapper usage, and implicit READY
  • pytest orchestrator/tests/test_coordinator_routes_functional.py — 60 existing tests still pass

Authored-by: egg

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.
@james-in-a-box

This comment has been minimized.

@james-in-a-box

james-in-a-box Bot commented Mar 14, 2026

Copy link
Copy Markdown
Contributor Author
Autofix tracking
{"Lint/Python": 1, "Test/Security Scan": 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.

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 || true

This 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 || true

The 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 concurrent parameter on _build_agent_prompt is 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.py lines 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

@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.

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

@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 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_command is the sole entrypoint for both ConcurrentPhaseExecutor._spawn_agent and coordinator.py spawn route — both callers get the fix automatically.
  • set -uo pipefail is 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_EXIT on non-zero correctly propagates the original exit code to the container runtime.

No blocking issues found.

— 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

Copy link
Copy Markdown
Contributor Author

Agreed. Added behavioral subprocess-based tests (TestConsensusWrapperBehavior) that exercise the actual bash wrapper logic rather than just string-matching. The four new tests use a mock egg-orch script in a temp directory to verify:

  • test_nonzero_exit_does_not_call_readiness — failed agent doesn't invoke egg-orch signal readiness
  • test_clean_exit_calls_readiness — clean exit invokes egg-orch signal readiness --state READY
  • test_nonzero_exit_propagates_exit_code — exit code 42 propagates correctly
  • test_non_concurrent_mode_skips_consensus — without EGG_CONCURRENT_MODE=true, no consensus logic runs

The existing string-matching tests are kept alongside since they serve as a fast structural check.

— 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

This comment has been minimized.

@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: 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 0 gate exits immediately, no READY signal, no polling loop.
  • Configurable timeout: Uses EGG_CONSENSUS_WRAPPER_TIMEOUT env 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: Verifies false (exit 1) produces no egg-orch calls at all — confirmed via log file absence.
  • test_clean_exit_calls_readiness: Verifies true (exit 0) invokes egg-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 no egg-orch calls without EGG_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 pipefail works correctly with ${VAR:-default} patterns throughout the template — no unset variable risks.
  • No set -e, which is intentional: the wrapper must capture CLAUDE_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_agent and coordinator.py spawn route) use build_consensus_wrapped_command, so the exit-code fix applies universally.

No blocking issues found.

— Authored by egg

@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor Author

Conflict Resolution Summary

Resolved merge conflicts with main (from Agent SDK migration in #1088 and other changes):

File Category Resolution
orchestrator/concurrent_executor.py Semantic Kept PR's build_consensus_wrapped_command over main's build_agent_command — the consensus wrapper is the core feature of this PR
orchestrator/routes/coordinator.py Semantic Kept PR's consensus wrapper approach over main's prompt-appending approach; removed unused build_agent_command import
orchestrator/routes/pipelines.py (auto-merged) No conflicts — git merged automatically

Conflict Details

Both conflicts stem from the same root cause: main merged #1088 (Agent SDK migration) which introduced build_agent_command in shared/egg_agent/, while this PR introduced build_consensus_wrapped_command in orchestrator/consensus_wrapper.py. Both are used in the same call sites.

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 build_agent_command is not needed at these call sites.

Verification

  • make lint — all checks passed (ruff, mypy, shellcheck, custom checks)
  • pytest orchestrator/tests/test_consensus_wrapper.py — 8/8 passed
  • pytest orchestrator/tests/test_concurrent_integration.py — 22/22 passed
  • pytest orchestrator/tests/test_coordinator_routes_functional.py — 67/67 passed

Total: 97 tests passed, 0 failed.

Please review: The consensus wrapper (consensus_wrapper.py) builds its own Claude CLI command internally rather than delegating to build_agent_command. A follow-up to have the wrapper use build_agent_command internally could reduce duplication, but is out of scope for this conflict resolution.

— 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: 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_agent calls build_consensus_wrapped_command — correct.
  • coordinator.py: Import swapped, old prompt-appending code removed, build_consensus_wrapped_command used — 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's build_agent_command call sites. The sequential-path callers correctly continue using build_agent_command from egg_agent, while the concurrent path goes through ConcurrentPhaseExecutor which 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 0 gate exits immediately.
  • Configurable timeout: EGG_CONSENSUS_WRAPPER_TIMEOUT env var with 300s default.
  • Behavioral tests: TestConsensusWrapperBehavior exercises 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

@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor Author
egg is addressing review feedback...

@jwbron
jwbron merged commit 137a254 into main Mar 14, 2026
22 checks passed
@james-in-a-box

Copy link
Copy Markdown
Contributor Author

egg feedback addressed. View run logs

13 previous review(s) hidden.

github-actions Bot pushed a commit that referenced this pull request Mar 14, 2026
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)
jwbron added a commit that referenced this pull request Mar 14, 2026
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>
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