Fix #2411: tolerant startup reconciliation + start_pipeline MCP verb - #2416
Conversation
…dd start_pipeline MCP verb
Two coupled fixes for the orch-restart recovery surface:
A. startup_reconciliation no longer marks the whole pipeline FAILED when
an in-memory ``container_id`` is missing from the global live-id set.
Instead, it queries k8s for pods labeled ``egg.pipeline.id=<id>``; if
any are alive, the pipeline is left RUNNING and record drift is
reconciled by the running orchestrator. Pipelines with zero live pods
still surface as FAILED.
B. Adds a ``start_pipeline`` MCP tool that wraps the existing
``POST /api/v1/pipelines/{id}/start`` endpoint. Operators using MCP
now have a recovery verb for the FAILED + RUNNING-phase combo;
``start_phase`` only targets phase-level state and 400s on
``phase_already_running`` in this combo.
Tests: new label-aware mock for the docker client, three new cases
covering the bug scenario (record drift with live pods), label-query
failure fallback, and the genuinely-orphaned case. New TestStartPipeline
class for the MCP verb. Existing assertion in
``test_alive_container_not_disturbed_alongside_dead_one`` was the
encoded buggy behavior — replaced with a corrected version.
There was a problem hiding this comment.
Review
The two-part fix is well-scoped and addresses a real failure mode (#2411). The reconciliation change is the right call — querying k8s by egg.pipeline.id label and trusting ground truth over persisted record IDs is the correct anti-drift approach. The new start_pipeline MCP verb fills a real gap (the start_phase wrapper 400s on the FAILED+RUNNING combo) and is correctly distinct from start_phase at the route level.
I'm approving with several non-blocking concerns. The most important is that the label-query fallback silently preserves the exact bug the PR is trying to fix.
Non-blocking — fallback on label-query failure quietly re-introduces the bug
orchestrator/startup_reconciliation.py:191-211
try:
pipeline_live_containers = docker_client.list_containers(
labels={_LABEL_PIPELINE_ID: pipeline_id},
)
pipeline_live_ids: set[str] = {ci.container_id for ci in pipeline_live_containers}
except Exception as e:
logger.warning(...)
pipeline_live_ids = set()
if pipeline_live_ids:
...
continueWhen the label-scoped query throws (pipeline_live_ids = set()), control falls through to the per-container/per-agent check at lines 213-244, which uses the global live_ids set built from the un-scoped query at line 64. In the exact #2411 drift scenario — persisted container_id="abc", new pod uid is def, pod is alive — the global check still reports "abc" not in {def, …}, marks every container FAILED, and flips the pipeline to FAILED. That is the pre-fix behavior the PR is supposed to prevent.
The new test test_label_query_failure_falls_back_to_global_check at tests/test_startup_reconciliation.py:357-382 only exercises the genuinely-orphaned case (persisted dead_xyz vs global some_other_pipeline_id). It does not exercise the drift case — where the bug remains.
In practice both queries route through KubernetesClient.list_containers → the same core_api.list_namespaced_pod, so a failure that affects only the label-scoped call is improbable. But the way the code reads, this is a defensive except Exception that papers over the original bug under degraded-cluster conditions while looking robust.
Suggestion: be explicit about the trade-off. Either:
- Fail-safe: on label query failure,
continue(leave RUNNING) and let the running orchestrator's later reconciliation handle it. The genuinely-orphaned case is rare and already surfaceable elsewhere; the drift case is the one #2411 is actively about. - Keep the current behavior, but rename the fallback comment from "falling back to global live-id check" to something like "label query failed; reverting to pre-#2411 behavior — drift cases may still mark FAILED" so the next maintainer sees the cost.
Either way, add a test_label_query_failure_with_id_drift so the drift outcome under failure is pinned by a test rather than implicit.
Non-blocking — AWAITING_HUMAN+0-pending still ignores live pods
orchestrator/startup_reconciliation.py:98-128
The AWAITING_HUMAN-with-no-pending-decisions branch executes continue regardless of outcome (line 128) and runs before the new label-scoped pod check. A pipeline that's AWAITING_HUMAN with all decisions resolved will still be marked FAILED — and the operator's recovery path (start_pipeline) then resets containers=[], agents=[] and orphans any live pods.
The PR doesn't claim to fix this case, and the rationale ("the polling thread that would have picked up the resolution is gone") is defensible — but it's the same root cause as the bug the PR addresses, and operators reading the new start_pipeline tool description may not realize the AWAITING_HUMAN code path bypasses the live-pod safety net.
If you want to leave this for a follow-up, fine — but please add a one-line note in the docstring or in _handle_start_pipeline's description that the live-pod-orphan footgun applies to the AWAITING_HUMAN recovery path too, not just the FAILED+RUNNING combo.
Non-blocking — start_pipeline lacks a guard against orphaning live pods
orchestrator/mcp_tools.py:765-800 and orchestrator/routes/pipelines.py:16779-16810
The _handle_start_pipeline MCP verb wraps a route that unconditionally resets phase_execution.containers = [] and phase_execution.agents = []. The tool description warns operators ("Use cancel_task(cleanup=true) first or rely on the running orchestrator's reconciliation if the pipeline is genuinely alive"), but there's no programmatic check.
This is the inverse of the Bug A fix: Bug A prevents false-positive FAILED on live pipelines, but if a pipeline still gets to FAILED somehow (e.g. AWAITING_HUMAN→FAILED via the path above, or a user-triggered failure), start_pipeline will silently orphan whatever pods are still labeled to it. A defensive check at the route level (label-query the pipeline; refuse to reset unless force=true or no live pods) would close this loop. Tracking-wise this might fit better as a follow-up to Bug C (which already involves auditing pipeline-scoped pod cleanup), but flagging it here so it doesn't get lost.
Non-blocking — _make_docker_client default obscures intent
orchestrator/tests/test_startup_reconciliation.py:82-110
When pipeline_live_map is None, label-scoped queries return the same live_ids set as the global query. That's not how real k8s behaves (label-scoped is a strict subset). The default keeps legacy tests passing without modification, which is convenient — but for tests like test_returns_zero_when_container_still_live (line 146), the new label-check path now passes for a different reason than the original test was asserting.
This isn't a functional defect, but the helper's doc-comment ("preserves the pre-#2411 single-list behavior") understates the issue. Consider either (a) requiring pipeline_live_map for tests in this class so each test makes its own pod-label expectations explicit, or (b) defaulting label-scoped queries to [] and updating each legacy test to opt in to the live-pod set.
Nits
orchestrator/startup_reconciliation.py:29-33: the comment justifies duplicatingLABEL_PIPELINE_IDbecause "kubernetes client modules are not on sys.path… in unit tests that mock the docker client." Butkubernetes_client.pyonly imports thekubernetespip package inside method bodies, and lives in the same directory asstartup_reconciliation.py, so a top-levelfrom kubernetes_client import LABEL_PIPELINE_IDwould work in tests. The duplication is harmless (test atkubernetes_client.py:1966pins the literal), but the rationale in the comment is overstated.docs/reference/orchestrator-cli.md:238: the newstart_pipelinerow mentions "AWAITING_HUMAN with all decisions resolved" — fine — but doesn't surface that PENDING pipelines also flow through this route (lines 16498-16830 don't early-return for PENDING). Probably not worth documenting if PENDING+/startisn't a real operator workflow, but worth confirming the route's contract is intentional.orchestrator/mcp_tools.py:780-782: minor wording — "this resets the current phase's container/agent records before re-spawning" should make clear the reset is unconditional (not contingent on the records being verifiably stale). The current phrasing implies an intelligent reset.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Change label-query failure fallback from global-check to fail-safe (continue, leaving pipeline RUNNING). The previous fallback silently re-introduced the #2411 bug under degraded-cluster conditions. - Pin the drift-under-failure case with a new test to prevent re-introduction. - Replace _LABEL_PIPELINE_ID literal with import from kubernetes_client (the duplication rationale was overstated; the kubernetes pip package is only imported inside method bodies). - Default _make_docker_client label-scoped queries to [] to match real k8s semantics (label scope is a strict subset). Update legacy tests that need observable live pods to set pipeline_live_map explicitly. - Document AWAITING_HUMAN orphan-live-pods footgun in the start_pipeline MCP tool description, plus PENDING flow through the route. - Clarify that the route's container/agent reset is unconditional. - File #2420 to track the route-level orphan-pods guard (deferred).
|
Thanks for the thorough review. Per-item disposition: Non-blocking — fallback on label-query failure quietly re-introduces the bugfixed-in-PR (commit 0fc0575) — Took option 1: on label-query failure, Non-blocking — AWAITING_HUMAN+0-pending still ignores live podsfixed-in-PR (commit 0fc0575) — Documented the footgun in the Non-blocking —
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — all blocking concerns addressed
The new commit (0fc0575) fixes the substantive concern from the previous review and tightens documentation. Approving.
Verified
-
Fallback on label-query failure (was the closest-to-blocking item) —
orchestrator/startup_reconciliation.py:215nowcontinues instead of falling through withpipeline_live_ids = set(). The drift case under label-query failure no longer trips the pre-#2411 global check. The newtest_label_query_failure_with_id_drift_does_not_mark_failed(test file lines 391–420) pins this with the exact #2411 shape — persistedstale-from-before-restartvs globalnew-pod-uid-after-restart— so a future maintainer cannot accidentally re-introduce the silent fallback. -
_make_docker_clientdefault — Now defaults label-scoped queries to[], matching real k8s semantics. All previously-passing tests that needed a live-pod observation have been updated explicitly (test_returns_zero_when_container_still_live,test_dead_containers_in_prior_phase_not_marked_failed,test_pending_phase_with_containers_left_to_container_loop, bothTestStartupConsensusReconstructiontests). 28 startup-reconciliation tests + 5 start_pipeline MCP tests pass locally. -
_LABEL_PIPELINE_IDdeduplication — Replaced the literal withfrom kubernetes_client import LABEL_PIPELINE_ID as _LABEL_PIPELINE_IDatstartup_reconciliation.py:34. Verified the import is safe at module load time:kubernetes_client.pyonly imports thekubernetespip package inside method bodies (lines 154, 229, 693, 856), andfrom models import …is already required for the existingcli.pyimport path. Confirmed by importingstartup_reconciliationcleanly with onlyorchestrator/andshared/onsys.path. -
PENDING flow documentation —
docs/reference/orchestrator-cli.md:241andmcp_tools.py:778-780now both note "starts PENDING pipelines (no early-return for PENDING in the route)". Confirmed againstroutes/pipelines.py:16498-16830: none of the early-returns match PENDING, so it falls through topipeline.status = RUNNING+ thread launch. -
Reset wording —
mcp_tools.py:773-775now says "unconditionally resets the failed phase to PENDING (clearscontainers,agents,artifactsregardless of whether the records are verifiably stale)" — matches the route's lack of programmatic checks. -
AWAITING_HUMAN footgun documentation —
mcp_tools.py:781-795now spells out that the live-pod-orphan case applies to (a) the route'srequest_changes / change_approachreset branch (routes/pipelines.py:16704-16722, which setscontainers=[]/agents=[]) and (b) startup reconciliation's AWAITING_HUMAN→FAILED transition (which fires atstartup_reconciliation.py:99-129before the new live-pod safety net at line 202). The actual fix is correctly deferred to #2420.
Non-blocking
from kubernetes_client import LABEL_PIPELINE_ID as _LABEL_PIPELINE_IDlands atstartup_reconciliation.py:34— afterlogger = get_logger(...)at line 27 rather than at the top of the file. PEP 8 nit; matches the file's existing pattern (the conditionalegg_loggingimport block above also runs before the constant). Not worth churning.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
| egg is addressing review feedback... |
|
Thanks for the re-review. Per-item disposition: Non-blocking —
|
|
egg feedback addressed. View run logs 4 previous review(s) hidden. |
Add the new live-pod guard step (#2411) to the RUNNING pipeline reconciliation description in the orchestrator architecture doc. The reconciler now queries k8s for pipeline-scoped pods before marking a pipeline FAILED — if any pods are alive, the pipeline is left RUNNING and record drift is deferred to the running orchestrator. Triggered by: c733f2e (#2416) Authored-by: egg Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com>
* Fix #2420: guard start_pipeline against orphaning live pods Follow-up to #2411 / PR #2416. The `POST /pipelines/{id}/start` route unconditionally cleared the failed phase's `containers=[]`/`agents=[]`/ `artifacts={}` and re-launched a runner. If pods labeled to the pipeline were still alive (e.g. AWAITING_HUMAN→FAILED via the existing reconciliation path, a user-triggered failure, or any future code path that lands a FAILED state with live pods) the reset silently orphaned them. This adds a route-level safety guard that fires on both reset paths (FAILED recovery and AWAITING_HUMAN request_changes/change_approach): label-query k8s for `egg.pipeline.id=<id>`, refuse with 409 + `reason=live_pods_present` when any pod is alive, and require an explicit `force=true` (with optional `force_reason` audit note) to override. The label-query failure path also returns 409 + `reason=live_pod_check_failed` so the caller is aware that we couldn't verify zero — fail-safe is to refuse. The MCP `start_pipeline` tool forwards the new params; docs updated in `orchestrator-cli.md` (table, parameters list, error reason codes, and the recovery workflow example). ## Test plan - [x] `make lint` (ruff, format, mypy — all clean) - [x] `make test` — 16,436 tests pass, 41 skipped - [x] New tests in `test_start_pipeline.py::TestStartPipelineLivePodGuard`: - FAILED path refuses reset with `live_pods_present` when pods are alive - FAILED path with `force=true` overrides the guard - FAILED path with pod-check failure returns `live_pod_check_failed` - FAILED path with zero pods proceeds (green path) - Invalid `force_reason` returns 400 with `invalid_force_reason` - AWAITING_HUMAN request_changes branch is also guarded - AWAITING_HUMAN approve branch (no reset) is NOT blocked - FAILED with PENDING phase (no reset) is NOT blocked - [x] New tests in `test_mcp_tools.py::TestStartPipeline`: - Tool definition exposes `force` / `force_reason` - Default call (no force) sends no body - `force=true` and `force_reason` are forwarded to the route * Address #2436 review: filter terminal-phase pods + harden force flag Filter `_count_live_pods_for_pipeline` to count only pods in live phases (Pending / Creating / Running). The unfiltered count would trip the guard on Failed/Succeeded pods still inside the Job's ttlSecondsAfterFinished window (default 600s), false-positiving on the recovery hot path the guard exists to serve. Apply the same filter to startup_reconciliation.py so both label-scoped checks agree on the meaning of "live". Also addressing review non-blockers: - Strict-boolean force check: `body.get("force") is True` rather than `bool(...)` so non-boolean truthy values don't flip the predicate. - Template the force=true audit log on the live count, including an info-level no-op path when zero live pods are present. - Doc nit: clarify `details.live_pod_count` semantics and that `live_pod_check_failed` carries no `details.live_pod_count`. New tests: - TestCountLivePodsForPipelinePredicate: exercises the predicate against a stubbed backend with mixed-status ContainerInfos (covers the Failed-within-TTL case the unit suite was missing). - TestStartPipelineForceBooleanStrictness: confirms `"true"` and `1` are both rejected as non-bool by the route guard. - test_terminal_phase_pods_do_not_mask_orphaned_pipeline: covers the same filter in startup_reconciliation. — Authored by egg * Address #2436 re-review: doc clarifications + dedupe force-path warning --------- Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Fixes #2411.
Two coupled fixes for the orch-restart recovery surface that broke when
issue-2261-v8ran into a mid-flight orchestrator pod restart on 2026-05-05.Bug A — startup reconciliation marked live pipelines FAILED
orchestrator/startup_reconciliation.pyiteratesphase_execution.containersandphase_execution.agentsand compares each persistedcontainer_idagainst the global set of live container IDs. When a record's id isn't in that set, every dead record flips the whole pipeline toFAILED.After an orch restart, persisted
container_ids drift from the new orch process's view of the cluster (pods can be re-recorded with new uids, or the persisted snapshot lags the latest pod observation). The reporter saw 33 healthy sandbox pods divorced from their pipeline because a handful of records had drifted.Fix: before walking the in-memory records, query k8s for pods labeled
egg.pipeline.id=<id>. If any are alive, leave the pipeline RUNNING — record drift is the running orchestrator's job to reconcile, not startup's. Genuinely-orphaned pipelines (zero live pods labeled to them) still surface as FAILED via the existing path.Bug B — no MCP recovery verb for the FAILED + RUNNING-phase combo
The HTTP route
POST /pipelines/{id}/startalready handles this combo correctly (routes/pipelines.py:16779-16809resets the phase to PENDING and re-launches the runner). But the only MCP wrapper near it isstart_phase, which calls/phase/start(a different route) and 400s withphase_already_runningfor this exact combo. MCP-only callers had no recovery path; the operator in the report had tokubectl execand curl directly.Fix: add a
start_pipelineMCP tool that wraps the existing/pipelines/{id}/startendpoint. Distinct fromstart_phase(pipeline-level vs phase-level). Documented the distinction in the tool description and indocs/reference/orchestrator-cli.md.start_pipelineresetscontainers=[]andagents=[]on the failed phase before re-spawning, so calling it on a still-live pipeline orphans the live pods. The Bug A fix prevents the reporter's specific bait-and-switch (false-positive FAILED on a live pipeline), but operators recovering from a real FAILED pipeline that still has live pods shouldcancel_task(cleanup=true)first.Bug C (cleanup_pipeline missing 8/33 pods) — split out as follow-up
Filed as a separate issue per the user's direction. Investigation needs to confirm the slice-agent labelling hypothesis (whether per-slice agents are labeled with the umbrella
pipeline_idor a slice-scoped id, which would explain why label-keyed cleanup missed exactly 8 = 4 slices × 2 reviewers).Test plan
make lint(ruff, format, mypy — all clean)make test— 2,236 tests passorchestrator/tests/test_startup_reconciliation.py— 27 tests including 3 new ones:test_record_drift_does_not_fail_pipeline_when_pods_alive— exact Orchestrator startup-reconciliation marks live pipelines FAILED; no MCP recovery path #2411 scenariotest_label_query_failure_falls_back_to_global_check— defensive fallbacktest_label_query_returns_empty_marks_pipeline_failed— orphan path still worksorchestrator/tests/test_mcp_tools.py— newTestStartPipelineclass with 5 tests covering the tool definition, endpoint routing, URL encoding, and thestart_pipelinevsstart_phasedistinctionstart_pipelineMCP verb returns 200 and re-launches a recoverable pipeline