Add runtime container monitor and wave cycle cap - #848
Conversation
The SDLC pipeline commits .egg-state/ files to the local worktree after each phase completes but never pushes them to the remote. This causes implement-phase agents to fail when pushing code because the gateway's phase filter sees unpushed .egg-state/contracts/ and .egg-state/reviews/ files in the diff and blocks the push. Add push_worktree_branch() calls after each _commit_statefiles_to_worktree() call — both after contract initialization and after phase completion — so statefiles reach the remote before the next phase begins. The existing push_worktree_branch method already bypasses phase restrictions (it creates a temp session without a phase parameter), so it pushes .egg-state/ files regardless of the current phase. Authored-by: egg
Start the ContainerMonitor background thread after startup reconciliation in api.py, with a handler that marks pipelines FAILED when their containers exit unexpectedly during execution. This closes the gap where PR #839 only handled the restart case. Add a max_waves=30 safety cap to execute_all_waves() in multi_agent.py to prevent unbounded wave cycles when the dispatcher keeps returning agents as runnable.
This comment has been minimized.
This comment has been minimized.
…licts in test_pipeline_failure_path.py
Conflict Resolution SummaryResolved merge conflicts with
DetailsThe conflict arose because both this PR (commit 90b6940) and PR #847 (merged as 1478f99) added the
Both additions from main were included in the resolution. All tests pass:
— Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Review: Runtime Container Monitor + Wave Cap
I reviewed all 5 changed files, the existing container_monitor.py, multi_agent.py, api.py, state_store.py, startup_reconciliation.py, dispatch.py, and models.py for full context.
Issue 1: Race condition — _reconcile_container_state does direct load→mutate→save without holding the per-pipeline lock
Severity: High (correctness)
_reconcile_container_state calls store.load_pipeline() → mutates the pipeline object → calls store.save_pipeline(), but does not acquire get_pipeline_state_lock(pipeline_id). The state store has a per-pipeline RLock and an update_pipeline() method specifically designed to make load-modify-save atomic. Other code paths (e.g., signal handlers, decision resolution) use these locks.
The container monitor runs in a background thread. If an agent signal (e.g., signal complete) arrives concurrently, the monitor's save can overwrite the signal's state change or vice versa. The pipeline model has a version field for optimistic locking, but _reconcile_container_state doesn't pass expected_version to save_pipeline() either, so it silently overwrites.
Fix: Either:
- Use
get_pipeline_state_lock(pipeline_id)around the load→mutate→save cycle, or - Use
store.update_pipeline()instead of direct manipulation, or - At minimum, pass
expected_version=pipeline.versiontosave_pipeline()so a concurrent write causes a conflict rather than silent data loss.
Issue 2: Docstring says default: 30 but actual default is 5
Severity: Low (documentation)
container_monitor.py (new code) / multi_agent.py:520:
max_waves: Safety cap on number of wave iterations (default: 30)But the actual signature default is max_waves: int = 5. This is clearly a copy-paste error from an earlier draft.
Fix: Change the docstring to (default: 5).
Issue 3: STOPPED events mark pipelines FAILED — this is incorrect for graceful exits
Severity: High (correctness)
The reconciliation handler processes ContainerEvent.STOPPED events (line 407):
if event.event_type not in (ContainerEvent.EXITED, ContainerEvent.FAILED, ContainerEvent.STOPPED):
returnSTOPPED is emitted when a container exits with code 0 (see _check_container line 149-150). Exit code 0 means the agent completed normally. When an agent finishes successfully, the normal flow is: agent signals completion → container exits with 0. If the monitor fires before the signal handler processes, or if the event ordering is slightly off, the monitor will mark a successfully completed pipeline as FAILED.
This will cause false failures on healthy pipelines.
Fix: Remove ContainerEvent.STOPPED from the handler's filter. Only react to ContainerEvent.FAILED (and EXITED defensively, though it's currently never emitted). A graceful exit (code 0) should not trigger pipeline failure:
if event.event_type not in (ContainerEvent.FAILED,):
returnOr, if you want to handle the edge case where a container exits with 0 but the agent never signaled completion, add a check inside _reconcile_container_state that skips containers/agents that are already COMPLETE.
Issue 4: _reconcile_container_state returns True after the first pipeline match, skipping other affected pipelines
Severity: Medium (correctness)
Line 381: return True is inside the for pipeline_id in pipeline_ids loop. If a container somehow appears in multiple pipelines (unlikely but possible with container ID reuse or misconfiguration), only the first pipeline would be reconciled. More importantly, if save_pipeline raises an exception, the function falls through and continues iterating — but on success it exits immediately.
The early return is fine if you know a container belongs to exactly one pipeline, but this should at least be documented. Or better: continue the loop and track total changes.
Issue 5: get_container_monitor() singleton is not thread-safe
Severity: Low (unlikely in practice)
_container_monitor: ContainerMonitor | None = None
def get_container_monitor() -> ContainerMonitor:
global _container_monitor
if _container_monitor is None:
_container_monitor = ContainerMonitor()
return _container_monitorThis pre-exists the PR but the PR adds a new caller (api.py). With the WSGI server using 16 threads, two requests could race through the check-then-assign. In practice this is only called once during main() startup before serving requests, so the risk is low, but it's worth noting.
Issue 6: No test for the race condition / concurrent access scenario
Severity: Medium (testing gap)
The tests mock the store but don't verify that the reconciliation handler interacts safely with concurrent pipeline state changes. Given Issue 1 above, a test demonstrating the lock acquisition would be valuable.
Issue 7: Handler processes events synchronously, blocking the monitor thread
Severity: Medium (design)
The reconciliation handler runs inside _emit_event which is called from the monitor's _check_all_containers loop. The handler calls get_state_store(), loads pipeline state, iterates all pipelines, and writes to disk. This I/O-heavy work blocks the monitor thread, delaying detection of other container state changes. If many containers die simultaneously (e.g., host issue), the monitor will process them one at a time, serially.
This isn't a bug per se, but it's worth considering whether the handler should enqueue work to a separate thread/queue rather than blocking the monitor loop.
Issue 8: ContainerEvent.EXITED is in the handler filter but is never emitted
Severity: Low (dead code)
_check_container maps ContainerStatus.EXITED to either ContainerEvent.STOPPED (exit 0) or ContainerEvent.FAILED (exit non-zero). ContainerEvent.EXITED is defined as a constant but never emitted. Including it in the filter is harmless but misleading — it suggests the event can occur. A comment would help, or it could be removed.
Summary
The wave cap change (max_waves=5) is clean and correct, aside from the docstring mismatch.
The runtime container monitor has two blocking issues:
- Race condition: Needs per-pipeline locking or optimistic version checks during reconciliation (Issue 1)
- False failures:
STOPPEDevents (exit code 0) should not mark pipelines FAILED (Issue 3)
These must be fixed before merge. The remaining items are lower priority but worth addressing.
— 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.
Review Feedback Response
Addressed all actionable items in commit f6f668d:
Issue 1 (High): Race condition — Fixed
- Wrapped the load→mutate→save cycle in
_reconcile_container_statewithget_pipeline_state_lock(pipeline_id)context manager - Added
expected_version=pipeline.versiontosave_pipeline()for optimistic locking - Added explicit
VersionConflictErrorhandling that returnsFalse(lets the concurrent writer win) - Added tests for both lock acquisition and version conflict handling
Issue 2 (Low): Docstring default mismatch — Fixed
- Changed
(default: 30)→(default: 5)inexecute_all_wavesdocstring
Issue 3 (High): STOPPED events marking pipelines FAILED — Fixed
- Handler now only reacts to
ContainerEvent.FAILED(non-zero exit) STOPPEDevents (exit code 0 / graceful exit) are ignored- Added test
test_handler_ignores_stopped_eventto verify
Issue 4 (Medium): Early return assumption — Documented
- Added docstring note: "A container belongs to exactly one pipeline, so the function returns after updating the first matching pipeline."
Issue 5 (Low): Singleton thread safety — Acknowledged
- Pre-existing and only called once during
main()startup. No change needed.
Issue 6 (Medium): Concurrent access test — Added
test_acquires_pipeline_lockverifies the lock is acquired for the correct pipelinetest_handles_version_conflictverifies graceful handling of concurrent writes
Issue 7 (Medium): Synchronous handler blocking — Acknowledged
- Noted as a design consideration, not a bug. The current serial processing is acceptable for the expected container failure rate. Can be revisited if many-container-failure scenarios become a concern.
Issue 8 (Low): EXITED dead code in filter — Fixed
- Removed
EXITEDandSTOPPEDfrom the handler filter — onlyFAILEDevents are processed - Added
test_handler_ignores_exited_eventtest
All 19 tests in the affected files pass. 639/642 orchestrator tests pass (3 pre-existing failures in test_docker_client.py). Ruff clean.
— Authored by egg
There was a problem hiding this comment.
Re-review: All previous issues addressed
Reviewed the delta from b9b5d1a to f6f668d. All 8 issues from the previous review have been properly fixed.
Verification of fixes
| Issue | Status | Notes |
|---|---|---|
| #1 (High): Race condition — no per-pipeline lock | Fixed | get_pipeline_state_lock(pipeline_id) wraps the entire load→mutate→save cycle. expected_version passed to save_pipeline(). VersionConflictError caught and handled gracefully (returns False, letting the concurrent writer win). |
#2 (Low): Docstring says default: 30 |
Fixed | Changed to (default: 5) in multi_agent.py:520. |
| #3 (High): STOPPED events mark pipelines FAILED | Fixed | Handler now filters only on ContainerEvent.FAILED. Graceful exits (code 0 → STOPPED) are ignored. |
| #4 (Med): Early return assumption | Fixed | Docstring now documents: "A container belongs to exactly one pipeline, so the function returns after updating the first matching pipeline." |
| #5 (Low): Singleton thread safety | Acknowledged | Pre-existing, only called at startup. No change needed. |
| #6 (Med): Concurrent access test gap | Fixed | Two new tests: test_acquires_pipeline_lock verifies lock acquisition with correct pipeline ID; test_handles_version_conflict verifies graceful VersionConflictError handling. |
| #7 (Med): Synchronous handler blocking | Acknowledged | Acceptable for expected failure rate. |
| #8 (Low): EXITED dead code in filter | Fixed | Removed from filter. New test test_handler_ignores_exited_event confirms. |
New code quality
The locking implementation is correct:
get_pipeline_state_lockreturns anRLock(per-pipeline, in-process). The lock wraps the full load→mutate→save cycle.expected_version=pipeline.versionprovides optimistic locking as a second layer of defense against races with writers that don't hold the same lock (e.g., signal handlers).VersionConflictErroris caught explicitly and returnsFalse— the right behavior since a concurrent writer already updated the pipeline state.- No deadlock risk:
save_pipelineinternally acquires the git serialization lock (_git_op), which is a different lock type in a strict hierarchy.
One minor non-blocking observation
container_monitor.py:401-406: The generic except Exception handler after save_pipeline logs the error but doesn't explicitly return False. Execution falls through to the next loop iteration and eventually returns False at line 408. Since a container belongs to exactly one pipeline, this is functionally correct — no remaining pipelines will match. An explicit return False would make the intent clearer, but this is not blocking.
All 15 tests pass. Ruff clean. No new 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.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: Non-blocking suggestion addressed
Delta since f6f668d: single line addition — return False in the generic except Exception handler within _reconcile_container_state.
Verification
The change adds an explicit return False after the logger.error() call in the catch-all exception handler for save_pipeline(). Previously, execution fell through the loop and eventually hit the return False at the function's end. Since a container belongs to exactly one pipeline, this was functionally correct but unclear. The explicit return now matches the VersionConflictError handler pattern directly above:
try:
store.save_pipeline(...)
return True # success
except VersionConflictError:
...
return False # concurrent writer wins
except Exception as e:
...
return False # save failed (NEW)All three code paths from save_pipeline() now have explicit returns. No behavioral change, purely a clarity improvement.
All 8 original review issues remain properly addressed from the previous round. No new issues introduced.
— Authored by egg
|
egg review completed. View run logs 7 previous review(s) hidden. |
…duction api.main() and cli.cmd_serve were duplicate startup paths. The production entrypoint always uses cli.py, so startup_reconciliation (#839) and ContainerMonitor (#848) were dead code — never executed on any restart. Move the startup logic into cmd_serve and delete api.main() along with its now-unused argparse and waitress imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix startup reconciliation and container monitor never running in production api.main() and cli.cmd_serve were duplicate startup paths. The production entrypoint always uses cli.py, so startup_reconciliation (#839) and ContainerMonitor (#848) were dead code — never executed on any restart. Move the startup logic into cmd_serve and delete api.main() along with its now-unused argparse and waitress imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add threads=16 to waitress serve() call in cmd_serve --------- Co-authored-by: James Wiesebron <jameswiesebron@khanacademy.org> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…duction (#852) api.main() and cli.cmd_serve were duplicate startup paths. The production entrypoint always uses cli.py, so startup_reconciliation (#839) and ContainerMonitor (#848) were dead code — never executed on any restart. Move the startup logic into cmd_serve and delete api.main() along with its now-unused argparse and waitress imports. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
api.main() and cli.cmd_serve were duplicate startup paths. The production entrypoint always uses cli.py, so startup_reconciliation (#839) and ContainerMonitor (#848) were dead code — never executed on any restart. Move the startup logic into cmd_serve and delete api.main() along with its now-unused argparse and waitress imports. Restore threads=16 in the waitress serve() call to match the previous api.main() behavior.
Summary
ContainerMonitorbackground thread after startup reconciliation. A new pipeline reconciliation handler detects when agent containers exit or fail during execution and marks the owning pipeline FAILED. This closes the gap where PR fix: Handle orphaned container state on orchestrator restart #839 only handled the restart case — silently dying containers during runtime no longer leave pipelines stuck in RUNNING forever.max_waves=5parameter toexecute_all_waves()to prevent unbounded wave iterations when the dispatcher keeps returning agents as runnable (e.g., reviewer→coder reset loops within a single review cycle).Issue: none
Test plan:
cd orchestrator && python -m pytest tests/test_container_monitor.py -v— 12 new tests passcd orchestrator && python -m pytest tests/test_multi_agent.py -v— 4 new tests passcd orchestrator && python -m pytest tests/test_pipeline_failure_path.py tests/test_startup_reconciliation.py -v— existing tests still passruff check orchestrator/— lint cleanAuthored-by: egg