fix(heartbeat): hold process_lost during cold-boot kube-blind window so live Jobs reattach across restart (BLO-12564) - #686
Merged
Conversation
…so live Jobs reattach across restart (BLO-12564) On a fresh worker boot the in-cluster kube client may not be serving yet, so the startup reap (before the periodic timer) and first ticks can run fully kube-blind (listAgentJobRunStatuses + listLiveAgentJobRunIds both null). An external-lifecycle run whose agent Job is still Running then resolves liveness "unknown" and gets minted process_lost past the silence floor -- zeroing a run whose Job (owned by its own k8s controller) survives the restart. Adds a bounded, worker-opt-in cold-boot reattach guard in reapOrphanedRuns: while worker uptime < coldBootReattachGraceMs AND liveJobRunIds === null, an external-lifecycle run that would be minted process_lost is left running so a warm tick can reattach it. Symmetric to the BLO-12563 shutdown guard. Grace defaults to 0 (off) and is wired to 5 min only on the long-lived worker; every per-request construction and the steady-state test contract are unchanged. liveJobRunIds === null structurally implies jobRunStatuses === null, so the guard never intercepts a kube-confirmed finalize -- only the blind mint. Pins the contract with 4 regression tests (within-grace shields, past-grace still reaps; started + pre-adapter). The rest of BLO-12564's acceptance already holds on master (alive-Job reattach via BLO-13176; no priority downgrade exists in code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 similar comment
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
@ally Please perform the pre-landing structural review for BLO-12564, focusing on the cold-boot grace boundary in |
13 tasks
allyblockcast Bot
added a commit
that referenced
this pull request
Jul 15, 2026
* fix(heartbeat): reconcile exact external jobs Co-Authored-By: Paperclip <noreply@paperclip.ing> * test(heartbeat): regression test for the live pre-adapter over-reap guard (BLO-16183) Guards the 9c1bde1 invariant ("keep live k8s runs out of orphan reaper"): a pre-adapter external-lifecycle run whose backing Job is alive must not be reaped as process_lost merely for being silent past the pre-adapter stale floor. Two tests pin both sides of the 45m hard ceiling -- at 20m (under) the live run is left running; at 50m (over) it is stale-killed as external_lifecycle_stale_killed, never process_lost -- so a refactor collapsing the pre-adapter alive-guard fails CI. Liveness stubbed alive via the listLiveAgentJobRunIds Set path; run age controlled via startedAt/createdAt. Serialized-shard (CI-only) test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: scope stateless K8s temp paths per run (BLO-16219) Neither claude_k8s nor opencode_k8s Job manifests defined TMPDIR, TMP, or TEMP, so concurrent stateless PR-review Jobs for the same agent exposed the same default /tmp and could collide on temp file names — contrary to the explicit distinct-temp-path acceptance contract for run-scoped K8s isolation (BLO-15957/BLO-16201). Add a `tmpRoot` field to the run isolation descriptor, computed alongside homeRoot/sessionRoot/cacheRoot/workspaceRoot under the same per-run (/runtime-cache/paperclip-runs/<run-id>/tmp) or per-workspace ephemeral root, and returned from buildK8sRunIsolationDescriptor for all K8s adapter types. Shared mode's descriptor is unchanged (adapters already discard non-shared fields for that mode). The actual TMPDIR/TMP/TEMP env-var wiring lives in the vendored adapter repos (kkroo/paperclip-adapter-claude-k8s and kkroo/paperclip-adapter-opencode-k8s), pushed and merged separately: - claude-k8s: TMPDIR/TMP/TEMP were never set for isolated Jobs; now set from the new tmpRoot for run and workspace isolation modes. - opencode-k8s: TMPDIR was set but nested under cacheRoot (not a sibling of home/session/cache/workspace, so it didn't match the acceptance path), and TMP/TEMP were never set. Now all three use the same tmpRoot sibling directory. Also folds in the previously build-time-patched run-isolation working-dir fix as a real upstream commit, retiring the separate patch file + git-apply step here. CLAUDE_K8S_REF and OPENCODE_K8S_REF are bumped to the new commits. Manual live-cluster verification (two concurrent stateless runs, distinct TMPDIR/TMP/TEMP, no collision, durable-workspace/cleanup/ restart/RWX/RWO/fallback checks) is tracked separately once this image deploys, per the issue's verifying signal. * fix(heartbeat): coalesce running task enqueue races * docs(recovery): record process_lost checkpoint/resume non-goal + classifier invariant (BLO-16185) Decision note (docs/plans/): checkpoint/resume is a non-goal for process_lost -- ~98% of reaped mints are pre-adapter with no work product; the ~1.6% started tail keeps work in git/comments, so the mitigation is idempotent re-dispatch (BLO-16182) + Job reattach (BLO-12564), not a checkpoint store. The original "assert only when !externalLifecycleStarted" is now false (started_job_absent legitimately reaps a started run), so the guard is a classifier boundary invariant test + the BLO-16184 classification split as the live tripwire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(helm): add PaperclipAgentPodUnschedulable schedule-starvation alert (BLO-16224) (#685) Adds a kube-state-metrics-based warn alert firing when agent-Job pods (ac-*) sit unschedulable for 15m — a regression guard on the scheduling-starvation that BLO-16224 measured. Chart-only; adapter-independent; gated behind prometheusRule.enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(heartbeat): hold process_lost during cold-boot kube-blind window so live Jobs reattach across restart (BLO-12564) (#686) Bounded, worker-opt-in cold-boot reattach guard in reapOrphanedRuns: while worker uptime < grace AND liveJobRunIds === null (fully kube-blind), an external-lifecycle run that would be minted process_lost is left running so a warm tick reattaches its still-Running Job across a restart. Grace off (0) by default; 5 min only on the long-lived worker. Never intercepts a kube-confirmed finalize (liveJobRunIds===null implies jobRunStatuses===null). +4 regression tests. Rest of BLO-12564 already held on master (BLO-13176 reattach; no priority downgrade in code). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(heartbeat): reconcile exact external jobs Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(heartbeat): converge external job recovery Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(heartbeat): bound kube-blind reservation grace Co-Authored-By: Paperclip <noreply@paperclip.ing> * test(heartbeat): align exact cleanup expectations Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: kkroo <kkroo@paperclip.ai> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Omar Ramadan <omar@blockcast.net> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Omar Ramadan <omar.ramadan93@gmail.com>
13 tasks
13 tasks
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.
Thinking Path
BLO-12564 asks the worker to reconcile/reattach in-flight runs across a server restart instead of zeroing them to
process_lost. An understand-phase fan-out over the reaper, the run↔Job liveness/linkage, the process_lost side-effects, the monitor loop, and the test harness showed the issue's premise is largely stale. An alive-Job run already reattaches on the kube-up path (BLO-13176 plus the 2026-05-23 live-but-quiet guards), the run↔Job linkage (externalRunId) already survives a restart, and the "priority auto-downgraded to low" behavior does not exist in code at all —escalateStrandedAssignedIssuewrites only{status: blocked, blockedByIssueIds}, never a priority. The instrumentation from the rest of the BLO-12292 tree (#675, #677, #681, #683) confirms this split. The one genuine residual is the cold-boot window: on a fresh worker boot the in-cluster kube client may not be serving yet, so the startup reap (which runs before the periodic timer inserver/src/index.ts) and the first ticks can run fully kube-blind — bothlistAgentJobRunStatuses()andlistLiveAgentJobRunIds()return null — and an external-lifecycle run whose agent Job is actually still Running resolves liveness "unknown" and gets mintedprocess_lostpast the silence floor. That is exactly the server restart the ticket names, in its worst form. This PR closes that window with a bounded, worker-opt-in grace and pins the whole reattach contract with regression tests.What Changed
Adds a cold-boot reattach guard in
reapOrphanedRuns(server/src/services/heartbeat.ts): while the worker has been up for less thancoldBootReattachGraceMsand the reaper is fully kube-blind (liveJobRunIds === null), an external-lifecycle run that would otherwise be mintedprocess_lostis left running, so the next warm tick reattaches a still-Running Job (or reaps a genuinely-dead one). It is placed at the single mint convergence point and is symmetric to the BLO-12563 shutdown-side drain guard. The grace defaults to0(off) on the factory and is wired to 5 min only on the long-lived worker (server/src/index.ts); every per-requestheartbeatService()construction and the whole test suite keep the steady-state null-liveness reap contract unchanged. BecauseliveJobRunIds === nullstructurally impliesjobRunStatuses === null, the guard can never intercept a kube-confirmed finalize (job_missing/job_failed/external_lifecycle_stale_killed) — only the blind silence-floor mint.What happened
On a
paperclip-0worker restart, the startup reap could run before the in-cluster kube client was ready, see both liveness lists return null, and mintprocess_loston external-lifecycle runs whose agent Job (owned by its own k8s controller) was still Running — zeroing a run that should simply have been re-adopted.Expected behavior
A worker restart with an in-flight run whose agent Job is still Running leaves the run running (reattach on the next warm tick), not a
process_lostrecovery transition; runs with genuinely no live Job still recover after the silence floor; priority is never auto-downgraded.Steps to reproduce
Restart the worker while an external-lifecycle run is in flight and the in-cluster kube API is briefly unreachable at boot; before this change the first reaper tick mints
process_loston the still-Running Job.Verification
pnpm --filter @paperclipai/server typecheckpasses clean. Added 4 regression tests inheartbeat-process-recovery.test.ts(Linux-CI-only; the suite skips on macOS embedded-postgres): within-grace shields plus past-grace still-reaps, for both the started and pre-adapter paths (BLO-12564: within the cold-boot grace .../... past the cold-boot grace ...). Each mirrors an existing steady-state sibling's seed (the "reaps ... silent past the staleness window" started case and the BLO-16183 pre-adapter aging) so the "reaches the mint absent the guard" premise is proven by parity. Two independent adversarial reviews found no defect across correctness and test-path items.Risks
Low. The guard fires only in the first
gracems of worker uptime and only when kube is fully unavailable (a transient blind tick self-heals on the next tick); the uptime bound is monotonic in real time, so there is no permanent shield, and every degenerate input (NaN/undefined) fails safe toward reaping. Worst case is a genuinely-orphaned pre-adapter run recovering up to 5 min later during a boot-time kube outage — bounded, and BLO-16182 already reclassifiesprocess_lostastransient_infrawith a retry budget, so that harm was already blunted. No change to local-adapter runs, to the kube-up reattach path, or to the steady-state null-liveness reap (pinned by the pre-existing tests, which construct the reaper with the grace off).Model Used
Claude Opus 4.8 (1M context)
Linked issue: BLO-12564 — code sibling of the BLO-12292 process_lost tree (instrumentation landed in #675, #677, #681, #683).
🤖 Generated with Claude Code