Skip to content

fix: Handle orphaned container state on orchestrator restart - #839

Merged
jwbron merged 3 commits into
mainfrom
fix/orphaned-container-state-reconciliation
Feb 21, 2026
Merged

fix: Handle orphaned container state on orchestrator restart#839
jwbron merged 3 commits into
mainfrom
fix/orphaned-container-state-reconciliation

Conversation

@jwbron

@jwbron jwbron commented Feb 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • Startup reconciliation: On restart, any pipeline in RUNNING status whose agent containers are no longer alive in Docker is now detected and marked FAILED, allowing retry via POST /pipelines/{id}/start. Implemented in new orchestrator/startup_reconciliation.py, called from api.py main() before serving.
  • _spawn_and_wait() hardening: Wraps wait_for_container() in a try/except (ContainerNotFoundError, ContainerOperationError) so a container lost mid-wait synthesizes a failed ContainerInfo and falls through to the existing state-update/cleanup path — the agent is always marked FAILED rather than leaving the pipeline stuck in RUNNING.

Fixes issue-738 (pipeline permanently stuck after gateway crash killed the coder container).

Test plan

  • 11 new unit tests in tests/test_startup_reconciliation.py covering: no pipelines, non-RUNNING pipelines, live containers untouched, stale container recovery, mixed live/dead, Docker unavailable, store unavailable, corrupt single pipeline, multi-pipeline recovery
  • Full suite: pytest tests/ — 643 passed

🤖 Generated with Claude Code

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>

@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: 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 container

This 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

@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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the review feedback:

1. Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED

Agreed. Changed ci.status = ContainerStatus.EXITEDci.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

@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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the review feedback:

1. Bug: _spawn_and_wait overwrites container status to EXITED after exception handler sets FAILED

Agreed. Changed ci.status = ContainerStatus.EXITEDci.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

@james-in-a-box

Copy link
Copy Markdown
Contributor

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>

@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: 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 EXITEDFixed 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

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

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

@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor

Automated feedback loop has reached the maximum of 3 rounds. Human review is needed to make further progress on this PR.

1 similar comment
@james-in-a-box

Copy link
Copy Markdown
Contributor

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>

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

  1. Bug: _spawn_and_wait hardcoded EXITED status — Fixed in previous round (commit a570e84). Still correct.

  2. Advisory: phase_execution.status not 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.

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

@james-in-a-box

Copy link
Copy Markdown
Contributor

Automated feedback loop has reached the maximum of 3 rounds. Human review is needed to make further progress on this PR.

@james-in-a-box

Copy link
Copy Markdown
Contributor

egg review completed. View run logs

5 previous review(s) hidden.

@jwbron
jwbron merged commit 63051b0 into main Feb 21, 2026
11 checks passed
james-in-a-box Bot pushed a commit that referenced this pull request Feb 21, 2026
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.
jwbron added a commit that referenced this pull request Feb 21, 2026
* 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>
jwbron added a commit that referenced this pull request Feb 21, 2026
…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>
jwbron added a commit that referenced this pull request Feb 21, 2026
* 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>
jwbron added a commit that referenced this pull request Feb 21, 2026
…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>
james-in-a-box Bot pushed a commit that referenced this pull request Feb 21, 2026
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.
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