Skip to content

Address review feedback on #3645 (cancel stops the driver) - #3649

Merged
jwbron merged 2 commits into
issue-3633-cancel-stops-driverfrom
egg/issue-3633-review-feedback
Jul 26, 2026
Merged

Address review feedback on #3645 (cancel stops the driver)#3649
jwbron merged 2 commits into
issue-3633-cancel-stops-driverfrom
egg/issue-3633-review-feedback

Conversation

@james-in-a-box

Copy link
Copy Markdown
Contributor

Address review feedback on #3645

#3645's own review surfaced three blocking correctness bugs and six non-blocking issues. This branch carries the fixes. It targets issue-3633-cancel-stops-driver rather than main so it merges into #3645 before that PR lands — the gateway does not permit egg to push directly to a human-owned branch.

Blocking. _run_pipeline's finally deleted the worktrees CANCELLED is supposed to preserve, contradicting restart_phase's CANCELLED allowlist (#1725) and the PATCH route's preserve_worktrees flag; CANCELLED now joins FAILED on the skip_cleanup arm. _run_concurrent re-reads the cancel status immediately before spawn_all, so a cancel landing during the tens of seconds of prompt-building and worktree setup never mints a cohort nothing would reap, and the step-0 cancel bail now stops the containers it owns (deliberately not on the superseded_by_restart arm, where the restarted thread owns them, #3315). A cancel-driven non-zero phase exit is no longer recorded as a slice failure, so operators and SSE consumers stop seeing a FAILED slice and its downstream cascade for a clean cancel.

Non-blocking. _cancel_pipeline_in_process — the first-principles "Don't build" origin of CANCELLED — now stops the live BRC event loops too. The event-loop registry ImportError logs loudly instead of silently returning 0. The dead _pipeline_superseded_by_restart is deleted, its epoch comparison now living solely in _phase_bail_reason_impl, which resolves both bail conditions from one pipeline load per tick; its #3315 coverage is retargeted at the live implementation. Plus test-stub and route-dedup cleanups and a corrected BLE001 audit note.

Issue: #3633

Test Plan

  • Automated: tests/test_cancel_stops_driver.py, tests/test_ble001_narrowing_audit.py, tests/test_restart_phase_consensus_timer.py (29 passed); tests/test_decisions_routes.py, tests/test_cancel_async_cleanup.py, tests/test_resolve_contract_decision_route.py, tests/test_first_principles_reviewer.py (111 passed). make lint — ruff clean; the 3 mypy errors in shared/egg_agent/client.py are pre-existing and untouched here.
  • Manual: cancel a pipeline mid-implement and confirm (a) the agent worktrees survive, (b) restart_phase resumes from them, (c) the slice is not marked FAILED and no downstream cascade fires, (d) no orphaned Jobs remain.

Manual Steps

Authored-by: egg

Blocking:
- _run_pipeline's finally block deleted the worktrees CANCELLED is supposed to preserve, contradicting restart_phase's CANCELLED allowlist (#1725) and the PATCH route's preserve_worktrees flag. CANCELLED now joins FAILED on the skip_cleanup arm.
- _run_concurrent re-reads the cancel status immediately before spawn_all, so a cancel landing during prompt-building/worktree-setup never mints an orphaned cohort, and the step-0 cancel bail now reaps the containers it owns. Deliberately not done on the superseded_by_restart arm, where the new thread owns them (#3315).
- A cancel-driven non-zero phase exit is no longer recorded as a slice failure, so operators and SSE consumers stop seeing a FAILED slice (plus its downstream cascade) for a clean cancel.

Non-blocking:
- _cancel_pipeline_in_process (the first-principles 'Don't build' origin of CANCELLED) now stops the live BRC event loops too.
- The event-loop registry ImportError is logged loudly instead of silently returning 0.
- Deleted the now-dead _pipeline_superseded_by_restart; its epoch comparison lives solely in _phase_bail_reason_impl, which resolves both bail conditions from a single pipeline load per tick. Its #3315 coverage is retargeted at the live implementation.
- test_cancel_stops_driver's stub slice carries a real SchedulerSliceState and both calls thread run_epoch.
- Merged the two byte-identical CANCELLED blocks in the PATCH route, keeping both comment blocks.
- Corrected the BLE001 audit note (_stop_pipeline_event_loops, _phase_bail_reason_impl, _pipeline_cancelled) and moved the bound to 123: three sites added, one removed with the deleted predicate.
@james-in-a-box

This comment has been minimized.

@james-in-a-box

Copy link
Copy Markdown
Contributor Author
Autofix tracking
{"Lint/Custom Checks": 1}

@james-in-a-box

This comment has been minimized.

@jwbron
jwbron merged commit 7b2af26 into issue-3633-cancel-stops-driver Jul 26, 2026
17 checks passed

@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: PR #3649 — Address review feedback on #3645 (cancel stops the driver)

Note: this PR was already merged (2026-07-26T23:25:01Z) into issue-3633-cancel-stops-driver. The findings below are therefore follow-up work on that branch, before #3645 lands on main. Two of them are, in my read, blocking for #3645.

I traced every changed hunk plus the callers/callees around them. Several changes are genuinely correct and I say so at the end. But the PR's headline fix — "the cancel bail now reaps the agent cohort it owns" — does not do anything, and the change that is load-bearing has an asymmetry that reintroduces the exact operator-visible symptom the PR set out to remove.


BLOCKING 1 — _stop_running_containers() on the cancel bail is dead code (CONFIRMED)

orchestrator/routes/pipelines/_run_concurrent.py:512-513

if _bail_reason == "pipeline_cancelled":
    _stop_running_containers()

_stop_running_containers is a functools.partial bound over active_executions, which is built as:

executions = executor.spawn_all(...)
active_executions = [e for e in executions if e.container_id]

And spawn_all (orchestrator/concurrent_executor.py:554-562) ends with:

# #3164: the orchestrator unconditionally owns the BRC event loop
# and spawns a one-shot pod per actionable event — NO agents are
# spawned up front.
del agent_prompts
self._start_event_loop(roles, tracker)
return []

It always returns []. So active_executions is always [], and _stop_running_containers_impl (_run_concurrent_support.py:150) iterates an empty list and returns. The new line is a no-op on every code path, including the one it was added for. The same is true of all seven _stop_running_containers() call sites (513, 575, 770, 908, 1046, 1275, 1451) — but this PR is the one that added a new one and described it in the PR body as the fix for a review finding.

executor.stop_event_loop() on line 502 is real and does matter, but it only calls loop.stop() (event_loop/_loop.py:1011), which sets self._stop, unregisters the loop, and joins the thread. It does not delete or terminate live one-shot Jobs. The thing that actually reaps a cancelled pipeline's Jobs is kubernetes_spawner/_jobs.py:160 cleanup_pipeline, which removes every list_pipeline_jobs(pipeline_id) Job and tears down gateway sessions before the if preserve_worktrees: return early-out — and it is invoked from the driver's finally, not from here.

Fix: either drop the line and the if, or make it reap for real. If the intent is "the cancelling thread should not wait for the driver's finally to kill in-flight event Jobs," call the spawner's Job-removal path (spawner.list_pipeline_jobs(pipeline_id)remove_agent_job) rather than a partial over a list that #3164 permanently emptied. Whichever way you go, the PR body's claim that this bail "stops the containers it owns" should not survive as-is.


BLOCKING 2 — the same branch still records a slice failure on superseded_by_restart (CONFIRMED; pre-existing, amplified by this PR)

orchestrator/routes/pipelines/_run_implement.py:917-931

if exit_code_inner != 0:
    # An operator cancel is not a slice failure (#3633 review). ...
    # recording that as a failure would set the slice FAILED, arm the
    # downstream cascade, and publish SLICE_CLOSED(outcome="failed")
    # to the bus ...
    if _pkg._pipeline_cancelled(store, pipeline_id):
        ...
        return exit_code_inner, logs_inner
    scheduler.record_failure(slice_id)

This PR unified the two bail reasons into a single helper — _phase_bail_reason_impl now returns either "pipeline_cancelled" or "superseded_by_restart" from one store.load_pipeline — and then carved out only the first of them here.

superseded_by_restart fires when a restart_phase bumps run_epoch and a stale implement driver thread is still walking the DAG. That thread's _run_concurrent_phase_with_impasse_retry returns non-zero; _pipeline_cancelled is False (the pipeline is RUNNING again, on the new epoch); so record_failure(slice_id) runs and the slice_closed_emitter publishes SLICE_CLOSED(outcome="failed") on the pipeline-global event bus — for a slice the fresh run is at that moment actively re-running. Operators and SSE consumers see a failed slice for a clean restart. That is verbatim the symptom quoted in the comment above the carve-out.

It gets worse on the next tick: grep -n "run_epoch\|superseded" orchestrator/routes/pipelines/_run_implement.py returns only two hits — the parameter at line 20 and the pass-through at line 905. The slice loop's own top-of-tick guard (line 545) checks _pipeline_cancelled only. So after publishing the spurious failure, the stale thread loops back and keeps admitting ready slices against the superseded epoch — the "opening slice-3 two hours after a cancel" failure mode from the loop's own comment, with restart_phase substituted for cancel.

Fix: guard on the bail reason, not on cancellation. Have _run_one_slice_inner consult _pkg._phase_bail_reason_impl(store, pipeline_id, run_epoch=run_epoch) and skip record_failure for both reasons, and add the same call to the top-of-tick guard at line 545 so a superseded thread stops admitting slices. run_epoch is already threaded into this function — it is passed straight through at line 905 and otherwise unused.


BLOCKING 3 — no new tests for any of the four production behaviour changes (CONFIRMED)

The diff touches three test files and adds zero new test cases:

  • test_cancel_stops_driver.py (+12/−2) — stub realism only (SchedulerSliceState.READY instead of the string "ready"; run_epoch= threaded into two existing tests). All 15 tests assert pre-existing behaviours.
  • test_restart_phase_consensus_timer.py (+40/−20) — retargets an existing class from the deleted helper to the new one.
  • test_ble001_narrowing_audit.py (+15/−9) — bound bookkeeping.

Meanwhile the PR changes four production behaviours, each in blocking-severity territory: the pre-spawn cancel guard (_run_concurrent.py:309-327), the cohort reap (512-513), the slice-failure carve-out (_run_implement.py:917-931), and the CANCELLED worktree-preservation branch (_run_pipeline.py). None is pinned.

This is not a process complaint — a test would have caught Blocking 1. Any test that drives the guard through the real spawn_all (rather than a hand-built executions list) and asserts docker_client.stop_container was called would fail today, because spawn_all returns []. A fixture that hands the executor a synthetic list of executions with container_id set would pass and would be testing a code path that no longer exists in production — please don't build that one.

Existing homes for all four:

  • test_pipeline_failure_path.py:437 TestFailurePathPreservesWorktrees::test_worktree_cleanup_skipped_on_failure already asserts mock_gateway.delete_worktrees.assert_not_called() for FAILED. The CANCELLED counterpart is a copy of that test with one enum changed.
  • test_cancel_stops_driver.py is the obvious home for the pre-spawn guard and the slice non-failure carve-out.
  • _cancel_pipeline_in_process's new loop-stop needs one test asserting _stop_pipeline_event_loops is called on the first-principles "Don't build" path (_handlers.py:1176 is the only caller).

BLOCKING 4 — the cancel carve-out covers one of five record_failure sites in the same function (PLAUSIBLE for four of them; mechanism named)

_run_one_slice_inner calls scheduler.record_failure(slice_id) at lines 821 (integration-branch creation failed), 931 (the carved-out one), 952 (evidence gate), 985 (green gate), and 1278 (PR creation failed). Only 931 got the guard.

The cancel path runs cleanup_pipeline on a background thread, which tears down the pipeline's gateway sessions. Lines 821 and 1278 are both single gateway calls (create_slice_integration_branch, create_slice_pr) whose surrounding except ... # noqa: BLE001 handlers explicitly say "Catches GatewayError (HTTP/timeout) and OSError (DNS / socket). Treat as failure so the cascade machinery surfaces a missing-parent error." A cancel landing while a slice sits between the top-of-tick guard and one of those calls produces exactly the false FAILED slice + armed cascade + SLICE_CLOSED(failed) this PR exists to prevent.

I'm ranking this PLAUSIBLE rather than CONFIRMED because I did not verify that the driver's own agent_role="orchestrator" gateway calls ride a session that cleanup_pipeline tears down — if they use a separate long-lived orchestrator session, 821 and 1278 only trip on ordinary transient errors. 952 and 985 are lower risk: both gates fail-open on infra errors and fail-closed only on a definitive verdict.

Fix (cheap and covers all five): hoist the check into a small helper and call it at each site — if _slice_stopped_by_lifecycle(store, pipeline_id, run_epoch): return exit_code, logs — rather than open-coding it at one of five. That also gives Blocking 2 its fix for free.


Non-blocking

N1 — the pre-spawn guard's stop_event_loop() is unconditionally a no-op. _run_concurrent.py:326 calls executor.stop_event_loop() before spawn_all has run, so self._event_loop is still None and the method returns immediately. Harmless, but a reader will assume it's doing teardown. Either drop it or add a one-line comment saying it's defensive-only for a future reordering.

N2 — allowlist growth. Adding _run_concurrent.py (#3650) and _run_pipeline.py (#3651) follows the documented process — scripts/file-size-allowlist.yaml's header says new entries "should only be added (with a tracking issue) for files awaiting their own decomposition," and both issues are open with matching decomposition titles. So this is compliant, not a violation. It's still worth noting that ~46 lines, most of it comment prose, pushed two files over a 1,500-line hard cap, and that _run_concurrent_support.py (422 lines) is the file's own designated extraction target and sits well under the cap. Moving the guard bodies there instead of growing the allowlist would have been roughly the same diff size.

N3 — a dropped assertion and a now-misleading test name. test_restart_phase_consensus_timer.py's test_none_run_epoch_is_never_superseded lost store.load_pipeline.assert_not_called() in the retarget. With _phase_bail_reason_impl the load is no longer skippable (it must happen to check CANCELLED), so dropping the assertion is correct — but the remaining assert ... is None against a RUNNING pipeline can no longer distinguish "not superseded" from "no bail for any reason," which is what the name promises. Add the interesting new coupling the unification created: a CANCELLED pipeline with run_epoch=None must still return "pipeline_cancelled". That is untested today and is exactly the case the merge of the two helpers introduced.

N4 — _cancel_pipeline_in_process's docstring names the wrong owner. routes/decisions/_handlers.py:1074 says "this hook does NOT tear down containers; the driver's cancel bail and the operator's own cleanup own that half." Per Blocking 1, the driver's cancel bail does not tear down containers. The actual owner is _run_pipeline's finally_spawner.cleanup_pipeline. Worth correcting whichever way Blocking 1 is resolved. (I checked the event emission here and it's fine: emit_event(EventType.PIPELINE_CANCELLED, ...) is the same enum _emit_pipeline_event("pipeline.cancelled") maps to via _EVENT_TYPE_MAP at __init__.py:535, so SSE consumers and /status/wait long-pollers wake either way.)


Verified correct

Flagging these explicitly so they don't get re-litigated:

  • _routes_crud.py (+16/−19) is a genuine no-op dedup. _stop_pipeline_event_loops moved inside the pre-existing if pipeline.status == CANCELLED and prev_status != CANCELLED: block; I read lines 560-700 and the two conditions were identical. Correct simplification.
  • _run_pipeline.py's CANCELLED branch is right. skip_cleanup guards only delete_worktrees and is passed as preserve_worktrees=skip_cleanup to cleanup_pipeline, which still removes Jobs and gateway sessions — so preserving worktrees for restart_phase (#1725) does not leak pods. pipeline_was_restarted correctly wins via the preceding if.
  • Deleting _pipeline_superseded_by_restart is safe. Its only live caller (_run_concurrent_retry.py:143) was already on _phase_bail_reason_impl before this PR; only doc-comment references remain.
  • The BLE001 bound is exact. grep -rn "noqa: BLE001" orchestrator/routes/pipelines/*.py | wc -l = 123, matching the new bound, and the itemized +3/−1 accounting in the comment is accurate site-for-site.
  • _lifecycle_helpers.py's except ImportError now logs before return 0 — a silent import failure there was a real operator-facing blind spot. Good catch by whoever raised it.
  • The SchedulerSliceState.READY stub fix is the right direction: the old string stub would have kept passing after an enum rename.
  • ruff check clean; the three touched test files pass (29 tests). I did not run the full suite.

The dedup, the worktree branch, the helper deletion, and the BLE001 accounting are all good. Blocking 1 and 2 are what need to change before #3645 lands: one line that does nothing, and one guard that stops half of the condition it was unified from.

— Authored by egg

@james-in-a-box

Copy link
Copy Markdown
Contributor Author

egg review completed. View run logs

2 previous review(s) hidden.

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