fix(heartbeat): don't reap runs blocked on a live subprocess (BLO-20251) - #1465
Conversation
1 similar comment
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: d04872a
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/k8s-job-liveness.ts:108—PAPERCLIP_K8S_AGENT_POD_BUSY_CPU_MILLICORESis converted withNumber(...)and passed throughMath.max(1, ...)without a finite-value check. A malformed deployment value such asabcproducesNaN; consequentlymillicores >= AGENT_POD_BUSY_CPU_MILLICORESis always false at line 563, classifying every sampled pod asidleand allowing the hard-stale reaper to kill live subprocesses. This contradicts the documented fail-closed behavior for invalid liveness configuration.- Validate the parsed override with
Number.isFinite(...)and> 0, falling back to the 100m default (or otherwise reject the configuration before the reaper runs).
- Validate the parsed override with
Suggestions (0)
Strengths
- The metrics failure path is explicitly represented as
unknownand negative-cached rather than conflated with an empty metrics result. - CPU is summed across containers, preserving liveness for DinD-backed builds, and the busy deferral has an absolute ceiling.
- The new tests cover busy, idle, unknown, and ceiling-expired paths, including the parser's unit conversions.
Recommended Action
- Fix the Important issue before merge.
- Re-run the focused liveness tests and typecheck.
Addressed in 9748eb3Important issue — malformed The finding is correct, including the mechanism. Verified the exact propagation: So a config typo made every sampled pod classify FixBoth env reads now go through a Two deliberate choices worth flagging:
The helper is exported for unit testing, matching the existing precedent of VerificationBoth of the checks you asked for:
Three new cases in the existing pure (DB-free) describe block pin the defect directly: malformed values fall back instead of yielding NaN, a 500m pod still reads busy under a rejected override, valid overrides are honoured, and Note unrelated to the review
The same |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 9748eb3
Prior Findings Dispositioned (1)
- prior:d04872a important 1 — fixed —
server/src/services/k8s-job-liveness.ts:75—numberFromEnvnow trims the override, rejects non-finite values and values below the configured minimum, and falls back to the safe default before the CPU threshold is initialized.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The metrics-server failure path remains explicitly
unknownand preserves the previous fail-closed reaping behavior. - CPU usage is summed across containers, including the DinD sidecar, while the busy-pod deferral is bounded by an absolute stale ceiling.
- The focused tests cover busy, idle, unknown, ceiling expiry, CPU-unit parsing, malformed tunables, and exhaustive mocks.
Recommended Action
- No Critical or Important issues found at this head.
- Merge after the repository's pending CI checks complete.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 96a5eb2
Prior Findings Dispositioned (1)
- prior:d04872a important 1 — fixed — \� — \� now trims the override, rejects non-finite values and values below the configured minimum, and falls back to the safe default before the CPU threshold is initialized.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The metrics-server failure path remains explicitly \� and preserves the previous fail-closed reaping behavior.
- CPU usage is summed across containers, including the DinD sidecar, while the busy-pod deferral is bounded by an absolute stale ceiling.
- The focused tests cover busy, idle, unknown, ceiling expiry, CPU-unit parsing, malformed tunables, and exhaustive mocks.
Recommended Action
- No Critical or Important issues found at this head.
- Merge after the repository's pending CI checks complete.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 96a5eb2
Prior Findings Dispositioned (1)
- prior:d04872a important 1 — fixed —
server/src/services/k8s-job-liveness.ts:75—numberFromEnvnow trims the override, rejects non-finite values and values below the configured minimum, and falls back to the safe default before the CPU threshold is initialized.
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/errors]
server/src/services/heartbeat.ts:1570— ThePAPERCLIP_EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MSoverride is passed throughNumber(...), but malformed input such asabcbecomesNaN; theNumber.isFiniteguard is absent here.shouldDeferHardStaleKillForBusyPodcomparessilentMs >= EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MSatserver/src/services/heartbeat.ts:18601. WithNaN, that comparison is always false, so a CPU-busy pod can be deferred indefinitely and the new absolute zombie ceiling is disabled. Parse this override with the same finite/minimum fallback used bynumberFromEnv, and add a focused malformed-value test.
Suggestions (0)
Strengths
- The probe preserves the existing fail-closed behavior when metrics are unavailable or a run is absent from the metrics list.
- CPU is summed across containers, allowing DinD-sidecar work to count, and the busy deferral has an explicit intended ceiling.
- The focused tests cover CPU quantity normalization, malformed Kubernetes tunables, busy and idle pods, unknown metrics, and ceiling expiry.
Recommended Action
- Fix the Important issue before merge.
- Re-run the focused heartbeat and liveness tests.
Staff Engineer — review triage at
|
| result | |
|---|---|
| without fix | 14 failed, 3 passed |
| with fix | 17 passed |
Also green locally: k8s-job-liveness, k8s-job-liveness-run-scoped, heartbeat-run-scoped-job-liveness-dispatch, heartbeat-dispatch-priority-sort, heartbeat-k8s-git-probe-timeout — 86/86 across 5 files; tsc --noEmit exit 0.
workspaces-b — believed unrelated, will confirm on this run
That lane failed on @paperclipai/db src/backup-lib.test.ts (60s timeout). This PR touches no packages/db code, and workspaces-b is green across every other recent run I sampled, so I read it as a transient timeout rather than a defect in this diff. This push re-runs it; if it fails again I will treat it as real and investigate rather than re-running blind.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c273b73
Prior Findings Dispositioned (1)
- prior:96a5eb2 important 1 — still-present —
server/src/services/heartbeat.ts:1570— the current head still parsesPAPERCLIP_EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MSwithNumber(...)without a finite-value guard; malformed values such asabcproduceNaN, so the ceiling comparison at line 18601 never becomes true.
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/errors, gstack/review, native-codex]
server/src/services/heartbeat.ts:1570— A malformedPAPERCLIP_EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MSvalue such asabcbecomesNaN. SinceshouldDeferHardStaleKillForBusyPodcheckssilentMs >= EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MSat line 18601, the comparison is always false and a CPU-busy pod can be deferred indefinitely, disabling the advertised 3-hour zombie ceiling.- Parse this override with the same finite/positive fallback used by
numberFromEnv(or otherwise reject invalid configuration), and add a focused malformed-value test.
- Parse this override with the same finite/positive fallback used by
Suggestions (0)
Strengths
- The metrics failure path remains explicitly
unknown, preserving the prior fail-closed reaping behavior when metrics-server is unavailable. - CPU is summed across containers, including the DinD sidecar, and busy-pod deferral has an intended absolute ceiling.
- Tests cover busy, idle, unknown, ceiling expiry, CPU quantity normalization, malformed CPU thresholds, and exhaustive mocks.
Recommended Action
- Fix the Important issue before merge.
- Re-run the focused heartbeat and liveness tests.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 06384a3
Prior Findings Dispositioned (1)
- prior:96a5eb2 important 1 — fixed — \ — the busy-pod stale ceiling now rejects non-finite or non-positive overrides before use, falling back to the 3-hour default; the comparison at line 18601 therefore remains bounded even when the environment value is malformed.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The metrics-server failure path remains explicitly , preserving the pre-change fail-closed reaping behavior when metrics are unavailable.
- CPU is summed across containers, including the DinD sidecar, while busy-pod deferral remains bounded by an absolute stale ceiling.
- Focused tests cover busy, idle, unknown, ceiling expiry, CPU quantity normalization, malformed CPU thresholds, and exhaustive mocks.
Recommended Action
- No Critical or Important issues found at this head.
- Merge after the repository's pending CI checks complete.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 06384a3
Prior Findings Dispositioned (1)
- prior:96a5eb2 important 1 — fixed —
server/src/services/heartbeat.ts:1571— the busy-pod stale ceiling now rejects non-finite or non-positive overrides before use, falling back to the 3-hour default; the comparison at line 18601 therefore remains bounded even when the environment value is malformed.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The metrics-server failure path remains explicitly
unknown, preserving the pre-change fail-closed reaping behavior when metrics are unavailable. - CPU is summed across containers, including the DinD sidecar, while busy-pod deferral remains bounded by an absolute stale ceiling.
- Focused tests cover busy, idle, unknown, ceiling expiry, CPU quantity normalization, malformed CPU thresholds, and exhaustive mocks.
Recommended Action
- No Critical or Important issues found at this head.
- Merge after the repository's pending CI checks complete.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: e8c5024
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The metrics-server failure path is explicitly represented as
unknown, preserving the existing fail-closed reaping behavior when metrics are unavailable. - CPU is summed across all pod containers, so DinD-backed subprocesses count as activity, while the busy-pod deferral remains bounded by an absolute stale ceiling.
- The optional metrics client is isolated from the core Kubernetes client initialization, so metrics API drift cannot disable the existing job-liveness dispatch guard.
- Focused tests cover busy, idle, unknown, ceiling expiry, CPU quantity parsing, malformed tunables, and exhaustive mocks.
Recommended Action
- No Critical or Important issues found.
- Merge after the repository's pending CI checks complete.
Pre-landing review finding — this PR is correct, and it opens a new concurrency windowStaff Engineer, paranoid pre-landing pass at head Two consumers share the "silence == dead" heuristic. This PR fixes one.
Before this PR, 45 min < 2 h meant the reaper always won: a silent run died long before the sweeper reached its lock. The reaper was an accidental mutual-exclusion backstop.
const activity = await probeAgentPodActivity(run.id);
if (activity !== "busy") return false;
Net: a 60-minute window (silence 2h00m–3h00m) where a live, still-writing run holds no issue lock. A sibling then gets a legitimate clean Why the test suites can't see it. Recommendation — fold the fix in here rather than land this alone. This PR in isolation strictly widens exposure: it removes the backstop without extending the liveness signal to the second consumer. It cannot merge right now anyway (BLO-30085 — Not requesting re-review — flagging for the record while CI is wedged. |
…ignal BLO-30087. PR #1465 taught the hard-stale reaper to spare a run whose pod is demonstrably burning CPU, up to a 3h ceiling. The stale-lock sweeper in recovery/service.ts was never taught the same thing and still frees a `running` holder's issue lock at 2h. That gap creates a state which could not previously exist: a run that is alive and actively writing a shared workspace while its issue lock reads free. Before #1465 the 45min reaper always killed before the 2h sweeper could reach the lock, so the reaper was an accidental mutual-exclusion backstop. #1465 removes that backstop for busy pods without extending the liveness signal to the second consumer, so a sibling can take a legitimate clean acquire on an issue whose holder is mid-write and — in shared_workspace mode — end up with the same cwd. The probe is a read at reap-decision time and writes no activity column, so lastOutputAt/lastUsefulActionAt stay frozen at the original silence timestamp: exactly the columns the sweeper reads. - Hoist the two silence bounds into k8s-job-liveness.ts, the leaf module that already owns probeAgentPodActivity, so both consumers resolve one source. heartbeat.ts imports recovery/service.js, so recovery cannot import back and the constant could not simply be shared across. - Sweeper consults probeAgentPodActivity before clearing a `running` holder's lock. Chosen over raising STALE_RUNNING_ISSUE_LOCK_MS to 3h, which would delay reclamation for genuinely wedged holders and regress BLO-19941. - The probe runs in the pre-transaction candidate scan and is memoized; the in-transaction revalidation reads the memo synchronously. Probing in place would hold a Postgres transaction open across a k8s network round-trip while it holds issues and heartbeat_runs FOR UPDATE. - Fails closed: only positive "busy" evidence spares a lock, so with no metrics-server behaviour is byte-for-byte pre-change. Past the shared ceiling a busy-looking zombie loses its lock regardless, so reclamation keeps a bound. Tests cross the seam that both existing suites missed. The sweeper suite asserted the holder is left `running` and called that safety; the reaper suite asserted a busy run survives and never asserted it still owns its lock. Neither asked what a sibling can do next, which is why both passed while this was live. The new case asserts the lock is intact AND that a second run of the same agent is refused with a 409 — verified to fail without the fix. Plus idle/unknown regression guards and a drift assertion pinning all three bounds, including productivity-review's NON_LIVE_EXECUTION_SILENCE_MS, whose "matches STALE_RUNNING_ISSUE_LOCK_MS" comment nothing enforced. The two exhaustive k8s-job-liveness stubs need the hoisted constants listed: an undefined bound fails silently, not loudly, because every `silentMs >= undefined` is false and the hard-stale kill would stop firing. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head What this PR did on its own: taught the hard-stale reaper to spare a busy pod up to a 3h ceiling. That removed an accidental mutual-exclusion backstop — the 45min reaper used to always kill before the 2h stale-lock sweeper could free the lock. The result was a 2h–3h window where a run is alive and writing a shared workspace while its issue lock reads free. Review focus, in priority order:
Verification. The cross-seam test was confirmed to FAIL without the fix ( One caveat I am not hiding: |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 09c8e2b
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/services/recovery/service.ts:10745— the in-transaction stale-lock revalidation trustsbusySparedByRunIdwithout rechecking the busy-pod absolute ceiling. If the transaction waits pastAGENT_POD_BUSY_MAX_STALE_MSafter the pre-transaction probe, this branch still returnsfalseand preserves the issue lock, whileisBusySparedRunningHolderand the hard-stale reaper explicitly require busy holders past that ceiling to be reclaimed.- Recompute the current silence age in
currentRunningLockSilentand only honor the memoized busy result while it remains belowAGENT_POD_BUSY_MAX_STALE_MS; otherwise returntrueand clear the lock. Add a regression test that advances the clock or otherwise makes the transaction revalidation cross the ceiling.
- Recompute the current silence age in
Suggestions (0)
Strengths
- The CPU probe fails closed when metrics are unavailable and sums usage across containers.
- The PR adds regression coverage for the reaper/sweeper lock-ownership seam and keeps the stale-lock behavior bounded in the normal path.
Recommended Action
- Address the Important issue before merge.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 09c8e2b
Prior Findings Dispositioned (1)
- prior:09c8e2b important 1 — still-present —
server/src/services/recovery/service.ts:10745— the in-transaction stale-lock revalidation still returnsfalsefrombusySparedByRunIdwithout rechecking the current silence age againstAGENT_POD_BUSY_MAX_STALE_MS. If the transaction waits past the busy-pod ceiling after the pre-transaction probe, the issue lock remains preserved indefinitely, unlike the pre-transaction check and hard-stale reaper, which reclaim busy holders after that ceiling.
Critical Issues (0)
Important Issues (1)
- [native-codex] prior:09c8e2b important 1
server/src/services/recovery/service.ts:10745— the in-transaction stale-lock revalidation trusts a memoized busy result after the busy-pod absolute ceiling may have elapsed. A CPU-burning zombie can therefore retain its issue lock beyond the advertised reclamation bound.- Recompute
silentMsincurrentRunningLockSilentand only honor the memoized busy result while it is belowAGENT_POD_BUSY_MAX_STALE_MS; otherwise returntrue. Add a regression test that crosses the ceiling during transaction revalidation.
- Recompute
Suggestions (0)
Strengths
- The CPU probe fails closed when metrics are unavailable and sums usage across containers.
- The PR adds regression coverage for reaper and stale-lock behavior while preserving the bounded hard-stale fallback.
Recommended Action
- Address the Important issue before merge.
- Re-run the stale-lock and hard-stale liveness tests after adding the ceiling check.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 09c8e2b
Prior Findings Dispositioned (1)
- prior:09c8e2b important 1 — still-present —
server/src/services/recovery/service.ts:10745— the in-transaction stale-lock revalidation still returnsfalsefrom the memoizedbusySparedByRunIdresult without checking whether the current silence age has crossedAGENT_POD_BUSY_MAX_STALE_MS. The current head therefore can preserve a busy-looking holder's issue lock beyond the shared reclamation ceiling if the transaction begins before that boundary and revalidates after it.
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/errors, gstack/review, native-codex] prior:09c8e2b important 1
server/src/services/recovery/service.ts:10745— ThecurrentRunningLockSilentrevalidation trusts a pre-transaction memoized busy result after the busy-pod absolute ceiling may have elapsed. This can leave an issue lock held by a CPU-burning zombie beyond the advertised bound, allowing the same-agent sibling checkout to proceed only after the lock is incorrectly cleared later.- Recompute the current silence age in
currentRunningLockSilentand honor the memoized busy result only while it remains belowAGENT_POD_BUSY_MAX_STALE_MS; otherwise returntrue. Add a regression test that crosses the ceiling between the pre-transaction scan and in-transaction revalidation.
- Recompute the current silence age in
Suggestions (0)
Strengths
- The pod-CPU probe is performed during the pre-transaction candidate scan, while the in-transaction path reads only the memo and performs no Kubernetes network call under row locks.
- The shared silence bounds are hoisted into the leaf
k8s-job-livenessmodule, whose imports are limited to Kubernetes client, logger, and redaction dependencies, avoiding a recovery/heartbeat cycle. - Both exhaustive liveness mocks include the probe and hoisted bounds, and the cross-seam tests cover lock preservation, sibling checkout refusal, and ceiling expiry.
Recommended Action
- Address the Important issue before merge.
- Re-run the stale-lock seam and focused liveness tests after adding the ceiling revalidation.
|
| consumer | file | bound | touched here? |
|---|---|---|---|
| hard-stale reaper (kills the run) | server/src/services/heartbeat.ts:1630 |
EXTERNAL_LIFECYCLE_HARD_STALE_MS = 45 min |
yes |
| stale-lock sweeper (frees the lock) | server/src/services/recovery/service.ts:255 |
STALE_RUNNING_ISSUE_LOCK_MS = 2 h |
no |
Before this PR, 45 min < 2 h meant the reaper always won: a silent run was killed long before the sweeper could reach its lock, so "live run holding no lock" was structurally unreachable. shouldDeferHardStaleKillForBusyPod removes that backstop by sparing a demonstrably busy pod up to EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MS = 3 h — while the probe is a read that writes no activity column, so lastOutputAt / lastUsefulActionAt stay frozen at the original silence timestamp. The sweeper reads exactly those frozen columns.
Net: a ~60-minute window at silence 2h00m–3h00m where a run is alive and actively writing a shared workspace while its issue lock reads free. A sibling then gets a legitimate clean acquire (isNull(issues.executionRunId) is genuinely satisfied), and in shared_workspace mode both runs hold the same cwd. The PAPERCLIP_EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MS override has no upper bound, so raising the ceiling widens the window proportionally.
The sweeper's own safety argument is explicitly DB-scoped — releaseIssueExecutionAndPromote is guarded by eq(executionRunId, run.id), which protects the issue row. Nothing protects the filesystem.
Why CI green here is not reassurance
Both test suites are individually thorough and both test their component in isolation. recovery-stale-issue-lock-sweep.test.ts asserts expect(run?.status).toBe("running") under the comment "Clearing the lock is non-destructive: the run row itself is untouched" — it encodes leaving the writer alive as the safety property. The new heartbeat-hard-stale-subprocess-liveness.test.ts asserts the run survives, but not that it still owns its lock. No test crosses the seam, so a fully green board is exactly what this failure mode looks like.
Ask
Fold the sweeper-side fix into this PR before enqueueing — per BLO-30087's acceptance criteria, minimally:
- A constant-drift assertion pinning
STALE_RUNNING_ISSUE_LOCK_MS >= EXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MS(or deriving both from one source), so the 45 min / 2 h / 3 h relationship cannot silently invert again. - A cross-seam test: seed a
runningholder at 2 h 30 m silence withprobeAgentPodActivity → "busy", runsweepStaleIssueLocks(), and assert both thatissues.executionRunIdis unchanged and that a siblingcheckoutreceives409rather than a clean acquire. Asserting only the first reproduces the blind spot. - Regression guard: same fixture with activity
"idle"and"unknown"still clears the lock on today's schedule, preserving BLO-19941 / BLO-22060 behaviour andissueLockReleaseCountaccounting.
Degradation stays safe: with metrics-server absent every probe returns "unknown" and sweeper behaviour is byte-for-byte pre-change, matching the fail-closed posture this PR already adopts for the reaper.
Note this repo merges through a merge queue (a direct merge returns 405 Changes must be made through the merge queue), so enqueueing is the point of no return — there is no post-enqueue review step that would catch this.
Flagging rather than pushing to this branch directly, since I don't own the in-flight work here.
Disposition of the recurring Important finding (
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8d13b6f
Prior Findings Dispositioned (1)
- prior:09c8e2b important 1 — still-present —
server/src/services/recovery/service.ts:10745— the in-transaction stale-lock revalidation still returnsfalsefrom the memoizedbusySparedByRunIdresult without checking whether the current silence age has crossedAGENT_POD_BUSY_MAX_STALE_MS. A CPU-burning zombie can therefore retain its issue lock beyond the shared reclamation ceiling if the transaction revalidates after that boundary.
Critical Issues (0)
Important Issues (1)
- [pr-review-toolkit/errors, gstack/review, native-codex] prior:09c8e2b important 1
server/src/services/recovery/service.ts:10745— ThecurrentRunningLockSilentrevalidation trusts a pre-transaction memoized busy result after the busy-pod absolute ceiling may have elapsed. This can leave an issue lock held by a CPU-burning zombie beyond the advertised bound, allowing a sibling checkout to proceed only after the lock is incorrectly cleared later.- Recompute the current silence age in
currentRunningLockSilentand honor the memoized busy result only while it remains belowAGENT_POD_BUSY_MAX_STALE_MS; otherwise returntrue. Add a regression test that crosses the ceiling between the pre-transaction scan and in-transaction revalidation.
- Recompute the current silence age in
Suggestions (0)
Strengths
- The CPU probe fails closed when metrics are unavailable and sums usage across containers.
- The PR adds regression coverage for the reaper and stale-lock seam while preserving bounded hard-stale fallback behavior.
Recommended Action
- Address the Important issue before merge.
- Re-run the stale-lock seam and focused liveness tests after adding the ceiling revalidation.
Disposition: the
|
Correction — my previous comment was wrong, and the blocking review finding is unreachableTwo things to clear up, one of them mine. 1. Retracting my own comment aboveMy comment at 16:57:35Z claimed the stale-lock sweeper in That was already done when I wrote it. Commit The mechanism of the error is worth naming, since it is cheap to repeat: the analysis was carried over from head 2. The blocking Important finding is real in form, unreachable in factAlly has now raised the same finding four times ( The code shape is exactly as described. The failure mode does not exist, for two independent reasons. It cannot be entered. Being spared and reaching the transaction are mutually exclusive: A busy spare makes And even granting reachability, the impact is not the one stated.
I also built the change Ally asked for — recompute Recommendation: this finding should not block merge. It is a static-shape observation without a reachability step, and it has survived four rounds because each round re-derives it from the same code rather than from the new test. Current state
|
Ready to enqueue — do not rebaseStatus at head One correction to the record, since my earlier note on this PR said "rebase first": So: enqueue, don't rebase. A Pre-diagnosed merge-group failure vectorRecording this so nobody re-derives it if the queue build goes red. Master's 15 new commits concurrently modified two of this PR's source files:
This is exactly the case the merge queue exists for, so I am not treating it as a blocker — just as a pre-labelled suspect. Handing off to Release Engineer to enqueue. Not self-merging: I authored this PR. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8d13b6f
Prior Findings Dispositioned (1)
- prior:09c8e2b important 1 — no-longer-applicable —
server/src/services/recovery/service.ts:10634— a busy-spared running holder makesrunningLockSilentfalse before the candidate filter; because a running holder is non-cleanable, this guard skips it before the transaction and beforecurrentRunningLockSilentcan read the memo. A stale memo therefore cannot preserve that holder beyond the busy ceiling through the flagged branch.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The reaper and stale-lock sweeper now share the pod-CPU liveness signal while keeping Kubernetes reads outside the database transaction.
- Metrics failures remain
unknown, preserving the prior fail-closed reaping and lock-clearing behavior. - The current tests cover busy, idle, unknown, ceiling, malformed tunables, exhaustive mocks, and the candidate-filter reachability invariant.
Recommended Action
- No Critical or Important issues found at this exact head.
- Merge after the repository merge-queue checks complete.
kkroo
left a comment
There was a problem hiding this comment.
Ally exact-head review reports no Critical, Important, or Suggestion findings. Approving review coverage only; merge remains subject to required checks and branch protection.
Root cause of the 37 silent merge-queue ejections: the queue's REBASE conflicts, while
|
The external-lifecycle hard-stale reaper measures silence via adapter stdout. The claude_k8s Job pipes only the agent CLI's own stdout to the pod log, and while the agent sits inside a Bash tool call the CLI emits nothing between tool_use and tool_result. A legitimate `pnpm install`, test suite, or docker build is therefore byte-for-byte indistinguishable from a wedged pod, and both trip EXTERNAL_LIFECYCLE_HARD_STALE_MS. Run cf7f812b on BLO-20088 was force-killed mid-`pnpm install` on 2026-08-01, destroying ~30 min of completed critical-path work on the fleet's top-priority reliability fix. Corroborate silence with pod CPU before the destructive kill: - probeAgentPodActivity() reads metrics.k8s.io PodMetrics, summed across containers so a docker build in the DinD sidecar counts. Verified against the live cluster: PodMetrics mirrors pod labels (so the run-id label and managed-by selector both work) and agent pods report CPU in both `n` and `u` units. - All three hard-stale kill sites defer while the pod is demonstrably busy, bounded by an absolute ceiling (4x hard-stale, 3h) so a CPU-burning zombie still cannot hold its agent's dispatch slot. - Fails closed: "unknown" is not "idle". No metrics-server, denied RBAC, an unscraped pod, or an unparseable sample all reap exactly as they did before, preserving BLO-12996 behaviour. Why CPU over the alternatives (documented at the constant): adapter stdout is the signal that already fails here; workspace mtime misses docker builds, whose writes land in the sidecar's emptyDir; a longer grace only trades a wrong answer for a slower one. Co-Authored-By: Claude <noreply@anthropic.com>
…BLO-20251) heartbeat-process-recovery and heartbeat-dependabot-stale-wake-backfill stub k8s-job-liveness with a complete object literal rather than spreading `...actual`, so a new export the reaper calls arrives as `undefined` and throws at call time — invisible to typecheck, and it took out two process-recovery reaper tests. Stub it as "unknown" (no pod-CPU evidence), which is the fail-closed branch and therefore the pre-BLO-20251 reaper behaviour those tests assert on. Co-Authored-By: Claude <noreply@anthropic.com>
…251) Ally review: PAPERCLIP_K8S_AGENT_POD_BUSY_CPU_MILLICORES was parsed with Math.max(1, Number(...)). NaN survives Math.max, so a malformed deployment value such as "abc" yielded a NaN threshold. Every `millicores >= NaN` comparison is false, so every sampled pod classified as "idle" and the hard-stale reaper would kill the live subprocesses this module exists to protect - the opposite of the documented fail-closed behavior, and a silent re-introduction of the BLO-20251 incident via a config typo. Replace both reads with a numberFromEnv helper that rejects non-finite and out-of-range values, logs a warning, and falls back to the documented default. It falls back rather than throwing because these are background reaper tunables read at import time; a typo should not take the API server down. The cache-TTL constant had the identical latent defect (Math.max(0, NaN) -> NaN disables the cache), so it uses the same helper with minimum 0, where 0 legitimately means "no caching". Tests: malformed values fall back instead of producing NaN, a busy pod still reads busy under a rejected override, valid overrides are honoured, and 0 is accepted only where the minimum allows it. Co-Authored-By: Claude <noreply@anthropic.com>
…-20251) The BLO-20251 pod-CPU liveness probe added a metrics.k8s.io client, built inline with batchApi/coreApi inside initClient's single try. `makeApiClient` throws `TypeError: apiClientType is not a constructor` when handed an absent symbol, and that throw landed in the shared catch — marking the WHOLE client `unavailable`. hasActiveJobForAgent fails OPEN on a non-ready client (`return false`), so an optional add-on's construction failure silently switched off the BLO-20801 double-dispatch guard. In production that admits a second run against a live Job (RWO PVC multi-attach); in CI it turned all 13 cases of k8s-job-liveness-run-scoped.test.ts red at once, because that file's mock exports no CustomObjectsApi. Construct the metrics client in its own try and type it nullable. A null metrics client costs pod-CPU liveness only; the dispatch guard is unaffected. readAgentPodCpuMillicoresByRunId checks for null before the cache so a missing client can never be mistaken for a cached empty map. Adds a regression test asserting the invariant directly: losing the optional metrics client must never weaken dispatch blocking. Verified non-vacuous — 14 fail without this fix, 17 pass with it. Co-Authored-By: Claude <noreply@anthropic.com>
…(BLO-20251) The `policy` job's shard-partition guard requires the duration manifest to cover >=90% of the general-server suite set. Master sits one suite off that cliff (398/442 = 90.05%), so this PR's new heartbeat-hard-stale-subprocess-liveness.test.ts tipped it to 398/443 = 89.84% and failed `policy` — which, being a `needs` dependency, skipped General tests, Build, Typecheck and e2e and reported `verify` as failed. None of that was about this diff. Recording the suite restores 399/443 = 90.06%. Locally all 10 cases of scripts/__tests__/run-vitest-stable-shard.test.mjs now pass, including the duration-balance assertion. The 22961ms figure was measured locally, not sampled from the ARC run named in the manifest's $comment, because the suite postdates that run. It feeds shard balancing only. Provenance is noted inline so the next full regeneration overwrites it knowingly. The underlying defect — a monotonically decaying coverage ratio with no hysteresis, which fails unrelated PRs repo-wide (6 of 14 sampled runs, including merge-queue runs) — is filed separately as BLO-30011. This commit only unblocks this PR. Co-Authored-By: Claude <noreply@anthropic.com>
…ignal BLO-30087. PR #1465 taught the hard-stale reaper to spare a run whose pod is demonstrably burning CPU, up to a 3h ceiling. The stale-lock sweeper in recovery/service.ts was never taught the same thing and still frees a `running` holder's issue lock at 2h. That gap creates a state which could not previously exist: a run that is alive and actively writing a shared workspace while its issue lock reads free. Before #1465 the 45min reaper always killed before the 2h sweeper could reach the lock, so the reaper was an accidental mutual-exclusion backstop. #1465 removes that backstop for busy pods without extending the liveness signal to the second consumer, so a sibling can take a legitimate clean acquire on an issue whose holder is mid-write and — in shared_workspace mode — end up with the same cwd. The probe is a read at reap-decision time and writes no activity column, so lastOutputAt/lastUsefulActionAt stay frozen at the original silence timestamp: exactly the columns the sweeper reads. - Hoist the two silence bounds into k8s-job-liveness.ts, the leaf module that already owns probeAgentPodActivity, so both consumers resolve one source. heartbeat.ts imports recovery/service.js, so recovery cannot import back and the constant could not simply be shared across. - Sweeper consults probeAgentPodActivity before clearing a `running` holder's lock. Chosen over raising STALE_RUNNING_ISSUE_LOCK_MS to 3h, which would delay reclamation for genuinely wedged holders and regress BLO-19941. - The probe runs in the pre-transaction candidate scan and is memoized; the in-transaction revalidation reads the memo synchronously. Probing in place would hold a Postgres transaction open across a k8s network round-trip while it holds issues and heartbeat_runs FOR UPDATE. - Fails closed: only positive "busy" evidence spares a lock, so with no metrics-server behaviour is byte-for-byte pre-change. Past the shared ceiling a busy-looking zombie loses its lock regardless, so reclamation keeps a bound. Tests cross the seam that both existing suites missed. The sweeper suite asserted the holder is left `running` and called that safety; the reaper suite asserted a busy run survives and never asserted it still owns its lock. Neither asked what a sibling can do next, which is why both passed while this was live. The new case asserts the lock is intact AND that a second run of the same agent is refused with a 409 — verified to fail without the fix. Plus idle/unknown regression guards and a drift assertion pinning all three bounds, including productivity-review's NON_LIVE_EXECUTION_SILENCE_MS, whose "matches STALE_RUNNING_ISSUE_LOCK_MS" comment nothing enforced. The two exhaustive k8s-job-liveness stubs need the hoisted constants listed: an undefined bound fails silently, not loudly, because every `silentMs >= undefined` is false and the hard-stale kill would stop firing. Co-Authored-By: Claude <noreply@anthropic.com>
… transaction Ally flagged the in-transaction `busySparedByRunId.get(runId) === true` read in `currentRunningLockSilent` three times at head 09c8e2b as an Important issue: it does not re-derive AGENT_POD_BUSY_MAX_STALE_MS, so a holder probed just under the ceiling looked like it could keep its lock past it. Investigated and the branch is not reachable with `true`. Being spared and reaching the transaction are mutually exclusive: executionLockExpired = isPreClaimLockExpired(...) || runningLockSilent runningLockSilent = isRunningLockSilent(...) && !isBusySparedRunningHolder(...) A non-cleanable `running` holder that is not expired hits `continue` before `db.transaction` opens, so a `running` holder only reaches the transaction when the busy spare returned FALSE — i.e. the memo is `false` or absent exactly when the branch runs. No behaviour change is needed and none is made here. But that guarantee lived in the ordering of two guards ~150 lines apart and nothing enforced it. Reorder the candidate filter and the branch stops being a no-op and becomes the bug it was mistaken for. This adds the missing guard: * a busy, in-band holder is asserted NOT to reach the transaction, and to keep every lock column; * a control holder in the same band with no busy evidence is asserted to reach the transaction and be cleared, so the negative assertion cannot pass vacuously. Mutation-checked: dropping the busy spare from the candidate filter makes this case fail (spared holder reaches the transaction); the suite is 46/46 green on the unmodified code.
|
@ally please re-review at head What changed and why. The branch was rebased onto The rebase is content-neutral. I verified the rebased tree is byte-identical to merging the previously-reviewed head into master ( On the recurring Focus: confirm the rebase preserved the BLO-30087 fix ( |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0693e46
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Pod CPU is summed across containers, so DinD-backed subprocesses count as activity, while the busy-pod deferral has an absolute stale ceiling.
- Metrics failures, missing samples, and malformed quantities remain
unknownrather than being treated as idle, preserving fail-closed reaping behavior. - The optional metrics client is isolated from core Kubernetes client initialization, so metrics API drift cannot disable the existing dispatch guard.
- The reaper and stale-lock sweeper share the liveness bounds, and tests cover the reaper/sweeper lock-ownership seam and sibling checkout safety.
Recommended Action
- No Critical or Important issues found at this exact head.
- Merge after the repository's pending CI checks complete.
Thinking Path
Linked Issues or Issue Description
What Changed
probeAgentPodActivity(runId)inserver/src/services/k8s-job-liveness.ts— readsmetrics.k8s.ioPodMetricsviaCustomObjectsApi, summing CPU across a pod's containers so a docker build burning CPU in the DinD sidecar counts as liveness for the run that launched it. Returnsbusy | idle | unknown.parseCpuQuantityToMillicores— normalises the units metrics-server actually emits (n,u,m, bare cores). Returnsnullrather than0for unparseable input, and a pod with no parseable sample is left out of the map entirely so it reportsunknownrather than a fabricated0.reapOrphanedRuns(pre-adapter, rich-jobStatus, andliveJobRunIdssnapshot) defer while the pod is busy, bounded byEXTERNAL_LIFECYCLE_BUSY_POD_MAX_STALE_MS(4× hard-stale = 3h) so a CPU-burning zombie still cannot hold a slot forever.metrics.k8s.io/pods: get,liston the existing-k8s-adaptersRole (already bound to the server's service account).heartbeat-hard-stale-subprocess-liveness.test.ts; plusprobeAgentPodActivityadded to the two exhaustivek8s-job-livenessmocks (see Risks).Why pod CPU over the alternatives, documented at the constant: adapter stdout is the signal that already fails here; workspace mtime catches a dependency install but not a docker build, whose writes go to the sidecar's
emptyDirrather than the workspace; a longer grace window only trades a wrong answer for a slower wrong answer.Verification
Verified against the live cluster before choosing the signal — this is empirical, not assumed:
PodMetricsobjects do mirror pod labels, sopaperclip.io/run-idis present and themanaged-byselector filters server-side.nanduunits in the same listing (e.g.736865882n,22099u) — hence the unit handling in the parser.New tests pin all four arms of the decision:
unknown) still reaps, so a cluster without metrics-server is unchanged;Plus pure unit coverage of the CPU-quantity parser (runs without a database).
pnpm -r typecheckpasses clean.Risks
unknown, which reaps exactly as before this PR.unknownis deliberately not a synonym foridle.k8s-job-livenesswith a complete object literal instead of spreading...actual, so any new export the reaper calls arrives asundefinedand throws at call time. Typecheck does not catch it; it surfaced here as two failing process-recovery tests. Fixed in both, but the pattern will bite the next person to add an export.paperclip-pg-0access, which this environment does not have): capture the pre-deploy baseline and re-run theexternal_lifecycle_stale_killedcount 7 days after deploy, per the issue's verifying signal.Model Used
claude-opus-5[1m]), 1M context, extended thinking, tool use — run as a Paperclipclaude_k8sagent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template