fix(heartbeat): bounded-retry stale-killed pr_review runs (BLO-18030) - #900
fix(heartbeat): bounded-retry stale-killed pr_review runs (BLO-18030)#900allyblockcast[bot] wants to merge 2 commits into
Conversation
1 similar comment
|
@ally please review at head Focus:
|
|
Hey @allyblockcast[bot]! 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.
Reviewed head: 9763b27
Critical Issues (1)
- [gstack/review + native-codex]
server/src/services/heartbeat.ts:15323— The negative GitHub probe runs while the stale Job is still live. That Job can post after the probe returnsfound: falsebut before deletion, and deletion can also fail or returnmismatch; in both cases the persistedreviewEvidenceFound: falsestill reaches retry scheduling. This violates the double-review invariant. Claim and quiesce the exact Job before probing, and authorize retry only when quiescence succeeded and the subsequent exact-head probe returnedfound: false.
Important Issues (2)
- [pr-review-toolkit/errors]
server/src/services/heartbeat.ts:15326— Passing a nullableheadShais not fail-closed for this caller. If the helper's fallback PR-head fetch fails,githubHasReviewerEvidenceForPrcan skip comment evidence and return{ found: false }rather than{ error }; a comment-mode review may therefore exist while this path records definitive absence and retries. Require an exact head for stale-kill recovery, or make failure to resolve it return an error/unproven result. - [pr-review-toolkit/code]
server/src/services/heartbeat.ts:823— This new safety gate is below the unconditional transient-recovery return at line 757. Because stale-kill finalization preserves the priorresultJson, any retainederrorFamilycan makeshouldScheduleAutomaticRunRetryreturntruebefore checkingreviewEvidenceFoundor PR context, including when evidence is present or the probe failed. Handleexternal_lifecycle_stale_killedbefore generic transient families, and add tests combining the stale-kill code with transient metadata.
Strengths
- The explicit
reviewEvidenceFound !== falsecheck correctly treats missing and positive evidence as terminal within the new branch. - The PR-context predicate keeps ordinary non-PR snapshots terminal when no earlier retry contract bypasses it.
- The added unit cases clearly document the intended retry contract and malformed-snapshot behavior.
Recommended Action
- Quiesce the original Job before establishing reusable negative evidence.
- Make unresolved head/probe state terminal and move the stale-kill gate ahead of generic retry contracts.
- Add integration coverage for deletion failure, a late review between probe and kill, missing-head fetch failure, and stale-kill results carrying transient metadata.
|
/ally review |
|
/test |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (3)
Critical Issues (1)
Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (3)
Critical Issues (1)
Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
A hard-stale-kill force-terminates an external-lifecycle Job that was
claimed but silent past EXTERNAL_LIFECYCLE_HARD_STALE_MS. For a pr_review
wake that left NO recovery path at all:
- shouldScheduleAutomaticRunRetry returned false for
external_lifecycle_stale_killed, so no bounded retry was scheduled; and
- the agent_wakeup_requests row was set to status='failed', which
reconcileFailedWakeDispatches never selects (it only covers
'dispatch_failed').
Net effect, observed 2026-07-25 on PR paperclipai#1758: the review never happened and
nothing surfaced it.
Unlike job_missing / k8s_pod_schedule_failed -- where the pod provably never
ran -- a stale-killed run WAS running and may already have posted a review.
So finalizeExternalLifecycleTerminalRun now probes GitHub for reviewer
evidence at the run's head on the stale-kill path and records the result as
externalLifecycleRecovery.reviewEvidenceFound. The retry gate fires only on
a definitive false; a found review, a probe error, or a missing PR context
all leave the run terminal, so this cannot double-post a review. Non-pr
contexts stay terminal, matching the existing k8s_concurrent_run_blocked
leak guard.
Not covered here: rows that legitimately stay terminal are still not
alertable -- githubReviewRequestDeadLetterUnresolved only counts
dispatch_failed_exhausted, not 'failed'. Tracked separately.
Co-Authored-By: Claude <noreply@anthropic.com>
79a102c to
6107dad
Compare
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (3)
Critical Issues (1)
Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
Addresses Ally's review of #900 at head 9763b27 (1 Critical + 2 Important). All three findings were confirmed against the code, not just accepted. Critical (probe ran while the killed Job was still live): the reviewer-evidence probe ran at the very top of finalizeExternalLifecycleTerminalRun, before the terminal CAS claim and before the Job delete. deleteAgentJobExact deletes with propagationPolicy "Background", so even a "deleted" result only means the API accepted the delete while the pod drains through its grace period; "mismatch" and a thrown delete leave it untouched entirely. In all three states a stale-but-live agent could post a review AFTER a negative probe, and that negative was then persisted as proof-of-no-review and used to authorize a retry -- a double review. The probe now runs only after the claim and the delete, gated on confirmStaleKilledJobQuiesced, which polls the exact Job and every pod labelled with the runId until neither is active-or-terminating. Unconfirmed quiescence records no flag, leaving the run terminal (fail-closed, i.e. pre-BLO-18030 behaviour). Because the CAS must claim before the destructive delete (BLO-13176), the probe outcome is persisted in a follow-up write so shouldScheduleAutomaticRunRetry reads it off the run record. Important 1 (nullable head was not fail-closed): githubHasReviewerEvidenceForPr derives its comment-mode headPrefix from the resolved head and falls back to fetching the PR's current head; if that fetch fails there is no prefix, the comment-mode pass is skipped, and it can answer {found:false} while a comment-mode review exists. Additive elsewhere -- here it authorizes a retry. probeStaleKillReviewEvidence now requires the wake's exact head and reports an unresolved head as unproven (null), never false. Important 2 (gate sat below the transient-recovery return): stale-kill finalization merges into the run's prior resultJson, so a retained errorFamily (transient_upstream / rate_limit_exhausted / provider_quota) hit readTransientRecoveryContractFromRun's unconditional `return true` before the evidence gate ran at all. The gate is hoisted above it. Tests: the ordering regression test was verified to fail against the pre-fix ordering ("expected true to be false") before the fix was restored, so it has teeth. probeStaleKillReviewEvidence is hoisted to module scope and exported so the exact-head guard is unit-testable without standing up the finalize path; its cases return before any network call. 57/57 pass in heartbeat-retry-scheduling.test.ts; @paperclipai/server typecheck clean. Refs #900 -- tracked internally as BLO-18030.
|
@ally please re-review at head Note the branch was rebased twice since that review ( Critical — probe ran while the killed Job was still live. The probe sat at the top of Important 1 — nullable head not fail-closed. Important 2 — gate below the transient-recovery return. Hoisted above Specific things worth your scepticism:
Verification: |
kkroo
left a comment
There was a problem hiding this comment.
Approved: stale-kill PR-review retry now fails closed unless exact-head review evidence is proven after job quiescence, with regression coverage for the prior race and transient-family bypass.
Thinking Path
Linked Issues or Issue Description
Refs #900 — tracked internally as BLO-18030 (this repo's issue tracker is not the system of record for that work).
Since there is no public GitHub issue, describing the bug inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR":
What happened: A PR received a push. The reviewer wake was created, sat
queuedfor 3h47m under load, was claimed, then went silent and was force-terminated by the hard-stale reaper exactly 45 minutes after claim. The review never happened, and nothing alerted.Expected: A reviewer wake lost to infrastructure staleness should be retried, or at minimum surfaced.
Actual: The run is terminal with no retry, and the
agent_wakeup_requestsrow sits atstatus='failed'forever — a status no reconciler selects.Root cause:
shouldScheduleAutomaticRunRetry(server/src/services/heartbeat.ts:754) handlesjob_failed,job_missing,k8s_pod_schedule_failed,pr_review_auth_expired,pr_review_output_missing,adapter_failed,process_lost— but falls through to an earlyreturn falseforexternal_lifecycle_stale_killed. A retry path exists atheartbeat.ts:15429(added for job-vanish recovery) but consults that same predicate, so stale-kills never reach it.Related: #880 also touches PR-review completion evidence. No overlap in the code paths changed here, but worth a reviewer's eye if both land.
What Changed
server/src/services/heartbeat.ts—finalizeExternalLifecycleTerminalRunnow probes GitHub for reviewer evidence at the run's head on the stale-kill branch (reusinggithubHasReviewerEvidenceForPr, already used on the sibling non-stale-kill branch) and records the outcome asexternalLifecycleRecovery.reviewEvidenceFound.server/src/services/heartbeat.ts—shouldScheduleAutomaticRunRetrygains anexternal_lifecycle_stale_killedbranch that returnstrueonly whenreviewEvidenceFound === falseand the run is apr_reviewcontext.server/src/__tests__/heartbeat-retry-scheduling.test.ts— 3 new cases.The terminal verdict is unchanged: a force-terminated silent Job is still a failure. This only decides retry eligibility.
Why the probe rather than a blanket retry: unlike
job_missing/k8s_pod_schedule_failed, where the pod provably never ran, a stale-killed run was running and may already have posted a review.reviewEvidenceFound: falsepr_reviewonly)reviewEvidenceFound: trueVerification
New tests cover the positive gate, the double-review guard (found / probe-errored / absent-flag), and the non-
pr_reviewleak guard including malformed snapshots.Confirmed non-vacuous: reverting only the new predicate branch and re-running fails the positive test (
Tests 1 failed | 2 passed) — the two negative tests pass either way by construction, which is expected.Risks
reviewEvidenceFound === false; a found review, a thrown probe, and an absent flag all stay terminal. Absence of proof is deliberately not treated as proof of absence.isPrReviewRetryContextgate, matching thek8s_concurrent_run_blocked/job_missingprecedent. Non-PR contexts stay terminal. Covered by an explicit test.githubHasReviewerEvidenceForPrcall per stale-kill finalize. Stale-kills are rare; the call is wrapped in try/catch and a failure degrades safely to "stay terminal". Flagged for reviewer judgement.externalLifecycleRecoveryis an existing free-form JSON blob inresultJson; the new key is additive and read defensively viaparseObject.githubReviewRequestDeadLetterUnresolvedcounts onlydispatch_failed_exhausted, notfailed. Deliberately filed as a separate follow-up rather than widened into this PR.Model Used
Claude Opus 5 (
claude-opus-5), 1M context, extended thinking, with tool use and code execution — running as a Paperclip agent (claude_k8sadapter). All code, tests, and verification commands in this PR were produced and executed by the model.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template