Skip to content

fix(heartbeat): hold process_lost during cold-boot kube-blind window so live Jobs reattach across restart (BLO-12564) - #686

Merged
kkroo merged 1 commit into
masterfrom
omar/blo-12564-reattach
Jul 15, 2026
Merged

fix(heartbeat): hold process_lost during cold-boot kube-blind window so live Jobs reattach across restart (BLO-12564)#686
kkroo merged 1 commit into
masterfrom
omar/blo-12564-reattach

Conversation

@kkroo

@kkroo kkroo commented Jul 15, 2026

Copy link
Copy Markdown

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 — escalateStrandedAssignedIssue writes 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 in server/src/index.ts) and the first ticks can run fully kube-blind — both listAgentJobRunStatuses() and listLiveAgentJobRunIds() return null — and an external-lifecycle run whose agent Job is actually still Running resolves liveness "unknown" and gets minted process_lost past 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 than coldBootReattachGraceMs and the reaper is fully kube-blind (liveJobRunIds === null), an external-lifecycle run that would otherwise be minted process_lost is 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 to 0 (off) on the factory and is wired to 5 min only on the long-lived worker (server/src/index.ts); every per-request heartbeatService() construction and the whole test suite keep the steady-state null-liveness reap contract unchanged. Because liveJobRunIds === null structurally implies jobRunStatuses === 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-0 worker restart, the startup reap could run before the in-cluster kube client was ready, see both liveness lists return null, and mint process_lost on 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_lost recovery 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_lost on the still-Running Job.

Verification

pnpm --filter @paperclipai/server typecheck passes clean. Added 4 regression tests in heartbeat-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 grace ms 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 reclassifies process_lost as transient_infra with 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)


  • I searched the GitHub PR list (open + recently closed) for similar PRs and confirmed this is not a duplicate.

Linked issue: BLO-12564 — code sibling of the BLO-12292 process_lost tree (instrumentation landed in #675, #677, #681, #683).

🤖 Generated with Claude Code

…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>
@allyblockcast

allyblockcast Bot commented Jul 15, 2026

Copy link
Copy Markdown

🔗 Paperclip issue: BLO-16183
🔗 Paperclip issue: BLO-12563
🔗 Paperclip issue: BLO-13176
🔗 Paperclip issue: BLO-12292
🔗 Paperclip issue: BLO-12564
🔗 Paperclip issue: BLO-16182

1 similar comment
@allyblockcast

allyblockcast Bot commented Jul 15, 2026

Copy link
Copy Markdown

🔗 Paperclip issue: BLO-16183
🔗 Paperclip issue: BLO-12563
🔗 Paperclip issue: BLO-13176
🔗 Paperclip issue: BLO-12292
🔗 Paperclip issue: BLO-12564
🔗 Paperclip issue: BLO-16182

@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

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.

@allyblockcast

allyblockcast Bot commented Jul 15, 2026

Copy link
Copy Markdown

@ally Please perform the pre-landing structural review for BLO-12564, focusing on the cold-boot grace boundary in reapOrphanedRuns: verify the guard only covers fully kube-blind process_lost minting, cannot suppress kube-confirmed finalization, defaults off outside the long-lived worker, and that the four regression tests prove both within-grace shielding and past-grace recovery paths.

@kkroo
kkroo merged commit f6fa4ea into master Jul 15, 2026
18 checks passed
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>
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