Fix #2420: guard start_pipeline against orphaning live pods - #2436
Conversation
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
There was a problem hiding this comment.
Review summary
The intent is sound — adding a route-level guard to prevent start_pipeline's reset from orphaning labeled pods is the right defense for #2420. The structure (helper function + uniform guard at both reset sites + force / force_reason audit override + 409 reason codes + thorough test coverage of the route-level branching) is clean and matches the codebase's idioms.
But the guard's core predicate is wrong. _count_live_pods_for_pipeline counts every pod matching the label regardless of pod phase, which makes it fire false-positives in the most common operational case it is supposed to support. Details below.
Blocking
1. _count_live_pods_for_pipeline does not filter terminated pods — guard fires false positives in the typical recovery flow
orchestrator/routes/pipelines.py:917-933:
def _count_live_pods_for_pipeline(pipeline_id: str) -> int | None:
try:
spawner = _get_spawner()
pods = spawner.backend.list_containers(
labels={LABEL_PIPELINE_ID: pipeline_id},
)
return len(pods)
except Exception as e:
...Trace:
spawner.backendresolves toKubernetesClient(kubernetes_spawner.py:353-359; thedocker_clientshim is just an alias).KubernetesClient.list_containers(kubernetes_client.py:488-560) callscore_api.list_namespaced_pod(label_selector=...). It does not filter onpod.status.phaseand theallparameter is documented as ignored. It returns every pod matching the selector, including pods inFailedandSucceededphases.- Job creation defaults
ttlSecondsAfterFinished=600(kubernetes_client.py:339). After a phase fails, the pod object survives in the cluster for up to 10 minutes before the Job's TTL controller GCs it.
Concrete failure mode:
- Pipeline runs implement; coder pod exits non-zero; phase marked FAILED; pipeline marked FAILED.
- Coder Pod object stays in the cluster in phase
Faileduntil the Job's TTL elapses (~10 min by default). - Operator runs
start_pipelineto recover (the documented recovery path — and the most common one this guard exists to serve). - Guard sees 1 pod with
egg.pipeline.id=<id>, returns 409live_pods_presentwithlive_pod_count=1, even though the pod has already exited and there is nothing to orphan. - Operator's only recourse is
force=true— at which point the guard is a no-op for the entire post-failure-within-TTL window. The "Cancel them first viacancel_task(cleanup=true)" advice in the error message is misleading: there's nothing live to cancel.
The PR's own description and the issue (#2420) are unambiguous that the predicate should be "live pods" — pods that would actually be orphaned by clearing containers=[] / agents=[]. A Failed or Succeeded pod has already exited; the reset orphans no work. Counting those pods inverts the relationship the guard is trying to establish.
This makes the guard counter-productive for its intended hot path. The end-to-end test in the PR description (the one unchecked checkbox) is exactly the case that would have caught this; the unit tests all mock _count_live_pods_for_pipeline and never exercise the predicate against a pod with a terminal phase.
Fix: filter by ContainerStatus, e.g.
from models import ContainerStatus
_LIVE_STATUSES = (
ContainerStatus.PENDING,
ContainerStatus.CREATING,
ContainerStatus.RUNNING,
)
def _count_live_pods_for_pipeline(pipeline_id: str) -> int | None:
try:
spawner = _get_spawner()
pods = spawner.backend.list_containers(
labels={LABEL_PIPELINE_ID: pipeline_id},
)
return sum(1 for p in pods if p.status in _LIVE_STATUSES)
except Exception as e:
..._pod_phase_to_status (kubernetes_client.py:84-93) already maps Failed/Succeeded/Unknown to terminal ContainerStatus values, so this filter is a single line and does not require extra k8s calls.
Add a unit test that exercises the real helper against a MagicMock spawner.backend whose list_containers returns a mix of RUNNING and FAILED ContainerInfos and asserts the count is the running-only number. That's the test the PR description's unchecked end-to-end was implicitly relying on.
Note: startup_reconciliation.py:202-206 uses the same unfiltered pattern. That's pre-existing — but per the review rules, since the PR is adding a second consumer of this predicate and #2411's PR (#2416) was the immediate prior touch on the same file, fix startup_reconciliation.py in the same pass (or extract the filter into a single shared helper used by both) so the two label-scoped pod checks agree on the meaning of "live". Otherwise the false-positive is just relocated, not eliminated.
Non-blocking
2. bool(body.get("force", False)) accepts arbitrary truthy values
orchestrator/routes/pipelines.py:16736:
force = bool(body.get("force", False))A request body like {"force": "false"} or {"force": []} flips the predicate's behaviour silently. The complete_phase / advance_phase audit pattern this is supposed to mirror tends to be stricter (is True or explicit boolean coercion). Suggest force = body.get("force") is True to match a true boolean explicitly, mirroring how force_reason is type-checked two lines below.
3. force=true warning text is misleading when there are no live pods
orchestrator/routes/pipelines.py:947-961:
if force:
live = _count_live_pods_for_pipeline(pipeline_id)
logger.warning(
"start_pipeline force=true override; phase reset will proceed "
"even if live pods are labeled to the pipeline",
...
)The static message states unconditionally that pods may be live. When the helper returns 0, the operator audit log claims the override mattered when it didn't. Either skip the warning when live == 0 (downgrade to info) or template the message based on live, e.g. "start_pipeline force=true override applied (live_pod_count=%s)". The structured live_pod_count=live field is fine; only the static message is the problem.
Also: _count_live_pods_for_pipeline will emit its own logger.warning("start_pipeline live-pod check failed", ...) when the k8s API errors and force=true. In the override path that warning is just noise — the operator already opted out. Consider downgrading the log on the force=true path or threading a quiet flag through.
4. live_pod_check_failed returning 409 rather than 503
orchestrator/routes/pipelines.py:964-973. The other 409s on this route describe pipeline state conflicts. A k8s API failure isn't a conflict — it's an upstream-service problem the operator has no per-pipeline action for. 503 (with the same reason code) would be more accurate and gives operators a cleaner "is this a per-pipeline issue or a k8s outage" signal. Not a blocker since the response is internally consistent with the rest of the route's "all gates are 409" convention, but worth aligning if the team has a stance.
5. Test plan: end-to-end checkbox is unchecked
The unchecked "End-to-end: simulate a FAILED pipeline with live pods labeled to it…" in the test plan is the test that would have caught issue #1. The unit tests deliberately mock _count_live_pods_for_pipeline (the autouse fixture at test_start_pipeline.py:58-68 and the per-test with patch(...) calls), so nothing exercises the production predicate against real ContainerInfo shapes. Add a unit-level test of _count_live_pods_for_pipeline against a stubbed spawner backend that returns mixed-status ContainerInfos before merging — the end-to-end can come later, but the unit-level coverage of the predicate itself shouldn't ship blank.
6. Guard fires on AWAITING_HUMAN request_changes path even when phase is COMPLETE
orchestrator/routes/pipelines.py:16931-16944. The request_changes branch resets the phase only if its current status is one of (COMPLETE, FAILED, RUNNING, AWAITING_HUMAN). A COMPLETE phase has, by construction, finished all its agents — there is nothing to orphan. Combined with issue #1, this path will frequently 409 on a recently-completed phase whose pods are still in the cluster within Job TTL, even though no live work exists. Once #1 is fixed (filter to running/pending), this becomes moot. Flagging it because the issue and the in-code comment both speak in terms of "live pods", and COMPLETE phase is by far the most common request_changes input.
7. Doc nit: details.live_pod_count semantics
docs/reference/orchestrator-cli.md:269:
live_pods_present| 409 | …details.live_pod_countcarries the count
After the fix in #1 this should read "carries the count of pods in Pending / Running phase" or similar, so operators know what they're looking at. The live_pod_check_failed path doesn't include details.live_pod_count; calling that out explicitly in the docs would also help operators write retry logic.
What's right
- Both reset sites are guarded uniformly, including the AWAITING_HUMAN→request_changes branch the issue explicitly flagged.
- The
force/force_reasonshape mirrorscomplete_phaseand the structured warning logging is the right audit primitive. make_error_response(reason=...)extension is backward-compatible (kwarg-only, defaultNone); existing call sites keep working.- Tests cover the AWAITING_HUMAN approve branch correctly skipping the guard (no orphan risk → no guard fire), the FAILED-with-PENDING-phase case correctly skipping (no reset → no guard), and the route's
force=trueoverride path. The unit-level branching coverage is good; only the predicate itself is missing. - MCP tool definition + handler thread
force/force_reasoncleanly. The "no body when force omitted" test is a thoughtful guard against accidental wire-format churn.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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
Review feedback dispositionThanks for the careful read. Pushed Item 1 —
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: blocker resolved
a1f1408 lands the fix for the predicate that was the sole blocker in my prior review. The implementation is clean, the disposition table is honest, and the new test coverage hits the exact false-positive case the unfiltered predicate produced. Approving.
Verification of prior items
Item 1 — predicate filtering (BLOCKING) — fixed
_count_live_pods_for_pipeline (orchestrator/routes/pipelines.py:919-963) now sums against the new _LIVE_POD_STATUSES = (PENDING, CREATING, RUNNING) tuple. Pods in FAILED / EXITED / REMOVED are correctly excluded — verified by exercising the real helper through TestCountLivePodsForPipelinePredicate (test_start_pipeline.py:1008-1104) with mixed-status ContainerInfos, including the exact "only terminal pods → count 0" case I called out (test_zero_when_only_terminal_pods).
The class-level _zero_live_pods fixture override at test_start_pipeline.py:1017-1024 correctly bypasses the module-level autouse patch — confirmed by the predicate tests passing while exercising the real helper.
startup_reconciliation.py:206-220 applies the same inline filter with a doc-cross-reference back to the routes helper, and test_terminal_phase_pods_do_not_mask_orphaned_pipeline covers the symmetric case there. The duplication-vs-extraction call is reasonable for a 3-line tuple.
I checked — _LIVE_POD_STATUSES now appears inlined in three places: routes/pipelines.py:931, startup_reconciliation.py:213, and kubernetes_monitor.py:489. The third is pre-existing. Three is enough that a shared models._LIVE_POD_STATUSES (or a helper on ContainerStatus) would be the cleaner long-term home — flagging non-blocking; it's not worth bouncing the PR for.
Item 2 — strict-boolean force — fixed
orchestrator/routes/pipelines.py:16783 now reads body.get("force") is True. The new TestStartPipelineForceBooleanStrictness (test_start_pipeline.py:1107-1168) confirms "force": "true" and "force": 1 both fall through to the guard and 409 with live_pods_present. The existing test_failed_path_force_overrides_live_pod_guard at test_start_pipeline.py:821 already covered the actual-True happy path, so the strictness is now bracketed on both sides.
Item 3 — audit log templating — fixed
_guard_live_pods_or_force (orchestrator/routes/pipelines.py:978-1006) now branches the static message on live is None / live > 0 / live == 0, with live == 0 correctly downgraded to info so it doesn't read like a near-miss. The structured live_pod_count field is preserved in all three branches.
Item 4 — 409 vs 503 — reasonably disagreed
The reasoning ("every gate on this route returns 409 with a reason code, callers switch on reason rather than HTTP status, and a single status family across the route gives operators a cleaner mental model") is sound. I'd rather see a route-wide flip than a one-off divergence too. Carrying the disagreement forward as-is.
Items 5–7 — fixed
Predicate-level test coverage added (item 5); item 6 is moot per the item 1 fix; doc text now distinguishes the live_pods_present / live_pod_check_failed schemas (item 7).
Non-blocking observations on the new code
-
Doc terminology vs
_LIVE_POD_STATUSES—docs/reference/orchestrator-cli.md:269says "in a live phase (Pending/Running)", but_LIVE_POD_STATUSESincludesCREATING. On k8s_pod_phase_to_status(kubernetes_client.py:84-93) never returnsCREATINGso operator-facing language matches the k8s pod phase model, but on the Docker runtime pathCREATINGis a real transient state. Consider either listingCreatingalongsidePending/Runningin the docs or noting thatCreatingis an internal transient state mapped from neither k8s phase. -
Double
live-pod check failedwarning onforce=true— whenforce=Trueand the label query errors, both_count_live_pods_for_pipeline(line 958) and thelive is Nonebranch in_guard_live_pods_or_force(line 984) emit warnings. Not incorrect, just slightly noisy in the audit trail; my prior review item 3 mentioned this and the disposition only addressed thelive == 0near-miss. Aquiet=forceflag threaded through the helper, or simply deleting the helper-level warning when the caller is the guard (since the guard now logs the full audit context), would tighten this. Defer to taste. -
Unknownk8s phase semantics —_pod_phase_to_statusmaps"Unknown"toContainerStatus.FAILED, so a pod on an unreachable node is excluded from the live count and the reset proceeds. This is consistent with the rest of the orchestrator (same predicate is used inkubernetes_monitor.pyandstartup_reconciliation.py), and arguably correct on the recovery hot path — but operators recovering a pipeline whose pods are on an unreachable node would silently get the reset rather than alive_pods_present409. Worth a doc sentence somewhere if this isn't already noted in a runbook; flagging because the semantics change relative to the unfiltered predicate (which would have counted them).
What's right
- The disposition table is item-by-item, references commit
a1f1408, and either marks each prior item fixed-in-PR with file:line citations or names the explicit disagreement (item 4) with rationale. That's the right shape for a re-review. - The autouse-fixture-override pattern (
test_start_pipeline.py:1017-1024) is a clean way to bypass the module-level live-pod patch for the predicate-coverage tests without disturbing every other test in the file. - The doc note on
live_pod_check_failedcarrying nodetails.live_pod_countis exactly the kind of contract-level call-out that helps operators write retry logic — the kind of doc nit I'd otherwise have to re-flag. test_terminal_phase_pods_do_not_mask_orphaned_pipelineextends_make_docker_clientwith apipeline_live_statusparameter (defaulted toRUNNINGso existing tests stay green) and exercises the symmetric reconciler path. That's the right shape for the dual filter.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Re-review feedback dispositionThanks for the approval and the three follow-up observations. Pushed Observation 1 — Doc terminology vs
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: all three observations resolved
7b4f399 addresses all three non-blocking observations from my prior approval. The delta is two surgical changes (doc clarifications + a threaded quiet flag) plus two predicate-level tests. Approving.
Verification of prior items
Observation 1 — Doc terminology vs _LIVE_POD_STATUSES (Creating) — fixed
docs/reference/orchestrator-cli.md:269 now reads "in a live phase (Pending / Running, plus the orchestrator-internal Creating transient — never observed on k8s, where _pod_phase_to_status maps Pending/Running/Failed/Succeeded/Unknown only…)". I cross-checked against kubernetes_client.py:84-93 — the listed mapping matches _pod_phase_to_status exactly. The Creating qualifier accurately captures that it's the Docker-runtime transient, not a k8s phase, so operators on the k8s path don't go looking for a phase that never appears there.
Observation 2 — Double live-pod check failed warning on force=true — fixed
_count_live_pods_for_pipeline (orchestrator/routes/pipelines.py:938) now accepts quiet: bool = False. The guard's force=True branch (orchestrator/routes/pipelines.py:988) passes quiet=True because its live is None audit log already carries the same context. The non-force caller at pipelines.py:1017 keeps the default quiet=False so operators retain the helper-level warning when no guard-level audit context fires (the no-force path returns 409 live_pod_check_failed and doesn't itself log).
Test coverage in TestCountLivePodsForPipelinePredicate (test_start_pipeline.py:1106-1149) brackets both behaviours:
test_quiet_suppresses_helper_warning—quiet=True+RuntimeErrorfromlist_containers→mock_logger.warning.assert_not_called().test_loud_default_emits_helper_warning— default + same failure → exactly onelogger.warning(...)whose first positional arg contains"live-pod check failed".
I ran both new tests directly against the real helper — they pass and exercise the production code path (not a mocked predicate). The patch("routes.pipelines.logger") choice over caplog is correct: the egg EggLogger has propagate=False, so caplog would not capture these emissions.
Observation 3 — Unknown k8s phase semantics — fixed
docs/reference/orchestrator-cli.md:269 now states explicitly: "pods on unreachable nodes report phase Unknown and are mapped to FAILED, so they are excluded from the live count — the reset will proceed silently for these. If a pipeline's pods are on an unreachable node, manually verify before relying on a zero count." That captures the recovery-hot-path tradeoff and gives operators an explicit pointer rather than discovering it at incident time.
Non-blocking observation: shared _LIVE_POD_STATUSES — reasonably deferred
The disposition explicitly defers extraction to a follow-up. I flagged it as non-blocking and the duplication is contained (3 inline tuples at routes/pipelines.py:931, startup_reconciliation.py:214, kubernetes_monitor.py:490, all cross-referenced and tested for the symmetric case). Carrying the deferral forward as-is.
New code review (no blocking issues)
- The
quietplumbing is minimal and only changes theexceptarm of the helper. The successful-path return value is unchanged. No risk of masking a unit failure. - The default
quiet=Falsepreserves existing semantics for every non-guard caller (currently only the no-force path inside the guard itself, but the surface stays loud-by-default for any future caller). - The doc text is dense but accurate. I'd consider trimming it in a future doc pass, but it conveys the right semantics for operators reading the table cell directly.
- The audit-log dedupe is the correct fix for the noise: there is now exactly one
logger.warning(...)perforce=True + label-query-failureinvocation, sourced from the guard with the full audit context (pipeline_id,live_pod_count=None,force_reason).
What's right
- The disposition itemizes each observation with a commit reference and either marks it fixed-in-PR with file:line citations or names the explicit deferral with rationale. Right shape for a re-review.
- The
quietflag preserves the warning for non-guard callers (correct default) and only suppresses it where the guard is going to emit equivalent context (correct override). That's the asymmetric-but-defensible design — opt-in suppression rather than opt-out emission. test_quiet_suppresses_helper_warningandtest_loud_default_emits_helper_warningare bracketing tests on the same predicate, which is the right shape for a behavioural flag likequiet.
— Authored by egg
|
egg review completed. View run logs 6 previous review(s) hidden. |
Fixes #2420.
Follow-up to #2411 / PR #2416. Adds a route-level safety guard before the
POST /pipelines/{id}/startreset clears the current phase'scontainers/agents/artifacts.Problem
start_pipelineunconditionally resets the failed phase and re-launches a runner. If pods labeled to the pipeline are still alive when this fires — for example via the existing AWAITING_HUMAN→FAILED reconciliation path (startup_reconciliation.py:99-128), a user-triggered failure, or any future code path that lands FAILED with live pods — the reset silently orphans whatever pods remain. PR #2416 prevented the most common false-positive (live pipeline marked FAILED at startup), but the recovery path itself still compounds the damage when a real live-pod-on-FAILED state arises.Fix
Label-query k8s for
egg.pipeline.id=<id>before either reset site:pipelines.py~17012)pipelines.py~16934)Refuse with 409
reason=live_pods_present(details.live_pod_countcarries the count) when any pod is alive. 409reason=live_pod_check_failedwhen the label query itself fails — fail-safe rather than risking the orphan. Passforce=true(with optionalforce_reasonaudit note, validated as string withreason=invalid_force_reason) to override; the override is logged structurally for audit.The MCP
start_pipelinetool forwards the new params. Docs updated inorchestrator-cli.md: table description, parameters list, error reason codes, and the recovery workflow example. The pre-existingmake_error_responseinroutes/pipelines.pywas extended with areasonkwarg to match the canonical pattern fromroutes/phases.py.The guard does not fire on the AWAITING_HUMAN approve branch (which advances the phase rather than resetting) or on a FAILED pipeline whose current phase is already PENDING (no reset would happen) — those paths have no orphan risk.
Test plan
make lint(ruff, format, mypy — all clean)make test— 16,436 tests pass, 41 skipped, 75 deselectedtest_start_pipeline.py::TestStartPipelineLivePodGuard(8):live_pods_presentwhen pods are aliveforce=trueoverrides the guardlive_pod_check_failedforce_reasonreturns 400 withinvalid_force_reasontest_mcp_tools.py::TestStartPipeline(4):force/force_reasonforce=trueis forwarded to the routeforce_reasonrides along withforce=truestart_pipelinereturns 409 withreason=live_pods_present, thenstart_pipeline(force=true, force_reason="…")proceeds and the structured warning lands in the orchestrator log— Authored by James Wiesebron