fix(heartbeat): force-reap live-but-silent external-lifecycle Jobs (BLO-12996) - #575
Merged
Conversation
…LO-12996) A k8s Job that is still `phase: active` but has emitted no useful action for a long time blocks ALL dispatch for its agent: the dispatcher's `hasActiveJobForAgent` gate refuses to start newly-queued work while the zombie's Job is alive, even though BLO-12990 Fix #1 already excludes stale runs from the slot-count gate. Result: a single silent run starves high-priority work for hours (observed 90-120 min zombies; only manual `kubectl delete pod` unblocked them). The naive fix (reap any silent active Job at EXTERNAL_LIFECYCLE_STALE_MS = 15 min) would reintroduce the 2026-05-23 regression: killing live-but-quiet Jobs at that floor produced ~6.5/hr fleet-wide false `process_lost` on healthy long-streaming agents (see the guard in reapOrphanedRuns). This threads the needle with a separate, conservative ceiling: - New EXTERNAL_LIFECYCLE_HARD_STALE_MS = 45 min gates ONLY the destructive kill. The 15-min soft floor is unchanged (slot-gate counting + reaping absent/terminal Jobs). - reapOrphanedRuns now force-kills a run whose Job is still `phase: active` but silent >= 45 min, in both the kube-status-snapshot and kube-list branches, reusing finalizeExternalLifecycleTerminalRun's teardown (lease release, issue-execution promote, dispatch trigger, bounded retry) with errorCode `external_lifecycle_stale_killed`. - Below 45 min a live-but-quiet Job is still left alone (2026-05-23 guard preserved; existing 16-min guard tests stay green). Tests: force-kill via each reaper branch (50 min silent) + a 30-min boundary case that must NOT kill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Important Issues (1)
- [native-codex]
server/src/services/heartbeat.ts:508-550— The newexternal_lifecycle_stale_killederrorCode is not added toshouldScheduleAutomaticRunRetry's allowlist, so force-killed zombie runs get no automatic retry, even forpr_reviewwakes.finalizeExternalLifecycleTerminalRun(heartbeat.ts:10282) callsshouldScheduleAutomaticRunRetry(finalizedRun)after every finalize, including the newstaleKill: truepath. That function only retries fortransient_upstream/rate_limit_exhaustedfamilies,pr_review_auth_expired,k8s_concurrent_run_blocked,k8s_pod_schedule_failed,job_missing,adapter_failed, orprocess_lost—external_lifecycle_stale_killedmatches none of these, so it always falls through toreturn false.- This is the exact failure class the codebase has patched three times already for adjacent codes (BLO-8215
pr_review_auth_expired, BLO-9147k8s_concurrent_run_blocked, BLO-10448job_missing/k8s_pod_schedule_failed) — each comment explicitly cites "silently dropped a PR review with no retry" as the bug being fixed. A run silenced for 45+ min while doing apr_reviewtask (e.g. an Ally review job itself, stuck on a slow CI wait or big diff) will now hit the same silent-drop bug those fixes closed. - Recommend gating
external_lifecycle_stale_killedthroughisPrReviewRetryContextthe same wayjob_missingis, unless intentionally excluded — in which case a one-line comment explaining why a hard-stale kill is not eligible for the same retry treatment as a vanished Job would remove the ambiguity.
Suggestions (2)
- [pr-review-toolkit:tests] New tests (
heartbeat-process-recovery.test.ts:9-76) assertreaped,status,errorCode, and the Job-delete call, but never assert retry behavior (e.g.scheduleBoundedRetryForRuncalled/not-called). A test asserting the retry decision for apr_review-context run would have caught the Important issue above. - [pr-review-toolkit:tests] Coverage jumps straight from 30-min (not stale) to 50-min (stale); there's no test at the actual 45-min
EXTERNAL_LIFECYCLE_HARD_STALE_MSboundary. Given the 2026-05-23 incident was itself an off-by-threshold regression, a boundary-exact test would harden this constant against future drift.
Strengths
- Excellent PR description with RCA context and an explicit "don't fast-merge" flag on a historically sensitive reaper path.
- Every new constant/branch has a comment explaining the why (2026-05-23 regression, BLO-12990 slot-gate interaction), not just the what.
- Tests cover both liveness-check branches (kube-status-snapshot and kube-list) plus the negative case guarding the prior incident.
- Correctly reuses
finalizeExternalLifecycleTerminalRun's existing teardown (lease release, issue-execution promote, dispatch trigger, bounded-retry hook) instead of duplicating it — the retry-hook gap above is a missing allowlist entry, not a structural bypass.
Recommended Action
- Resolve the retry-eligibility gap for
external_lifecycle_stale_killedbefore merge, or document why it's deliberately excluded. - Suggestions are opportunistic — a boundary test is cheap insurance given this constant's incident history.
13 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
A k8s Job that is still
phase: activebut has emitted no useful action for a long time blocks all dispatch for its agent.startNextQueuedRunForAgent'shasActiveJobForAgentgate refuses to start newly-queued work while the zombie's Job is alive — even though BLO-12990 Fix #1 already excludes stale runs from the slot-count gate. Net effect: a single silent run starves high-priority work for hours. Observed repeatedly on 2026-07-01/02 (90–120 min zombies on MulticastEngineer / Staff Engineer); only manualkubectl delete podunblocked them.The trap this avoids
The naive fix — reap any silent active Job at
EXTERNAL_LIFECYCLE_STALE_MS(15 min) — would reintroduce the 2026-05-23 regression: killing live-but-quiet Jobs at that floor produced ~6.5/hr fleet-wide falseprocess_loston healthy long-streaming agents (the guard is commented inreapOrphanedRuns, and pinned by the existing 16-min "does not reap … live but silent" tests).Approach — a separate hard ceiling
EXTERNAL_LIFECYCLE_HARD_STALE_MS = 45 mingates only the destructive kill. The 15-min soft floor is unchanged (slot-gate counting + reaping absent/terminal Jobs).reapOrphanedRunsnow force-kills a run whose Job is stillphase: activebut silent ≥ 45 min — in both the kube-status-snapshot and kube-list branches — reusingfinalizeExternalLifecycleTerminalRun's teardown (lease release, issue-execution promote, dispatch trigger, bounded retry) with errorCodeexternal_lifecycle_stale_killed.Keyed on
lastUsefulActionAt(not stdout), so a genuinely working agent that streams progress within 45 min is never killed; observed zombies (no useful action for 90–120 min) are.Tests
server/src/__tests__/heartbeat-process-recovery.test.ts— 121 passed locally (vitest run), including:external_lifecycle_stale_killed+ Job deletedReview note (please do not fast-merge)
This touches the exact reaper path that caused the 2026-05-23 fleet incident. The 45-min threshold is a deliberate, conservative choice — worth a CTO eye before merge. Let CI's serialized server suites run.
Closes BLO-12996.
🤖 Generated with Claude Code