Replace implicit consensus READY with agent restart - #1101
Conversation
The egg_agent shared module was added in #1088 but the orchestrator Dockerfile was not updated to copy it into the container image, causing an ImportError crash loop on startup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dependency blocking added in #1085 prevented agents from spawning until their declared dependencies completed. This is wrong — agents should run concurrently and communicate via the messaging system. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a pipeline is cancelled, cleanup_pipeline() removes Docker containers but the pipeline state store still shows them as "running". This adds _mark_pipeline_records_cancelled() which syncs the persisted state after container cleanup — marking containers as REMOVED, agents as FAILED, and coordinator spawn records as cancelled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous approach mutated the stale pipeline object returned by update_pipeline(), which could overwrite coordinator state changes made between the status update and container cleanup. Now reloads the latest state from the store after containers are killed, ensuring we mark the most up-to-date records and don't clobber concurrent writes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The consensus wrapper was blindly auto-signaling READY when an agent exited cleanly without participating in consensus. This masked the real problem: the agent didn't follow the consensus protocol. Instead of faking consensus, the wrapper now restarts Claude with a recovery prompt that explains what happened and instructs the agent to assess state and explicitly signal READY or continue working. Restarts are capped at 2 (configurable). After exhausting restarts, the wrapper enters a passive wait loop but does NOT auto-signal READY. Also removes the orchestrator-level implicit READY registration in the concurrent phase poll loop (pipelines.py) — agents must explicitly participate in consensus. Issue: #1081
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Lint/Python": 1} |
This comment has been minimized.
This comment has been minimized.
After exhausting restart attempts, the wrapper now exits with code 1 instead of entering a passive wait loop. There's nothing to wait for — no process inside the container can make progress. The non-zero exit triggers the orchestrator's agent failure path (HITL decision).
…t' into egg/egg-20260314-015506-498078/work
Update concurrent-execution.md to reflect the new behavior: the wrapper restarts the agent with a recovery prompt instead of auto-signaling READY. Remove references to EGG_CONSENSUS_WRAPPER_TIMEOUT (no longer used) and implicit READY (removed from orchestrator).
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…flicts in pipelines.py and tests Resolved conflicts from main's rename of _mark_pipeline_records_cancelled to _mark_pipeline_records_terminated and dynamic error messages. Included main's new test_failed_pipeline_uses_correct_error_message test.
Conflict Resolution SummaryResolved merge conflicts with
Verification: All 109 tests pass across the 4 test files from the PR's test plan:
Lint checks also pass. No concerns: All conflicts were cases where main generalized the PR's cancel-specific code to handle both cancelled and failed terminal states. The changes are complementary and the merge preserves both contributions. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review Summary
The core design change — replacing implicit consensus READY with agent restart — is sound. Agents should explicitly participate in consensus, and silently faking READY on their behalf was masking protocol violations. The restart-with-recovery-prompt approach is well-designed: the agent gets context about what happened, instructions on what to do, and a bounded number of chances before escalation. The Dockerfile fix for shared/egg_agent/ is a necessary addition (the orchestrator already imports build_agent_command from it in ~11 places). The _mark_pipeline_records_cancelled helper is a good addition — cancellation should update state records. Tests are comprehensive.
Two issues need attention before merge.
Blocking
1. Undocumented removal of spawn-time dependency validation (coordinator.py)
The PR removes the entire get_role_definition() dependency check block from spawn_agent() (lines 227–261 removed) without mentioning it in the PR description. This was explicitly added in PR #1085 as a safety check: spawning reviewer_code would fail if its dependencies (e.g., coder) hadn't completed yet. The corresponding tests (TestSpawnDependencyValidation) are also deleted.
The commit message says "agents should run concurrently and communicate via the messaging system" — this rationale makes sense for the concurrent execution model, but:
- The removal is scope creep — the PR title and description focus on consensus restart behavior, not dependency validation changes. This should be a separate PR or at minimum documented in the description.
- It creates an enforcement gap:
shared/egg_contracts/orchestration.pystill definescan_agent_run()which checks dependencies, andagent_roles.pystill declares dependencies for tester→coder, documenter→coder, integrator→[coder, tester], etc. The coordinator endpoint no longer enforces these, but the data model still declares them. A coordinator agent (which is just an LLM following a prompt) could now spawn agents out of order with no guardrail. The orchestration layer'scan_agent_run()operates at a different level and is not called from the spawn endpoint. - If the intent is to allow concurrent spawning, the dependency definitions should also be updated (or documented as "soft dependencies for ordering hints, not hard constraints"), so the codebase is internally consistent.
Request: Either restore the dependency check with a bypass flag for concurrent mode, or split this into a separate PR with proper justification and update the dependency declarations to match the new behavior.
Non-blocking
2. Stale {role} placeholder in _RECOVERY_PROMPT docstring
Line 22: # Placeholders: {restart_number}, {max_restarts}, {role} — but {role} does not appear anywhere in the _RECOVERY_PROMPT string. This is misleading. Remove {role} from the comment, or add the agent's role to the recovery prompt (which would actually be useful context for the restarted agent — it doesn't currently know what role it is).
3. Wrapper doesn't check if agent already signaled READY before restarting
After the initial run, the wrapper checks whether consensus is complete but not whether this agent already signaled READY. If the agent signaled READY but then exited (e.g., context exhaustion), the wrapper will restart it unnecessarily. The restarted agent will poll, discover it already signaled READY, and just stay alive — so this isn't incorrect, just wasteful. Consider adding an egg-orch signal readiness --json check before restarting to skip the restart if the agent already signaled READY.
4. _mark_pipeline_records_cancelled error leaves stale response data
If _mark_pipeline_records_cancelled (line 699) throws, the pipeline variable isn't reassigned, and the response at line 711 returns the pre-cleanup pipeline object (still showing running containers/agents). The state is eventually consistent on next load, but the immediate API response is misleading. Consider returning the pipeline from store.load_pipeline() in the error path, or documenting this as expected behavior.
5. Coordinator spawn comment is stale
Line 305-307 in coordinator.py: the comment still says "keeps the container alive polling for consensus if Claude exits". This no longer describes the behavior — the wrapper now restarts Claude, not polls. Update to match the new semantics.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
|
@james-in-a-box pull main into this branch |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-Review (post-merge with main)
The only change since the last review is commit d9a4966 — a merge with origin/main resolving conflicts in pipelines.py, test_coordinator_routes_functional.py, and test_pipelines_api.py.
Merge conflict resolution: Clean
The merge correctly:
- Took main's rename
_mark_pipeline_records_cancelled→_mark_pipeline_records_terminatedand dynamic error messagef"Pipeline {pipeline.status.value}" - Moved
AgentExecutionStatusandContainerStatusimports to module level (removing the redundant local import that was inside the old function) - Integrated main's new
test_failed_pipeline_uses_correct_error_messagetest and class renameTestTerminatedPipelineSyncsState - Removed the stale
# Dependency validation testssection header fromtest_coordinator_routes_functional.py
No issues introduced by the merge itself.
Previous blocking issue: Not addressed
Blocking #1 (Dependency validation removal) remains open. The PR still removes the spawn-time dependency check from coordinator.py without any of the requested remediation:
- The PR description still doesn't mention it. The description says "Replace implicit consensus READY with agent restart" — the dependency enforcement removal is an unrelated change bundled silently.
- Internal inconsistency persists.
shared/egg_contracts/agent_roles.pydeclares 15+ dependency edges (tester→coder, documenter→coder, integrator→[coder, tester], etc.).shared/egg_contracts/orchestration.pyexportscan_agent_run()andget_runnable_agents()which check these dependencies. Both are tested and exported from__init__.py. But the only enforcement point — the coordinator spawn endpoint — no longer calls them. The declarations and utility functions are dead code from the coordinator's perspective. - No bypass flag, no documentation update, no separate PR. The original review offered two acceptable paths: (a) restore the check with a concurrent-mode bypass, or (b) split it out with proper justification and update the dependency declarations. Neither was done.
The concurrent execution rationale is valid — agents need to start in parallel. But removing the enforcement without updating the data model creates a codebase where dependencies=[AgentRole.CODER] on the tester role is a lie. Either these declarations should be removed/marked as advisory, or the enforcement should be conditional on execution mode. Do one or the other, not neither.
Previous non-blocking issues: None addressed
All four non-blocking suggestions from the first review remain:
- #2: Stale
{role}placeholder in the_RECOVERY_PROMPTcomment (line 22 ofconsensus_wrapper.py). Still says# Placeholders: {restart_number}, {max_restarts}, {role}but{role}is unused in the prompt. - #3: No READY check before restart. Still just wasteful, not incorrect.
- #4: Stale response data in the
_mark_pipeline_records_terminatederror path (line 700-705 ofpipelines.py). If the function throws, the response returns the pre-cleanup pipeline object. Same issue, just with the renamed function. - #5: Stale coordinator spawn comment (lines 305-307 of
coordinator.py). Still says "keeps the container alive polling for consensus if Claude exits" — should say "restarts the agent".
— 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.
Feedback Response
Blocking #1 — Dependency validation removal
Disagree. coordinator.py has no diff against main in this PR — the dependency validation removal was merged to main via PR #1100 ("Fix orchestrator startup crash, spawn blocking, and cancel state sync"), not this PR. Running gh pr diff 1101 confirms coordinator.py is not in the changeset. The reviewer's first review (at commit 620a816) predated the merge with main, so the observation was valid at that time, but after the merge (d9a4966), the change is no longer attributable to this PR.
The consistency concern is valid though — agent_roles.py declares dependencies that the coordinator no longer enforces. This should be addressed in a dedicated follow-up PR that either marks them as advisory ordering hints or adds a conditional enforcement path for non-concurrent mode.
Non-blocking #2 — Stale {role} placeholder
Agreed. Removed {role} from the _RECOVERY_PROMPT comment since it's not used in the prompt string.
Non-blocking #3 — READY check before restart
Agreed. Added a pre-restart consensus and readiness check. The wrapper now:
- Checks if consensus is already complete (exits immediately if so)
- Checks if this agent already signaled READY via
EGG_AGENT_ROLEenv var — if so, enters a polling loop instead of restarting
Updated the test_clean_exit_triggers_restart test to use a stateful mock egg-orch that returns is_complete=false initially and true after the restart, so the new check doesn't short-circuit the restart test path.
Non-blocking #4 — Stale response data in error path
Agreed. Added store.load_pipeline() fallback in the exception handler so the API response reflects current state even when _mark_pipeline_records_terminated throws.
Non-blocking #5 — Stale coordinator spawn comment
Agreed. Updated comment from "keeps the container alive polling for consensus" to "restarts the agent with a recovery prompt if Claude exits before the orchestrator confirms consensus".
All 110 tests pass across the 4 test files. Lint is clean.
— Authored by egg
There was a problem hiding this comment.
Re-Review Summary
The new commit addresses all four non-blocking suggestions from the previous review. The dependency validation concern (previous blocking #1) is confirmed resolved — the removal is in main via PR #1100, not in this PR's diff.
However, the new code introduced to address feedback #3 (pre-restart READY check) exposes a pre-existing bug in the consensus wrapper that makes all consensus/readiness detection non-functional.
Blocking
1. All consensus checks query the wrong endpoint — detection is non-functional
The wrapper uses egg-orch message status --json to check both consensus completion and per-agent readiness state (both in the new pre-restart check AND the pre-existing post-restart check). But message status returns only message bus statistics:
{"success": true, "data": {"total": 42, "by_type": {"PROGRESS": 15, ...}}}It does not include consensus data. Consensus state (is_complete, per-agent state) is only available from the pipeline status endpoint (egg-orch pipeline status --json), nested under data.concurrent.consensus.
As a result:
IS_COMPLETEwill always be"False"— the pathdata.consensus.is_completedoesn't exist in the message status response, so the.get()chain falls through to the default.AGENT_STATEwill always be""—data.consensus.agentsdoesn't exist either.
This affects three code paths in the wrapper:
- New pre-restart consensus check (lines 83–90): Will never detect consensus is complete → always falls through.
- New pre-restart READY check (lines 93–117): Will never detect the agent is READY → always falls through to restart. The entire purpose of this code (avoiding wasteful restarts when the agent already signaled READY) is non-functional.
- Pre-existing post-restart consensus check (lines 131–136): Will never detect consensus completion → wrapper always exhausts max restarts and exits with failure code 1.
The tests mask this because the mock egg-orch returns {"data": {"consensus": {"is_complete": true}}} — a JSON structure the real egg-orch message status never produces. The _make_mock_tools_with_delayed_consensus helper has the same issue: it returns a response structure that doesn't match the real command's output.
Fix: Replace egg-orch message status --json with egg-orch pipeline status --json and update the JSON extraction path from data.consensus.is_complete to data.concurrent.consensus.is_complete (and similarly for per-agent state: data.concurrent.consensus.agents.<role>.state). Update the test mocks to match the real response structure.
The pre-existing post-restart check (point 3) has the same bug and should be fixed in the same pass — the PR is already modifying this code.
Non-blocking
2. READY polling loop reuses MAX_RESTARTS as iteration count
In the READY polling loop (lines 106–117), the wait loop is bounded by MAX_RESTARTS:
while [ "$WAIT_COUNT" -lt "$MAX_RESTARTS" ]; doMAX_RESTARTS semantically means "how many times to restart Claude" (default 2), but here it's repurposed as "how many poll cycles to wait for consensus." With default values (MAX_RESTARTS=2, POLL_INTERVAL=30s), the agent waits at most 60 seconds for consensus. This may be too short if other agents are slow. Consider a separate MAX_READY_POLLS constant or a longer timeout (the agent already signaled READY so there's minimal cost to waiting longer).
3. Test mock code duplication
_make_mock_tools_with_delayed_consensus duplicates the entire claude mock creation from _make_mock_tools. Extract the claude mock creation into a shared helper to reduce maintenance burden.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
All consensus checks were querying egg-orch message status, which returns message bus statistics — not consensus data. Changed to egg-orch pipeline status with the correct nested path (data.concurrent.consensus) matching the real API response. Also addressed: - Added MAX_READY_POLL_CYCLES (default 10) as a separate constant for the READY polling loop instead of reusing MAX_RESTARTS (default 2) - Extracted claude mock creation to shared _make_mock_claude() helper - Updated all test mocks to use pipeline status and the correct response structure
There was a problem hiding this comment.
Blocking #1 — Wrong endpoint for consensus checks
Agreed. Replaced all three occurrences of egg-orch message status --json with egg-orch pipeline status --json and updated the JSON extraction path from data.consensus.* to data.concurrent.consensus.* to match the real API response structure. All test mocks updated accordingly.
Non-blocking #2 — READY polling loop reuses MAX_RESTARTS
Agreed. Added MAX_READY_POLL_CYCLES = 10 as a separate constant. With the default 30s poll interval, this gives 5 minutes for other agents to finish — much more reasonable than the 60 seconds from reusing MAX_RESTARTS=2.
Non-blocking #3 — Test mock code duplication
Agreed. Extracted claude mock creation into a shared _make_mock_claude() helper function. Both _make_mock_tools and _make_mock_tools_with_delayed_consensus now use it, and _make_failing_claude delegates to it with a non-zero exit code.
All 112 tests pass across the 4 test files. Lint and mypy clean.
— Authored by egg
There was a problem hiding this comment.
Re-Review Summary
The new commit (b7776d0) cleanly addresses all three items from the previous review.
Previous Blocking #1 (Wrong endpoint): Fixed
All three consensus check paths now correctly use egg-orch pipeline status --json instead of egg-orch message status --json. The JSON extraction path is updated to data.concurrent.consensus.is_complete (and data.concurrent.consensus.agents.<role>.state for per-agent state), which matches the real API response structure from the get_pipeline_status() endpoint in pipelines.py. Test mocks are updated to return the correct response structure.
Previous Non-blocking #2 (READY polling reuses MAX_RESTARTS): Fixed
Added MAX_READY_POLL_CYCLES = 10 as a separate constant with a clear comment explaining the 5-minute timeout. The READY polling loop uses MAX_READY_POLLS instead of MAX_RESTARTS. The constant is parameterized through build_consensus_wrapped_command(max_ready_polls=...).
Previous Non-blocking #3 (Test mock duplication): Fixed
Extracted _make_mock_claude() as a module-level helper. _make_mock_tools, _make_mock_tools_with_delayed_consensus, and _make_failing_claude all delegate to it. Clean refactor, equivalent behavior.
Previously fixed items verified
- Non-blocking #4 (stale response in error path): Confirmed
store.load_pipeline()fallback exists in the_mark_pipeline_records_terminatedexception handler. - Non-blocking #5 (stale coordinator spawn comment): Confirmed updated to "restarts the agent with a recovery prompt if Claude exits before the orchestrator confirms consensus".
Non-blocking
1. No behavioral test for the READY polling path
The READY check path (agent already signaled READY → skip restart → poll for consensus) has string-match tests (test_contains_ready_check_before_restart, test_ready_polling_uses_separate_constant, test_default_max_ready_polls) but no behavioral test that actually exercises the path in a subprocess. A test that sets EGG_AGENT_ROLE in the environment and has the mock return per-agent READY state would cover this. Not blocking since the logic is straightforward and the JSON path was validated against the real API structure.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Non-blocking #1 — No behavioral test for READY polling pathAgreed. Added
Also added All 113 tests pass. Lint clean. |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-Review Summary
One new commit since the previous (approving) review: 8acea45 — "Add behavioral test for READY polling path". This addresses the single remaining non-blocking suggestion.
Previous non-blocking #1 (No behavioral test for READY polling path): Fixed
The new test (test_ready_agent_skips_restart_and_polls) correctly exercises the full READY polling path in a subprocess:
- Environment setup:
_run_wrapper_commandgains anagent_roleparameter that setsEGG_AGENT_ROLEin the subprocess environment, correctly popping it whenNoneto prevent leakage from the outer environment. - Mock structure:
_make_mock_tools_with_agent_ready_statereturns per-agent READY state in the JSON response using the correct nested path (data.concurrent.consensus.agents.<role>.state), matching the realegg-orch pipeline statusresponse structure. Usesconsensus_aftercounter to gate consensus completion. - Assertions: Verifies exit code 0, "already signaled READY" message, "Consensus reached" message, single Claude invocation (no restart), and no "Restarting" messages. Complete coverage of the path.
- Timing: With
EGG_MESSAGE_POLL_INTERVAL=1andconsensus_after=3, the test completes in ~2-3 seconds well within the 30-second timeout.
No issues found. The JSON string construction with concatenation instead of f-strings is pragmatic given the brace-heavy context of shell-embedded Python.
— Authored by egg
|
egg review completed. View run logs 14 previous review(s) hidden. |
Update sdlc-pipeline.md to describe the new consensus wrapper behavior introduced in PR #1101: instead of completing based on exit codes when containers exit without signaling READY, the wrapper now restarts the agent with a recovery prompt (up to MAX_CONSENSUS_RESTARTS, default 2). After exhausting restarts, exit code 1 triggers the HITL failure path. Authored-by: egg
…1102) Update sdlc-pipeline.md to describe the new consensus wrapper behavior introduced in PR #1101: instead of completing based on exit codes when containers exit without signaling READY, the wrapper now restarts the agent with a recovery prompt (up to MAX_CONSENSUS_RESTARTS, default 2). After exhausting restarts, exit code 1 triggers the HITL failure path. Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
Replace implicit consensus READY with agent restart
The consensus wrapper was blindly auto-signaling READY when an agent
exited cleanly without participating in consensus. This masked the
real problem — the agent didn't follow the consensus protocol — and
could lead to premature phase completion with incomplete work.
Key design change: Agents must explicitly participate in consensus.
The wrapper never auto-signals READY on behalf of an agent.
Instead of faking consensus, the wrapper now restarts Claude inside
the same container with a recovery prompt that:
READY or continue working
Restarts are capped at 2 (configurable via
max_restartsparam).After exhausting restarts the wrapper exits with code 1, triggering
the orchestrator's agent failure path (HITL decision with
retry/abort/continue options). No passive waiting — if the agent can't
follow the protocol after 3 attempts, escalate.
Also removes the orchestrator-level implicit READY registration in
the concurrent phase poll loop (
pipelines.py). Updates the docs fromPR #1099 to reflect the new behavior.
Issue: #1081
Test plan:
pytest orchestrator/tests/test_consensus_wrapper.py— 21 tests for wrapper restart behaviorpytest orchestrator/tests/test_concurrent_integration.py— 22 tests including updated no-implicit-READY testspytest orchestrator/tests/test_coordinator_routes_functional.py— 58 existing tests still passAuthored-by: egg