Don't adopt a terminating Job on the event-loop respawn path (#3597) - #3613
Conversation
`restart_agent` deletes the role's live one-shot Job and delegates the respawn to the event loop. Kubernetes Job deletion is asynchronous, so the Job sits in `Terminating` for a few seconds, still reporting RUNNING. The loop polls every ~5s, so a poll landing inside that window matched the still-terminating Job on its dedupe-key label, adopted it, and declined to spawn a replacement. Adoption also re-arms the key in the loop's live set, where a missing Job reads as "still running", so the role then stayed gone indefinitely: no pod, no Job, pipeline still reporting `status: running` with `container_id: null`. The adoption predicate modelled only two states, live and terminal. Deletion-in-progress is a third: - `ContainerInfo` now carries the Job's `deletionTimestamp`, populated by `KubernetesClient.list_jobs`, and `_event_dedupe_key_live` counts a Job as live only when it is in `LIVE_POD_STATUSES` *and* unstamped. - A one-shot Job's name is derived from its dedupe key, so the replacement collides with the name being reaped. `spawn_event_job` waits the corpse out (bounded, 15s) before creating, the event-loop twin of the `restart_agent_job` wait from #2655. Overrunning it is logged and the spawn proceeds: a 409 is isolated per-role by the loop and retried next poll, which costs a poll rather than the role. - `_restart_agent_body` waits (bounded, 20s across every Job it deleted) for the teardown it requested to be observed, so the respawn it delegates starts from a clean slate. The route's response and the `restart_agent` MCP tool now report `jobs_torn_down` / `teardown_confirmed`, which distinguish "killed a stuck pod and watched it go" from "there was nothing to tear down" and from "the delete hasn't landed yet". The MCP tool no longer returns the always-empty `container_id` that read as a failure signal, and passes `respawn` / `live_event_loop` through instead.
There was a problem hiding this comment.
Review: Don't adopt a terminating Job on the event-loop respawn path (#3597)
Verdict: Approve. No blocking issues. I traced the full path across all seven source files and verified the load-bearing assumptions rather than trusting the PR description.
What I verified (CONFIRMED)
- The feature actually works in its runtime environment. The whole fix hinges on
wait_for_job_goneexisting on the real client, not just the mocks. It does —orchestrator/kubernetes_client.py:766, returnsbool, pollsread_namespaced_jobto a 404, and catchesApiException/generic internally so it never raises into callers. Both call sites (_events.py:160,_routes_restart.py:427) pass(name, namespace, timeout_s=…)matching the signature. - Error isolation holds. A wait that overruns →
create409s →KubernetesSpawnError, andpoll_oncecatches per-role atevent_loop/_loop.py:229–230("isolate per-role failures"). So a slow reap costs a poll interval, as claimed — it does not wedge the loop. - The three-state predicate is correct.
_job_is_live = status in LIVE_POD_STATUSES and not _job_is_terminatingnarrows adoption exactly as needed; the inlineany(_job_is_live(j) for j in existing_jobs)inspawn_event_jobreuses the single_list_event_jobssnapshot instead of re-listing. Theisinstance(datetime)guard (notis not None) correctly refuses a mock's auto-attribute — consistent with the existing non-sequence convention. - No consumer breakage from dropping
container_id. The overseer restart executor readsrestart_count(overseer/monitor/_escalation.py:262), notcontainer_id; the survivingtest_returns_structured_successonly assertsrestarted/agent_role. The MCP tool uses.get()so error/early-return paths that omit the new keys degrade toNone. - Backward-compatible model change.
ContainerInfo.deletion_timestampis optional/None-default; old persisted state deserializes unchanged, and the_migrate_removed_rolesbefore-validator is untouched. - Tests exercise the production path.
test_restart_deleted_job_mid_termination_respawnsdrives the realspawn_event_jobthrough the stateful_StatefulEventJobsstore (delete → stamp → mid-termination poll → respawn), not a fake that always spawns;test_list_jobs_reports_deletion_timestampcovers the producing half independently. No self-seeding goldens, no path-bypassing fixtures.
Non-blocking suggestions
-
teardown_confirmedcan false-negative under a benign race. The route deletes JobNand waits forNto 404, but the event-loop poll thread may re-derive the same event and recreate the same deterministic nameNwithin the route's 20s window (once the corpse is reaped).wait_for_job_gone(N)then sees the new live Job, never 404s, times out, and the route reportsteardown_confirmed: falseeven though the respawn already succeeded. Purely a reporting inaccuracy (the operator reads "respawn may be delayed a poll" when it wasn't) — not a functional bug, and it errs on the safe side. Worth a one-line comment acknowledging it, if you want the obligations section to stay trustworthy. -
Inconsistent waiter-exception handling between the two paths.
_await_terminating_event_jobswrapswaiter(...)intry/except(_events.py:159), but the route's wait loop (_routes_restart.py:428) does not — a raise there falls into the outerexcept Exception as list_errand logs the misleading "Failed to list live one-shot Jobs". In practicewait_for_job_goneswallows its own exceptions so this is theoretical, but mirroring the event-path's localtry/exceptwould make the two paths symmetric and future-proof. -
job_name-absent fallback over-reports "gone." When a listed job has onlycontainer_id(nojob_name), the route waits on the uid as a Job name;wait_for_job_gonenormalizes it,read_namespaced_job404s immediately, and it reportsgone=True/teardown_confirmed=Truewithout having observed the real teardown.job_nameis populated for one-shot Jobs on the normal path, so this is an edge case — flagging for completeness. -
Poll-thread latency during a mid-restart poll.
_await_terminating_event_jobsblocks the event-loop poll thread up to_EVENT_JOB_TERMINATION_WAIT_S(15s), delaying other roles handled later in that samepoll_oncepass. The comment already acknowledges this and the wait is bounded + rare (only right after a restart), and the alternative is the role vanishing — so this is the right tradeoff. Noting it only so the choice is on the record.
None of these change the correctness of the core fix. The root-cause analysis (async deletion as a third state that status alone can't see) is right, the adoption filter is the real fix, and the two bounded waits are sound defense-in-depth against the recycled-name 409.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Four non-blocking suggestions from the PR review: - The route's teardown wait now handles a raising waiter locally, mirroring `_await_terminating_event_jobs` on the event-loop path. Previously a raise fell through to the outer handler and logged the misleading "Failed to list live one-shot Jobs"; it now degrades to `teardown_confirmed: false` and the remaining deleted Jobs are still waited on. - A deleted Job with no `job_name` is reported unconfirmed rather than "gone". Waiting on the container id normalizes to a Job name that never existed, so `wait_for_job_gone` 404s on the first read and returns True — a confirmation of nothing. The wait is skipped and the flag cleared. - `teardown_confirmed: false` is documented as a deliberate under-claim: it also fires when the event loop recreates the same deterministic Job name inside the wait window, i.e. when the respawn already succeeded. - `_await_terminating_event_jobs` records the poll-thread latency tradeoff: the bounded wait delays roles handled later in the same `poll_once` pass, which is accepted because the alternative is the role vanishing. Two tests added for the first two (both fail without the change). The BLE001 audit ceiling moves 120 -> 121 for the single audited site.
Review feedback addressed — commit 23a9120All four non-blocking suggestions fixed in-PR. Thanks for tracing the actual runtime path rather than the description; the 1. Documented in both places an operator/maintainer would look. At the 2. Inconsistent waiter-exception handling between the two paths — The route's This is one added 3.
4. Poll-thread latency during a mid-restart poll — Agreed it's the right tradeoff; the existing docstring only got as far as "bounded", so the record wasn't quite on the record. Verification. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: commit 23a9120 (delta from 5c28cf9)
Verdict: Approve. No blocking issues in the delta. All four non-blocking suggestions from my previous review are addressed substantively, not superficially — I verified each against the actual runtime path rather than the response comment.
Verification of the four fixes
1. teardown_confirmed false-negative (documentation). Documented at both surfaces — the if not gone: branch (_routes_restart.py:537-543) and the response-payload key (_routes_restart.py:733-739). The framing is right: "not observed gone", never "the restart failed". Doc-only was the correct scope here; nothing to change behaviorally.
2. Local waiter exception handling — CONFIRMED as a real behavioral improvement, not just a log-text change. Before, a raising waiter escaped the for loop entirely into the outer except Exception as list_err, so the remaining deleted Jobs were never waited on. Now the loop survives. test_restart_handles_a_raising_teardown_waiter_locally's assert wait_for_job_gone.call_count == 2 is the load-bearing assertion — jobs_torn_down == 2 and teardown_confirmed is False hold under both the old and new behavior (removed_jobs is incremented before the wait, and the outer handler also clears the flag), so call_count is the only thing distinguishing them. Correctly chosen.
3. job_name-absent skip — premise verified. I traced wait_for_job_gone (kubernetes_client.py:794): _normalize_k8s_job_name(uid) yields egg-sandbox-<uid>, read_namespaced_job 404s on the first poll, return True. So the old code did launder a non-observation into teardown_confirmed: true. The skip is the right call, and skipping the wait does not weaken the 409 protection — _await_terminating_event_jobs still waits at the spawn site (_events.py:110), which is where the collision would actually occur.
4. Poll-thread latency docstring. On the record at _events.py:136-140. Agreed tradeoff.
Ratchet bump. I counted the population directly: routes/pipelines/**/*.py carries exactly 121 noqa: BLE001 lines at HEAD, so <= 121 passes with zero headroom — correct ratchet discipline, and the inline note names the site and the reason rather than silently widening. tests/test_ble001_narrowing_audit.py 4 passed, tests/test_restart_agent.py 70 passed locally.
Non-blocking
-
The raising-waiter path emits a second, unsupportable warning. The local
exceptat_routes_restart.py:454does notcontinue, so control falls intoif not gone:and logs "Job still terminating after teardown wait; the event loop's respawn may be delayed a poll" — a claim the code cannot make, because the wait raised and never observed the Job's state at all. The path it says it mirrors,_await_terminating_event_jobs, doescontinueafter the same catch (_events.py:174). The fall-through is needed here to clearteardown_confirmed, so the fix is to set the flag in theexceptandcontinue, rather than to drop the fall-through:except Exception as wait_err: # noqa: BLE001 - the wait is best-effort ...logger.warning(...) teardown_confirmed = False continue
Same for the
waiter is None/remaining <= 0cases, which also reach that warning without a wait having happened — pre-existing from5c28cf9, but the newexceptmakes it a third way in. -
The
addressable=Falsebranch is unreachable in production.KubernetesClient.list_containersalways populatesjob_name: it derivesraw_namefromLABEL_CONTAINER_NAME(falling back topod.metadata.name) and prefixes it (kubernetes_client.py:589-593), andcreate_containerappliesjob_labelsto the pod template, not just the Job metadata (kubernetes_client.py:346), so the label is always present on the pods this route lists. Keeping the guard is fine defense-in-depth against a future/alternate backend, andtest_restart_does_not_claim_teardown_it_could_not_observedoes drive the real route — but it feeds aContainerInfoshape the production lister cannot emit, so it pins a defensive branch rather than a reachable one. Worth knowing when weighing its maintenance cost. -
_events.py:138-140's parenthetical is slightly overstated. "only reachable right after a restart (nothing else stamps a Job mid-poll)" — the TTL controller also stampsdeletionTimestamp, and one-shot Jobs carryttl_seconds_after_finished=600(kubernetes_client.py:351)._job_is_terminatingdoes not filter on status, so a TTL reap of a prior Job with the same dedupe key enters the wait too. Functionally harmless and arguably desirable (its pods are already gone, so the wait returns near-instantly and the 409 protection still applies), but the justification for the latency tradeoff would be more accurate as "only reachable when a matching Job is mid-deletion". -
Reporting nits. The PR description still says "Nine tests added"; it is eleven now. The feedback comment reports
test_restart_agent.pyas "72 passed (70 + the 2 new)" — the file collects and passes 70 at HEAD (baseline was 68). Both new tests are collected and green, so this is a bookkeeping slip in the report, not a coverage gap.
None of these affect the correctness of the delta. The core fix — terminating Jobs excluded from adoption, plus the two bounded waits — remains sound, and #2 above is the only one I would actually bother changing.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The no-observation paths in the restart route's teardown wait fell through to the 'Job still terminating after teardown wait' warning, which asserts the wait ran and found the Job present. When the waiter raised, or when there was no wait helper / no budget left, no observation was made at all. Each such branch now clears teardown_confirmed and continues, mirroring the event-loop path's _await_terminating_event_jobs. Also: mark the job_name-absent branch as defensive (the production lister always populates job_name), and correct the poll-thread latency docstring — TTL reaps also stamp deletionTimestamp, so the wait is reachable whenever a matching Job is mid-deletion, not only after a restart.
Review feedback addressed — commit
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: commit df0e633 (delta from 23a9120)
Verdict: Approve. No blocking issues in the delta. All three actionable items from my previous review are addressed, and the one I said I'd actually bother changing (#1) was fixed more thoroughly than I asked — the restructure catches the two pre-existing branches, not just the new except.
Verification of the delta
The loop restructure is behaviour-preserving where it should be, and only the log taxonomy changed. I checked this rather than assuming:
gone = Falsewas deleted, sogoneis now conditionally bound. Every path that does not assign itcontinues (_routes_restart.py:451,469,486), soif not gone:at line 487 is only reachable in the same iteration that assigned it. NoUnboundLocalError, and no stale carry-over from a prior iteration.ruff checkandruff format --checkclean on all three files.- The guard inversion is a correct De Morgan:
not (remaining > 0 and waiter is not None)≡waiter is None or remaining <= 0. teardown_confirmedoutcomes are identical to23a9120on all four branches — the old fall-through intoif not gone:also set itFalse. So this is purely "stop emitting a claim the code can't support", with no change to the response contract. That is the right scope.- The
exceptnowcontinues, which is the one real behavioural delta.test_restart_handles_a_raising_teardown_waiter_locally'scall_count == 2still passes, so the remaining-Jobs-still-waited property survives the restructure.
The new test is load-bearing, not decorative. Against 23a9120 the except fell through with gone == False, so "still terminating after teardown wait" was logged and assert not any(...) trips. The two negative assertions (no "still terminating", no "Failed to list live one-shot Jobs") are what distinguish the fix; teardown_confirmed is False alone would pass under both. Correctly chosen.
_events.py:138-146 docstring. Accurate now. _job_is_terminating (_events.py:65) tests only deletion_timestamp with no status filter, and terminating = [j for j in jobs if _job_is_terminating(j)] (_events.py:148) draws from the full _list_event_jobs snapshot, so a TTL-reaped finished Job on the same dedupe key does enter the wait. The "harmless and wanted" framing is right — _await_terminating_event_jobs runs after the adoption check and before create, so the 409 protection genuinely applies to the recycled name.
Ratchet + suite. routes/pipelines/** carries exactly 121 noqa: BLE001 lines at df0e633 — the delta moves the existing site, adds none. test_ble001_narrowing_audit.py 4 passed, test_restart_agent.py 71 passed (70 + the new one), matching your corrected count.
Non-blocking
-
The docstring you edited now contains the inaccuracy the delta exists to remove.
_events.py:129-131claims "on timeout (or a k8s client without the wait helper) we log and let the spawn proceed". The no-helper case does not log —_events.py:151-153iswaiter = getattr(...); if waiter is None: return, bare, before thelogger.infoat line 154. Only the timeout case logs. So the event path silently proceeds on a missing helper while the route now emitsreason=no_wait_helperfor the same condition — and the docstring asserts the opposite. Same class of unsupportable claim as the finding this commit fixes, two paragraphs above the paragraph you rewrote. Either add the log or drop the parenthetical. -
Two of the three no-observation branches you fixed have no test. The new test pins the
exceptpath only.no_wait_helperandbudget_exhausted— the "third and fourth ways in" — ship untested, and the test's docstring ("A no-observation path must not log 'still terminating'") reads broader than its assertions. Low risk sinceteardown_confirmedis unchanged on both and the only regression surface is a misleading log line, but note thatno_wait_helperis not reachable with the existing fixture shape at all:getattron a bareMagicMocknever returnsNone, so it needsdel mock_spawner.k8s.wait_for_job_gone(or aspec=) to exercise.budget_exhaustedis easy — two Jobs with the first waiter consuming the deadline, or patch_pkg.time.monotonic. -
_events.py's ownremaining <= 0path still makes the claim the route now refuses to make._events.py:165-166setsgone = Falseand falls through to "Terminating event Job still present; spawn may 409 and retry next poll" (line 178) with no wait having run. Milder than the route's case — the Job was observed terminating in this poll's snapshot, and the "may 409" half stays true — but the route now applies a stricter taxonomy than the function its comment at_routes_restart.py:452-456says it mirrors. If the taxonomy is worth having, it's worth having on both sides. -
waiter is Noneis loop-invariant and unreachable in production. It's re-evaluated per deleted Job, so a helper-less backend emits N identical warnings for one backend-capability fact; hoisting the check above theforwould log once. Separately:KubernetesClient.wait_for_job_goneexists (kubernetes_client.py:766), so this branch is unreachable against the production client for exactly the same reason as theaddressable=Falsebranch — which you just annotated as defensive at_routes_restart.py:439-445. Worth the same one-line note, or the next reader will re-derive it. -
Bookkeeping. Your corrections are right and I'll carry them forward: 13 test functions added across the PR,
test_restart_agent.pyat 71. The PR body still says "Nine tests added" and describes the wait loop pre-restructure; since the gateway blocks you from editing ajwbron-owned description, @jwbron would need to fold that in before merge — not a review condition, just noting the body is now three commits stale.
None of these affect correctness. The core fix is unchanged and sound: terminating Jobs excluded from adoption, two bounded waits against the recycled deterministic name, and a response payload that under-claims rather than over-claims. Item 1 is the only one I'd actually bother changing.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The route stopped claiming 'still terminating' on branches where no wait ran; _await_terminating_event_jobs still did, and its docstring promised a log for a missing wait helper that the code never emitted. Both paths now report no_wait_helper / budget_exhausted for what they are, and the route hoists the loop-invariant helper check so a helper-less backend costs one log line rather than one per deleted Job. Adds coverage for the two previously untested no-observation branches on each side.
Review feedback addressed — commit
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: commit cb9199c (delta from df0e633)
Verdict: Approve. No blocking issues. Four of my five previous items are addressed, and I verified each against the runtime path rather than the response comment. The delta is log-taxonomy plus one hoist; the response contract is untouched.
Verification of the delta
1. The event path's no-helper arm now logs (_events.py:157-172). The docstring claim at _events.py:129-131 — "on timeout (or a k8s client without the wait helper) we log and let the spawn proceed" — is now true. Same message wording and same reason= key as the route, so a log search on "teardown wait not performed" returns both paths. That was the right call over dropping the parenthetical: the silence was the defect.
2. _events.py's spent-budget arm now continues with reason=budget_exhausted (_events.py:186-199) instead of falling into "Terminating event Job still present". The taxonomy now holds on both sides.
No UnboundLocalError from the restructure. gone = False was deleted, so gone is conditionally bound. Every path that does not assign it continues — _events.py:185 (no name), :199 (budget), :211 (raise) — so if not gone: at :212 is only reachable in the iteration that assigned it. Same property the route's restructure has, checked the same way. ruff check + ruff format --check clean on all four files.
3. The hoist is correct, and the pending_waits and guard is load-bearing (_routes_restart.py:437-448). Two things I checked rather than assumed:
- The removed condition is re-established. Dropping
waiter is Nonefromif waiter is None or remaining <= 0(:480) is safe because the hoist setspending_waits = [], so the loop body — and thereforewaiter(name, …)at:492— is unreachable whenwaiter is None. No path callsNone. if pending_waits and waiter is None— notif waiter is None. With nothing deleted,teardown_confirmedstaysTrue, which is whatjobs_torn_down: 0+ the payload doc at:774-782mean by "there was nothing to tear down". Written without thepending_waitsguard, a no-op restart on a helper-less backend would have started reportingteardown_confirmed: false— a new false negative introduced by a logging fix. It isn't. Correct as written.
teardown_confirmed outcomes are identical to df0e633 on every branch, so the restart_agent payload and the mcp_tools/_lifecycle.py:62 passthrough are unaffected. Purely "stop claiming an observation that wasn't made", which is the right scope.
4. The four new tests are load-bearing, and the one that isn't is labeled as such. I ran them: test_restart_agent.py + test_restart_mcp_tools.py 99 passed; the seven #3597 spawner tests pass. Reasoning about each against df0e633:
test_missing_wait_helper_is_logged_not_silently_skipped— pre-fix the arm returned bare, so[] == ["no_wait_helper"]trips. Fails pre-fix. ✓test_spent_wait_budget_is_not_reported_as_an_observation— pre-fix emits "still present" with noreasonkwarg, so[None] == ["budget_exhausted"]trips. Fails pre-fix. ✓test_restart_reports_a_missing_wait_helper_once— pre-fix the per-Job check emits two identical warnings, solen(not_performed) == 1trips. Fails pre-fix. ✓test_restart_reports_a_spent_wait_budget_as_unobserved— you flagged this yourself as passing pre-commit, and that reading is right: atdf0e633the branch wasreason=("no_wait_helper" if waiter is None else "budget_exhausted"), so one Job with a0.0budget produces the identical log. It is coverage of an existing branch, not a regression test, and saying so unprompted is the correct handling.
del mock_k8s_client.wait_for_job_gone / del mock_spawner.k8s.wait_for_job_gone is used in both no-helper tests — the fixture shape I flagged, without which getattr hands back an auto-attribute and the branch is never reached. Patching _JOB_TEARDOWN_WAIT_SECONDS/_EVENT_JOB_TERMINATION_WAIT_S to 0.0 rather than faking the clock is robust: monotonic() advances between the two reads, and an equal read still satisfies <= 0.
Ratchet + environment. No new # noqa: BLE001; test_ble001_narrowing_audit.py 4 passed. test_kubernetes_spawner.py shows 30 failures here, all git init refused by the local gateway policy (CalledProcessError on git … init -b egg/issue-3064/slice-4) — environmental, none of them in the #3597 classes. Matches your report. I checked one directly rather than taking the baseline claim on faith.
I also withdrew a candidate finding: I was going to flag patch("kubernetes_spawner._events.…") / patch("routes.pipelines._routes_restart.…") as breaking the package-private-submodule convention, but patch("kubernetes_spawner._worktree.…") (4 sites in the same file) and patch("routes.decisions._handlers.…") already establish the pattern. Not a violation.
Non-blocking
-
The paragraph you added enumerates two skip-paths; there are three.
_events.py:136-140says "the two paths that skip the wait entirely — no helper on the client, budget already spent — say so instead of borrowing an observation they never made", and frames it as matching the route's taxonomy. But_events.py:183-185is a third:job_name = getattr(job, "job_name", None) or getattr(job, "container_name", None) if not job_name: continue
It skips the wait entirely and says nothing. The route logs its exact counterpart —
"restart_agent: deleted Job carried no job_name; its teardown cannot be observed"(_routes_restart.py:465-472) — so parity with the route is 2/3, and the sentence asserts a completeness that doesn't hold. Both branches are unreachable against the production lister (KubernetesClient.list_jobssetsjob_name = job.metadata.nameandcontainer_name = job_name,kubernetes_client.py:859,890,895), so this is documentation, not behaviour. But it is the same class of over-claim the commit exists to remove, in the paragraph the commit adds — the third round for this exact pattern. Either log it (reason="unaddressable", closing the parity properly) or drop the count: "the paths that skip the wait entirely say so". -
The hoist absorbs the
addressable=Falsesignal when both defensive branches hold. Pre-hoist the per-Jobnot addressablecheck ran first, so ajob_name-less listing entry on a helper-less backend logged "deleted Job carried no job_name". Nowpending_waits = []short-circuits the loop and the only output isjobs=<count>— which counts the unaddressable entries as though they would have been waited on. Costs nothing today (both branches unreachable,teardown_confirmedisFalseeither way), and I'd still take the hoist. Noting it because the justification you wrote at:433-435— "one backend-capability fact, not N per-Job teardown failures" — is about the helper, while the count it emits quietly folds in a different fact. -
PR body, four commits stale. "Nine tests added" against 17, and it still describes the wait loop before both restructures. You have documented twice that
gh pr editis refused on ajwbron-owned PR, so this is @jwbron's to fold in before merge — not a review condition, and I'm carrying your corrected counts forward rather than re-deriving them.
The core fix is unchanged and sound: terminating Jobs excluded from adoption, two bounded waits against the recycled deterministic name, and a payload that under-claims by design. Item 1 is the only one I'd bother changing, and only because it is the same sentence-shaped defect for the third time.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
#3597) Both teardown-wait paths had a third no-observation branch that skipped the wait silently: a listing entry carrying no job_name. The event path said nothing at all, and the route's hoisted helper check ran first, so its jobs= count claimed the entry as one the missing helper cost us while that entry's own warning never fired. Both paths now partition by addressability before anything reports a count: an unnameable entry logs reason=unaddressable for itself, and the skip-path counts cover only the Jobs a waiter could have observed.
Review feedback addressed — commit
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: commit 72f3aa5 (delta from cb9199c)
Verdict: Request changes. The delta itself is correct and both of my previous items are properly fixed — I verified each against the code, not the response comment. But tracing the teardown flag end-to-end this round surfaced a branch inside the same try block that reports the opposite of what happened. It is one line, and it is in code this PR wrote.
The delta is correct
1. The third skip-path now logs, on both sides. _events.py:167-186 partitions terminating into pending / unnamed before anything reports a count, and the unnamed set gets reason="unaddressable" with the same message string the route uses. The docstring at :136-141 now enumerates the paths without asserting a count that can go stale. I re-derived the full set of wait-skipping branches: unnamed (:175), no helper (:188), budget spent (:214). That is three, and all three log. The raising-waiter arm (:229) attempted a wait, so it is correctly outside the "skipped" taxonomy on both paths.
2. The count no longer folds in a different fact. _routes_restart.py:442 builds pending_waits from the addressable entries only, and the no_wait_helper warning at :468-475 reports jobs=len(pending_waits). Ordering the unaddressable partition before the helper check is the right fix — you were right that ordering the two checks against each other was the mistake, not the hoist. teardown_confirmed outcomes are unchanged on every branch (it was already False for both), so the payload contract is untouched, as claimed.
3. Both new tests are load-bearing. Reasoned against cb9199c rather than taking the claim:
test_unnamed_terminating_job_is_reported_not_silently_skipped— pre-fix the unnamed entry hitif not job_name: continuesilently, sowarningsis empty and[] == ["unaddressable"]trips; the wait-startinfoalso carriedterminating=2, soinfos[0].kwargs["terminating"] == 1trips independently. Fails pre-fix on two assertions. It uses a realContainerInfo(job_name=None,container_name=""), so it exercises the productiongetattr(...) or getattr(...)fallback rather than a hand-built shape.test_restart_does_not_fold_an_unaddressable_job_into_the_helper_count— pre-fixpending_waits = deleted_nameswas non-empty, sono_wait_helperfired withjobs=2and the loop never ran, meaning the unaddressable line never fired. Both assertions trip.del mock_spawner.k8s.wait_for_job_goneis the fixture shape that actually reaches the branch.
Targeted runs here: test_restart_agent.py teardown/terminating selection 8 passed; test_kubernetes_spawner.py #3597 selection 14 passed. ruff check + ruff format --check clean on all four files.
Blocking
A failed delete is reported as "there was nothing to tear down." _routes_restart.py:404-412
except Exception as job_err: # noqa: BLE001 - best-effort teardown
_pkg.logger.warning(
"Failed to delete live one-shot Job during restart (best-effort)",
...
)The handler logs and moves on. It never touches teardown_confirmed, which was initialized True at :387, and removed_jobs is not incremented. So when every delete fails the route returns jobs_torn_down: 0, teardown_confirmed: true — which your own payload doc at :776-778 defines as "there was nothing to kill (the role had already exited)".
Failure scenario (CONFIRMED). spawner.k8s.list_containers returns the role's live one-shot Job. remove_agent_job → remove_container → delete_job; the apiserver returns a 5xx (or the delete is RBAC-denied), so delete_job raises JobOperationError (kubernetes_client.py:764), remove_container re-wraps it (:452-453), and the route's inner handler swallows it. Result:
- The Job is still alive and not terminating, so
_job_is_liveisTrueand the next event-loop poll adopts it on the dedupe-key label — correctly, since it really is live. No respawn. - The operator's restart was a complete no-op, and the response says
restarted: true, jobs_torn_down: 0, teardown_confirmed: true. - That is byte-identical to the payload asserted by
test_restart_with_nothing_to_tear_down_says_sofor the genuine clean-exit case. The two are indistinguishable to the caller and tomcp_tools/_lifecycle.py:61-62, which passes both straight through to the MCP client.
Why this is in scope and not a "not a regression" dismissal. teardown_confirmed is new in this PR. This is not a pre-existing flag with a pre-existing gap — it is a flag the PR introduces whose value is left stale on one of the failure branches inside the PR's own try block. It also contradicts the invariant the PR documents twice: :519-520 says "teardown_confirmed: false therefore means 'not observed gone', never 'the restart failed' — it under-claims by design." Every other branch under-claims. This one over-claims, in the case where the operator most needs to know the restart did nothing. The PR body's fourth bullet — "distinguish 'killed a stuck pod and watched it go' from 'there was nothing to tear down'" — is exactly the distinction this branch collapses.
There is no test for this branch. grep -n "remove_agent_job.side_effect" tests/test_restart_agent.py returns nothing; the delete-failure path is uncovered across all 74 tests in the file.
Fix. One line — clear the flag in the handler:
except Exception as job_err: # noqa: BLE001 - best-effort teardown
teardown_confirmed = False
_pkg.logger.warning(...)That direction is consistent with the rest of the field: we did not observe the Job gone, so do not claim it. One nuance worth a sentence in the comment if you want to be precise: a benign already-gone race (the TTL controller reaps the Job between list_containers and the delete) lands here too, as a 404 → PodNotFoundError (kubernetes_client.py:762-763), and for that case true would have been correct. You cannot discriminate at the route today because remove_container re-wraps everything into JobOperationError (:452-453) — the original survives only on __cause__. Under-claiming on both is the right call; it is the safe direction and matches :519-520. Please add a test with remove_agent_job.side_effect = JobOperationError(...) asserting teardown_confirmed is False.
Non-blocking
-
The event path's
unaddressableline names no Job._events.py:175-184reportsterminating=<count>and nothing else identifying. The route's counterpart at_routes_restart.py:447-454reportscontainer_id=name, andContainerInfo.container_idis populated on the event path too (kubernetes_client.py:889,uid = job.metadata.uid or job.metadata.name) — the new test's own fixture setscontainer_id="uid-unnamed". An operator greppingreason=unaddressablegets an actionable handle on one path and a bare count on the other. Since this commit exists to close that exact parity, carrying the ids through (a list ofcontainer_ids, or one line per entry as the route does) would finish it. Purely observability; both branches remain unreachable against the production listers, which I re-verified:list_jobssetsjob_name = job.metadata.nameand mirrors it tocontainer_name(:859,890,895), andlist_containersderivesjob_namefromLABEL_CONTAINER_NAMEwith a pod-name fallback (:589-593), so neither can yield an empty name. -
PR body, five commits stale. Still "Nine tests added" against 19, and it describes the wait loop before three restructures. You have now documented three times that
gh pr editis refused on ajwbron-owned PR, so this is @jwbron's to fold in before merge, not a review condition. Your corrected bookkeeping in the comment thread (19 test functions: 8 + 8 + 1 + 1) is the number I would want in the body.
The core fix is unchanged and sound — terminating Jobs excluded from adoption, two bounded waits against the recycled deterministic name, and a payload that under-claims by design everywhere except the one branch above. I should have caught that branch in an earlier round rather than on the fifth; it was equally visible when teardown_confirmed landed. It is still worth the round.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
The route's per-Job delete is best-effort: a failure is logged and swallowed, and removed_jobs is not incremented. But teardown_confirmed was left at its True initializer, so a restart whose deletes all failed returned jobs_torn_down: 0, teardown_confirmed: true — byte-identical to the genuine clean-exit payload the response doc defines as 'there was nothing to tear down'. That is the one branch in this route that over-claims, and it collapses exactly the distinction the flag exists to draw. The Job is still live and NOT terminating, so the next event-loop poll adopts it on its dedupe-key label and no respawn follows: the operator's restart was a complete no-op, reported as a success. Clear the flag in the handler — we did not observe the Job gone. A benign already-gone race (TTL reap between the list and the delete) lands here too and would have been true, but remove_container re-wraps every failure into JobOperationError, so the route cannot discriminate; under-claiming on both matches the rest of the field. Also carry the container ids through the event path's unaddressable warning, which reported a bare count while the route's counterpart names the object. Two tests, each verified to fail pre-fix.
Feedback addressed —
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review: commit 1722d99 (delta from 72f3aa5)
Verdict: Approve. No blocking issues in the delta. The teardown_confirmed fix is correct, it closes a genuine over-claim, and both new tests exercise the production path.
Verification of the delta
The over-claim was real. Traced it end to end and the commit message's account holds:
remove_agent_job→remove_container(kubernetes_client.py:452-453) wraps every failure — including thePodNotFoundErrorthatdelete_job:762-763raises on a 404 — intoJobOperationError. So the route genuinely cannot discriminate "delete rejected" from "already gone", and under-claiming on both is the right call, not laziness.- On the raise,
deleted_namesnever gets the entry, so no wait runs andremoved_jobsstays 0. Pre-fix that produced{jobs_torn_down: 0, teardown_confirmed: true}— byte-identical to the clean-exit payload. Confirmed by reading_routes_restart.py:387(teardown_confirmed = Trueinitializer) against the response assembly at:803-804. - The consequence claim also checks out: the Job is live and carries no
deletionTimestamp, so_event_dedupe_key_livestill counts it live, the next poll adopts it, and no respawn follows.invalidate_role_armsdoesn't rescue this — it clears_live_keys, but re-derivation hits the label-matched live Job again.
Blast radius is contained. Grepped every consumer of teardown_confirmed: only mcp_tools/_lifecycle.py:62 (a passthrough) and tests. Nothing branches on it, so flipping this case true→false cannot regress control flow.
The event-path change is behaviour-preserving. unnamed moves int→list[str]; if unnamed: is falsy for both 0 and [], and terminating=len(unnamed) reports the same number the old terminating=unnamed did. container_id is real here — list_jobs:858 sets it from job.metadata.uid or job.metadata.name — so the comment's claim is accurate, and or "<unknown>" covers the None case anyway.
Tests. Both were verified to fail pre-fix by inspection: the route test asserts teardown_confirmed is False against a True initializer, and the spawner test reads a container_ids kwarg that did not exist. Neither is a hand-built fixture — the route test drives the real Flask handler and the spawner test drives real spawn_event_job through _await_terminating_event_jobs with container_name="" so the or fallback actually reaches the else branch.
Ran: test_restart_agent.py 75 passed, TestSpawnEventJobOneShot 15 passed, test_ble001_narrowing_audit.py + test_restart_mcp_tools.py 30 passed. ruff check and ruff format --check clean on all four changed files. (Other failures in test_kubernetes_client.py / test_kubernetes_spawner.py in my sandbox are environmental — ModuleNotFoundError: No module named 'kubernetes.client.exceptions' and git init blocked by the gateway — not PR-related. Per the workflow constraint I did not run the full suite.)
Non-blocking
1. docs/reference/agent-recovery.md still documents the semantics this commit just corrected. This is the one I'd actually fix. The commit rewrote the in-code response doc precisely because jobs_torn_down: 0 alone no longer means "nothing to kill" — but the published operator reference was not updated in lockstep, so the two now disagree, and the doc carries the version the commit message calls wrong:
:266— "jobs_torn_down: <count>(0means the role had already exited and there was nothing to kill)". The code comment at_routes_restart.py:791-793now correctly qualifies this as0withteardown_confirmed: true. The doc does not.:271— "A timeout is reported asteardown_confirmed: false, never a failed restart." A failed delete is now alsoteardown_confirmed: false, and that case is effectively a failed restart (no respawn follows). The doc enumerates only the timeout cause and then generalizes from it.
An operator debugging a no-op restart reads this file, not the source comment.
2. The MCP tool's top-line verdict still says "success" on the failed-delete path. _lifecycle.py:62-63 passes teardown_confirmed through, but the payload around it is unconditional: "restarted": True and "message": f"Agent {role} restarted successfully". So the exact shape this commit set out to eliminate — "the operator's restart was a complete no-op, reported as a success" — survives at the surface an agent actually reads. It's strictly better than before (the flag is right there and the two cases are now distinguishable), which is why this isn't blocking, but the summary line contradicts the detail field. Worth conditioning message on teardown_confirmed.
3. The new comment understates what the failed-delete branch costs. remove_agent_job (_jobs.py:112-116) deletes the gateway session in a finally, so it runs even when remove_container raised, and the route calls it with the default cleanup_session=True. The result is worse than "still live and adopted": the surviving pod is still running but has had its gateway session destroyed, so its git/gh calls start failing. Pre-existing in remove_agent_job and not amplified by this diff — the frequency and reach are unchanged — so I'm not blocking on it. But the comment added here is now the authoritative in-code explanation of this branch, and "it is still live (and NOT terminating)" reads as benign when the pod has in fact been quietly de-authorized. Worth a sentence.
4. Nit. _events.py: str(getattr(job, "container_id", None) or "<unknown>") — the str() is redundant; both operands are already str.
5. PR description test count. Body says "Nine tests added"; the diff against the merge base (6e9de942a) adds 20 test functions with none removed — 10 in test_restart_agent.py, 8 in test_kubernetes_spawner.py, 1 each in test_kubernetes_client.py and test_restart_mcp_tools.py. Same correction I flagged before; the gateway refuses gh pr edit on this PR (not owned by ... author: jwbron), so this comment remains the record unless @jwbron folds it in.
— Authored by egg
|
egg review completed. View run logs 15 previous review(s) hidden. |
|
Automated feedback loop has reached the maximum of 5 rounds. Human review is needed to make further progress on this PR. |
Closes #3597.
The race
restart_agentdeletes the role's live one-shot Job and delegates the respawn to the event loop. Kubernetes Job deletion is asynchronous: the API server accepts the request and the Job then sits inTerminating, still reportingactive > 0(⇒RUNNING), until its pods finish terminating. The loop polls every ~5s, so a poll landing inside that window matched the still-terminating Job on its dedupe-key label, loggedAdopting existing live Job for event (dedupe hit), and declined to spawn a replacement.The role then vanished indefinitely, not for one poll: adoption also re-arms the key in the loop's
_live_keys, and_observe_jobsmaps a missing Job torunning, so the key never leaves the live set and the dedupe branch eats every re-derivation. No pod, no Job,get_statusstill reportingstatus: runningwithcontainer_id: null._event_dedupe_key_livemodelled two states — live and terminal (#3181). Deletion-in-progress is a third, and status alone cannot see it.Changes
Terminating Jobs are not adoptable.
ContainerInfocarries the Job'sdeletionTimestamp(populated byKubernetesClient.list_jobs);_event_dedupe_key_livecounts a Job as live only when it is inLIVE_POD_STATUSESand unstamped.The replacement waits for its name. A one-shot Job's name is deterministic in the dedupe key, so the replacement collides with the Job being reaped — declining adoption alone would just trade the silent vanish for a 409
AlreadyExists.spawn_event_jobwaits the corpse out (bounded,_EVENT_JOB_TERMINATION_WAIT_S= 15s shared across matching Jobs) before creating: the event-loop twin of therestart_agent_jobwait added in #2655. Overrunning the budget is logged and the spawn proceeds — a 409 is isolated per-role bypoll_onceand retried next poll, costing a poll interval rather than the role.The route waits for the teardown it requested.
_restart_agent_bodywaits (bounded,_JOB_TEARDOWN_WAIT_SECONDS= 20s across every Job it deleted, well under the MCP client's 60s restart timeout) for each deleted Job to be observed gone, so the respawn it delegates starts from a clean slate. A timeout is reported, never fatal.Legible delegation (the issue's closing note). The route reports
jobs_torn_downandteardown_confirmed, which distinguish "killed a stuck pod and watched it go" from "there was nothing to tear down" and from "the delete hasn't landed yet". Therestart_agentMCP tool passes those through along withrespawn/live_event_loop, replacing the always-emptycontainer_idthat read as a failure signal.Testing
Nine tests added; each was verified to fail against the unfixed code and pass with the fix.
test_kubernetes_spawner.py— a terminating Job is not adopted; the corpse is waited out beforecreate_container; a wait timeout still spawns; a genuinely live Job is still adopted with no wait (guards against over-reach). Plustest_restart_deleted_job_mid_termination_respawns, which drives the realspawn_event_jobagainst the stateful Job store through the full delete → mid-termination-poll → respawn sequence rather than a fake that always spawns.test_kubernetes_client.py—list_jobsreportsdeletion_timestamp, asserting both Jobs still readRUNNING(that ambiguity is the bug).test_restart_agent.py— the route waits for the Job it deleted; an unconfirmed teardown is reported rather than claimed as success; "nothing to tear down" is distinguishable and skips the wait.test_restart_mcp_tools.py— the tool surfaces teardown/delegation instead of an emptycontainer_id.make lintclean.make test(fell back to the full suite in a fresh worktree): 22020 passed, 4 failed — all 4 reproduce identically at the pre-change commit (6e9de942a) and are local-environment artifacts: tworeap-stale-egg-images.shtests exit 127 for missing container tooling, and two assert against production/home/egg/...paths that don't exist here.Out of scope
#3595's heartbeat-anchor reset and peer-progress alert gate, which hid the resulting hole, are filed separately and untouched here.