fix(heartbeat): stop park/promote from bypassing timer suppression (BLO-31344) - #1628
fix(heartbeat): stop park/promote from bypassing timer suppression (BLO-31344)#1628allyblockcast[bot] wants to merge 1 commit into
Conversation
|
🔗 Paperclip issue: BLO-31344 |
1 similar comment
|
🔗 Paperclip issue: BLO-31344 |
|
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 |
…LO-31344) `skipTimerWhenNoActionableWork` was defeated by the scheduled-retry park/promote path in two independent places, so the knob worked on an idle fleet and stopped working under exactly the load that makes a wasted paid invocation most expensive. 1. The wake-time suppression gate was ordered *after* the penstock capacity gate. That gate does not decline a wake, it postpones it: it commits a `scheduled_retry` run and returns. So during a capacity crunch the suppression gate was never reached at all. Reordering collapses the common case with no run row, no park, no promotion and no adapter invocation. 2. Promotion never re-enters `wakeup()` — `promoteDueScheduledRetries` reads `scheduled_retry` rows straight from the DB — so no gate living there evaluates for a promoted retry, whatever parked it. Re-probe suppression in `promoteScheduledRetryRun`, cancelling with a new `timer_no_actionable_work` gate code. Both halves are required. (1) cannot help an already-parked row, nor a `dependency_blocked`/`transient_failure` park whose original wake was generic; (2) alone would still pay for the park and its promotion cycle. Guarded so a scoped retry is never suppressed: eligibility is reconstructed from the stored snapshot by `isGenericTimerWakeSnapshot`. That helper deliberately does not test `deriveTaskKey(snapshot)` for null — a generic tick is persisted with the `__heartbeat__` sentinel by the heartbeat fallback in `enrichWakeContextSnapshot`, while the live predicate reads the plain null key. On a stored row the sentinel is evidence the wake *was* generic, so a non-null check would be false for every row the predicate exists to match and would silently never fire. AC4 preserved: `hasActionableTimerWork` is unchanged, so a `todo`/`in_progress` row with unresolved blockers still counts as actionable; only `status: blocked` is excluded, by the pre-existing status filter. Verified: 10 new tests. Against unfixed `heartbeat.ts` the three behavioural ones fail — a park row is created where none should be, and generic `ccrotate_capacity` and `transient_failure` parks both promote — while the four preserved-behaviour guards pass on both sides. 69 tests green across the suppression and capacity-retry suites, 96 across retry/dependency scheduling. Co-Authored-By: Paperclip <noreply@paperclip.ing>
5c722a9 to
3c8c0cf
Compare
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: 3c8c0cf
Both halves of the fix are real, and I verified the mechanism rather than taking the description on trust. The __heartbeat__ sentinel reasoning in the new helper's doc comment is correct: enrichWakeContextSnapshot stamps contextSnapshot.taskKey = HEARTBEAT_TASK_KEY via the fallback at heartbeat.ts:8198 while returning the plain deriveTaskKey value, so a naive non-null check really would never fire. The capacity park's snapshot spreads enrichedContextSnapshot and re-asserts wakeSource (heartbeat.ts:31549), so the predicate does match production rows. The wake-side reorder is minimal — suppression and the penstock gate swap, nothing else moves — and every local it reads (policy, taskKey, enrichedContextSnapshot, agent) is bound well before the new position.
Critical Issues (0)
Important Issues (2)
-
[code / gstack-review]
server/src/services/heartbeat.ts:17468— the promotion-time gate cancels continuation parks, not just never-started ticks, andcancelledis terminal.
An issuelessmax_turns_continuationpark reaches this block unimpeded:evaluateScheduledRetryGateshort-circuits with{ allowed: true }when the snapshot carries noissueId(heartbeat.ts:17154), and the issueless case is an explicitly supported park state (heartbeat.ts:18683-18692branches onif (issueId) … else …). Such a park exists precisely because the agent ran out of turns mid-task; if the lane's assigned queue happens to be empty at promotion — the normal situation for an agent doing issueless work such as sweeps, reports or PR review — the continuation is now cancelled with no path back to it. The same reaches this block forsession_unavailable/zero_token_session_reset/job_failed/capacity_blocked, allrequiresIssueExecutionRetryLockreasons deliberately designed to survive a retry cycle.- The knob's contract is "skip a timer tick with nothing to do", and a fresh tick is separable from a continuation by row shape: the capacity park this PR targets is inserted without
retryOfRunId(heartbeat.ts:31655-31686), whereas everyscheduleBoundedRetryForRunpark setsretryOfRunId: run.id(heartbeat.ts:18852). AddingdueRun.retryOfRunId === nullto the condition — or excludingrequiresIssueExecutionRetryLock(dueRun.scheduledRetryReason)— keeps the stated target intact and cannot eat a continuation. If cancelling continuations is deliberate, please say so in the comment and on the issue: it is a larger behavior change than the title claims.
- The knob's contract is "skip a timer tick with nothing to do", and a fresh tick is separable from a continuation by row shape: the capacity park this PR targets is inserted without
-
[tests]
server/src/__tests__/heartbeat-timer-suppression-park-bypass.test.ts:390— the "regardless of which path parked it" test asserts generality against a row shape that path never writes.
seedPark(:227-246) hardcodesinvocationSource: "timer",wakeupRequestId: nulland noretryOfRunId— that is a capacity park with the reason string swapped to"transient_failure"at:399. A real bounded-retry park is inserted withinvocationSource: "automation", a non-nullwakeupRequestIdbound to a queued wakeup request, andretryOfRunId: run.id(heartbeat.ts:18845,:18848,:18852). So the test never exercises thewakeupRequestIdcancellation branch incancelScheduledRetryForGate, and it would keep passing under theretryOfRunIddiscriminator suggested above — i.e. it cannot detect the finding it is shaped to cover.- Relatedly, nothing pins the coupling the whole promotion-side fix rests on: that a real park's stored snapshot carries
wakeSource: "timer"+taskKey: "__heartbeat__". The suite already produces one genuine capacity park at:284; reading that row back and assertingisGenericTimerWakeSnapshot(parked.contextSnapshot) === truecloses both gaps in a few lines, and is exactly the regression the helper's own doc comment warns fails silently.
- Relatedly, nothing pins the coupling the whole promotion-side fix rests on: that a real park's stored snapshot carries
Suggestions (2)
- [comments]
server/src/services/heartbeat.ts:17456— the comment claims this block is needed for "adependency_blocked/transient_failurepark whose original wake was generic". Adependency_blockedpark cannot be generic: it is only written on an issue-scoped wake, withissueIdin the snapshot (heartbeat.ts:32529and its enclosing block), soisGenericTimerWakeSnapshotstructurally excludes it. Thetransient_failurehalf stands; droppingdependency_blockedavoids overstating what the block covers. - [code]
server/src/services/heartbeat.ts:31728— the ~35-line "Heartbeat ccrotate-awareness" comment immediately above documents thegateAppliesToWakepenstock gate, but the newgenericTimerWakeconst and suppression block now sit between it and theifit describes, so it reads as documentation for the suppression gate. Inserting the new block above that comment (or moving the comment down onto itsif) restores the adjacency this file otherwise relies on.
Strengths
- The sentinel trap is documented at the helper rather than at the call site, with the concrete failure mode ("would silently never fire") spelled out — the one thing a future simplification would otherwise break.
isGenericTimerWakeSnapshoterrs toward "not generic" (checkingcommentIdalongsidewakeCommentId), and the guard test at:369pins that a scoped park still promotes — the right asymmetry for a gate that can drop work.- The AC4 test keeps
todo-with-unresolved-blockers counted as actionable, protecting the deliberate BLO-19749 behavior this fix could easily have narrowed. - Both halves are argued as non-redundant in the code, with the reason each alone is insufficient — and that reasoning checks out:
promoteDueScheduledRetriesnever re-enterswakeup().
Recommended Action
- Decide the continuation-park question (Important #1) — either add the
retryOfRunId/ retry-reason discriminator, or state that cancellingmax_turns_continuationis intended and cover it with a test. - Tighten the promotion tests (Important #2) so the generality claim and the snapshot coupling are actually exercised.
- Take the comment fixes opportunistically.
Thinking Path
Linked Issues or Issue Description
Fixes: BLO-31344
Related prior work found in the dedup search below:
todo/in_progressbut unavailable to checkout, making the predicate wrongly returntrue. This PR is the opposite: zero such rows exist, the predicate correctly returnsfalse, and a run is dispatched anyway.promoteScheduledRetryRun. This change adds a block ahead of the capacity re-probe rather than inside it, to avoid entangling with BLO-28919's extension of that branch.What Changed
genericTimerWakesuppression gate inenqueueWakeupto run before the penstock capacity gate. The capacity gate parks rather than declines, so anything after it never evaluates for a parked wake.promoteScheduledRetryRun, ahead of the existing capacity re-probe, cancelling via the existingcancelScheduledRetryForGatepath.timer_no_actionable_workto theScheduledRetryGateerror-code union.heartbeatRuns.errorCodeis plaintext(), not apgEnum— no migration needed.isGenericTimerWakeSnapshot, a persisted-snapshot sibling of the livegenericTimerWakepredicate, placed beside the heartbeat task-key fallback that creates the trap it has to work around.server/src/__tests__/heartbeat-timer-suppression-park-bypass.test.ts(10 tests).Why two changes and not one
Neither half closes the bypass alone. The reorder cannot help a row that is already parked, nor a
dependency_blocked/transient_failurepark whose original wake was generic — those paths never consult the wake gate at all. The promotion re-probe alone would still pay for the park and its promotion cycle.The
__heartbeat__trapisGenericTimerWakeSnapshotdeliberately does not testderiveTaskKey(snapshot)for null.enqueueWakeupevaluates!taskKeyagainst the value returned byenrichWakeContextSnapshot— plainderiveTaskKey,nullfor a generic tick. The snapshot that gets persisted has by then hadderiveTaskKeyWithHeartbeatFallbackstamptaskKey: "__heartbeat__"into it; the fallback writes the sentinel to the snapshot and never to the returned value.So on a stored row the sentinel is evidence the wake was generic, not evidence it was scoped. A non-null check would be false for every row the predicate exists to match — the fix would compile, deploy, and silently never fire. A dedicated test pins this, because it is precisely the case a plausible-looking simplification breaks.
The helper errs toward "not generic" (it checks
commentIdalongsidewakeCommentId): suppressing a genuinely scoped retry would drop the wake its scope was for, which is far worse than promoting one redundant no-op.Verification
Negative control. The tests are only worth anything if they fail without the fix. Reverting
server/src/services/heartbeat.tsto HEAD while keeping the new test file:expected length 0, got 1: a park row was createdexpected 1 to be 0: promoted anywayexpected 1 to be 0: same fortransient_failureisGenericTimerWakeSnapshotunit testsThat reproduces the defect at both bypass sites independently. With the fix restored, 10/10 pass.
The four guards are deliberate:
still capacity-parks when actionable,promotes when actionable again,never suppresses a scoped park, andtodo with unresolved blockers stays actionable. They assert preserved behaviour, so passing on both sides is the correct outcome for them.Risks
text()value.hasActionableTimerWorkis unchanged, so atodo/in_progressrow with unresolved blockers still counts as actionable — onlystatus: blockedis excluded, by the pre-existing status filter. Pinned by a test.agent_wakeup_requestsis the linked issue's primary arbiter and is not readable from an agent seat, so the post-deploy assertion on live rows still needs an operator query. Also note the heartbeat scheduler runs inStatefulSet/paperclip, notDeployment/paperclip-api— confirm the digest on that tier before calling the fix live.Model Used
Claude Opus 4.5 (
claude-opus-5[1m]as served, Anthropic), 1M context, extended thinking enabled, with tool use and code execution via Claude Code running as a Paperclip heartbeat agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateDedup search run:
gh pr list --state all --searchoverskipTimerWhenNoActionableWork,hasActionableTimerWork,no_actionable_work,promoteScheduledRetryRun,31344— results linked above.🤖 Generated with Claude Code