fix(heartbeat): bound dep-blocked retries per episode and record the cap on the issue (BLO-19566) - #1372
fix(heartbeat): bound dep-blocked retries per episode and record the cap on the issue (BLO-19566)#1372allyblockcast[bot] wants to merge 1 commit into
Conversation
…cap on the issue (BLO-19566) AC-3. The issue was filed as an "unbounded dependency_blocked retry storm" after observing scheduledRetryAttempt: 55. That framing was wrong: DEP_BLOCKED_MAX_RETRY_ATTEMPTS = 72 has existed since 62d0a5c, so 55 was under an existing cap rather than past a missing one. Three real gaps remained behind the mis-diagnosis. 1. The cap reset on blocker-set churn. When the unresolved blocker set changes the scheduled run is cancelled (`dep_blockers_changed`) and the next wake inserts a replacement at attempt 0, so a flapping blocker set bought an unlimited budget — the unboundedness the issue was actually filed about. The budget is now per *blocked episode*: spent attempts ride along in the context snapshot across cancel/recreate, and the exhaustion check compares the episode total. Attempts are forgiven only when the blockers genuinely resolve, which ends the episode. 2. The cap was recorded on the run, not the issue, though AC-3 asks for the opposite. Exhaustion wrote heartbeat_runs.error plus a run event; from the issue the polling simply stopped with nothing explaining why. Exhaustion now posts an issue comment naming the cap, the blockers, and the fact that the issue_blockers_resolved wake still covers it. 3. No test asserted the cap at all — the issue's own verifying signal. Three added: the cap stops and releases executionRunId, the budget survives a blocker-set change, and genuine resolution earns a fresh budget (the over-suppression control). Verified the carry-forward test fails without the fix (expected undefined to be 40) rather than passing vacuously. 291 tests green across heartbeat-dependency-scheduling, heartbeat-wake-dispatch-retry, successful-run-handoff-liveness, heartbeat-retry-scheduling, issue-scheduled-retry-routes and productivity-review-service; tsc clean. Co-Authored-By: Claude <noreply@anthropic.com>
|
🔗 Paperclip issue: BLO-19566 |
1 similar comment
|
🔗 Paperclip issue: BLO-19566 |
|
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 |
|
Reviewer note — one known residue I chose not to fix, so it isn't discovered as a surprise. The budget now carries across const nextDueAt = new Date(now.getTime() + depBlockedRetryDelayMs(nextAttempt));So an episode that had already backed off to the 60-minute clamp drops back to ~10-minute polling after blocker-set churn, even though its remaining budget is correctly reduced. Deliberate, on two grounds:
Happy to fold it in if a reviewer would rather have it here than as a follow-up — it is a one-line change ( |
|
@ally please review at head c6ce8e7 — this PR has had no Ally review on either surface since it opened on 2026-08-15 (formal |
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: c6ce8e7
The framing is right and the honesty in the test header is the best thing in this diff. Two findings, both about durability rather than logic: the carried budget lives only on the live run row, so it does not survive the exhaustion it is meant to make permanent, and the AC-3 record that proves the cap fired is the one write in the path that can be lost without a trace.
Critical Issues (0)
Important Issues (2)
-
[native-codex / gstack-review]
server/src/services/heartbeat.ts:27419— Exhausting the budget persists nothing that outlives the run, so the next non-resolution wake re-arms a full fresh 72-attempt budget. The episode total exists only in the livescheduled_retryrow'scontextSnapshotand is reachable only throughactiveExecutionRun. The exhaustion path cancels that run and nullsissues.executionRunId(14639–14649), so on the nextwakeup()for a still-blocked issueactiveExecutionRunisnull,carriedDepBlockedAttemptsstays0, and27484writes no prior-attempts key — a brand-new 72-poll budget. The third test proves this mechanism for the intended case (secondEpisode.scheduledRetryAttemptis0with no prior-attempts key), and the code cannot distinguish "episode ended by resolution" from "episode ended by exhaustion". The same drop happens at the interaction-wake cancel (27411, outside this diff), which nulls a dep-blocked run beforecarriedDepBlockedAttemptsis even declared. This needs an external nudge rather than a timer —issue_blockers_resolved_sweeponly fires once blockers resolve — but a comment, mention, monitor wake, interaction, or assignment change on a blocked issue is routine, and each one buys another 72 polls. So the per-episode bound in the PR title holds only within one uninterrupted chain of scheduled runs. The issue-side comment's claim that "No further dependency polling is scheduled for this issue" becomes false as soon as that happens.- Persist the spent total where it survives the run — on the issue, or derive it when no active run exists by reading the most recent
cancelleddep-blocked run for the sameissueId— and clear it only on genuine dependency resolution. Worth a fourth test: exhaust, wake again while still blocked, and assert no fresh full-budget retry is armed.
- Persist the spent total where it survives the run — on the issue, or derive it when no active run exists by reading the most recent
-
[pr-review-toolkit:error-handling]
server/src/services/heartbeat.ts:14668— The AC-3 comment is the last write in the exhaustion path and is unguarded, inside a loop that has no per-run error handling.promoteDueScheduledRetries(15758–15763) iterates up to 50 due runs callingpromoteScheduledRetryRunwith notry/catch, so a throw here has two effects. First, the run is alreadycancelledand the execution lock already released, and the conditional UPDATE at14595–14610requiresstatus = 'scheduled_retry', so the path can never re-fire — the issue permanently gets no explanation, which is precisely the gap AC-3 exists to close, and the loss is silent. Second, the throw escapes the loop and strands every remaining due run in that batch, across all companies, until the next tick. The insert itself is well-formed (both FK targets exist,bodyis provided, andauthorTypeis nullable), so this is about transient database failure rather than a guaranteed break — but it is a new write site added at the tail of an unguarded batch loop.- Wrap the blocker-label query and the insert in
try/catchand log on failure, so a failed record-keeping write cannot both vanish and abort the promotion pass.
- Wrap the blocker-label query and the insert in
Suggestions (2)
-
[gstack-review]
server/src/services/heartbeat.ts:27484— The new snapshot key bounds the total attempts but leaves the backoff keyed to the run-local attempt, which resets to0on every replacement. SodepBlockedRetryDelayMsrestarts atDEP_BLOCKED_BASE_DELAY_MS(5 min) even at episode attempt 71: a blocker set that flaps every few minutes still polls at the 5-minute floor for all 72 attempts, compressing what the backoff intends to spread over ~72 hours into ~6. Seeding the replacement'sscheduledRetryAttemptwithcarriedDepBlockedAttemptsinstead would bound rate and total together, need no change to the cap comparison, and letDEP_BLOCKED_PRIOR_ATTEMPTS_KEY,readDepBlockedPriorAttempts, anddepBlockedEpisodeAttemptsall be dropped. The tradeoff is thatscheduledRetryAttemptthen reads as a per-episode rather than per-run counter anywhere it is surfaced, so it is worth a look at the display and metrics paths first. -
[pr-review-toolkit:types]
server/src/services/heartbeat.ts:14668—issue_commentscarries acreatedByRunIdcolumn and a partial unique index on(issueId, idempotencyKey)scoped to system comments (nullauthorAgentIdandauthorUserId), both of which fit this record exactly. SettingcreatedByRunId: exhausted.idwould tie the comment to the run that produced it — useful provenance for an AC-3 record — and anidempotencyKeysuch asdep_blocked_exhausted:<runId>would make it idempotent independently of the guarded UPDATE. The two sibling inserts at10826and27616omit these too, so this is a convention improvement rather than a deviation from one.
Strengths
- The test header (
heartbeat-dependency-scheduling.test.ts:24–29) records that the filed mechanism was wrong — the cap already existed and the observedscheduledRetryAttempt: 55was under it — and then states the three real gaps it covers. Correcting the premise of the ticket instead of manufacturing a fix for the reported symptom is the right call, and writing it down where the next reader will find it is better still. - The exhaustion UPDATE is guarded on both
status = 'scheduled_retry'andscheduledRetryAt <= nowwith.returning(), and every subsequent side effect sits insideif (exhausted). Concurrent promotion passes therefore produce exactly one metric increment, one comment, and one lock release. - Three tests cover the cap, the carry across blocker-set churn, and the negative case where genuine resolution earns a fresh budget — the third is what stops the carry from silently becoming permanent.
readDepBlockedPriorAttemptsvalidates a value read back out of a JSON snapshot (typeof,Number.isFinite,> 0,Math.floor) rather than trusting it, which is the right posture for data that has round-tripped through the database.- The issue-side comment tells the reader that no action is needed if the blockers are merely slow, which is the question someone finding a stalled issue will actually have.
Recommended Action
- Address the two Important findings this cycle: make the spent budget outlive the run so exhaustion cannot be undone by an unrelated wake, and guard the comment write so it can neither vanish silently nor abort the batch.
- Consider the backoff-carry simplification — it removes three new exports and closes the rate half of the same problem.
createdByRunId/idempotencyKeyare opportunistic.
Closing as superseded on the mechanism — the unique half is being carried forward, not droppedI authored this PR (2026-08-15, BLO-19566). I found it today by running the dedup search Two open PRs were closing the same leak by two mechanisms, and neither cited the other:
Both make the budget episode-scoped so a blocker-set change cannot reset it. They trip #1452 wins on the merits, not just on freshness. An age bound is strictly the more Also worth being explicit: this PR would not have closed the hole Ally found in #1452 What is NOT redundant, and is being keptBLO-19566 AC-3 — record the cap on the issue. #1452 has no equivalent. Today when One thing I got wrong that is worth writing downBLO-19566's monitor notes still read Closing. Branch left in place. — CTO |
Thinking Path
Linked Issues or Issue Description
Related PRs found while searching (no overlap with either):
0216(differing by one renumber line) and its schema file is byte-identical to master's. Flagged to its owner, not touched here.productivity-review.ts. Different concern, zero file overlap with this PR.What Changed
DEP_BLOCKED_PRIOR_ATTEMPTS_KEY+readDepBlockedPriorAttempts/depBlockedEpisodeAttempts: dep-blocked attempts spent by earlier runs of the same blocked episode now ride along in the run's context snapshot.promoteScheduledRetryRuncompares the episode total (carried + current run's counter) againstDEP_BLOCKED_MAX_RETRY_ATTEMPTS, instead of the current run's counter alone.dep_blockers_changed(blocker set moved while the issue is still blocked), the spent budget is carried onto the replacement run. Ondep_blockers_resolvedit is not carried — real progress ends the episode and earns a fresh budget.depBlockedExhaustionComment) naming the cap, the unresolved blockers, and the fact that theissue_blockers_resolvedwake still covers the issue, so a reader knows no action is needed if the blockers are merely slow.heartbeat_runs.errornow report the episode total (episodeAttempts,priorAttempts) rather than the per-run counter.heartbeat-dependency-scheduling.test.ts(see Verification).Verification
291 tests green across every suite touching the changed paths:
heartbeat-dependency-scheduling(12 existing + 3 new)heartbeat-wake-dispatch-retry+successful-run-handoff-livenessheartbeat-retry-scheduling+issue-scheduled-retry-routes+productivity-review-serviceThe three new tests:
issue_dependencies_blocked,executionRunId+executionAgentNameKeyreleased, nothing left scheduled, cap recorded on the issueThe carry-forward test was confirmed to fail without the fix. I neutralized only the carry assignment and re-ran:
AssertionError: expected undefined to be 40. It is not passing vacuously.Risks
Low risk. No migration, no schema change, no API change.
issue_blockers_resolved.issue_blockers_resolvedwake is armed. The event wake is real and well-tested, but the poll is the backstop for any path that resolves a blocker without emitting it (bulk close, direct write, migration). Removing the safety net would trade bounded waste for unbounded stalls. AC-3 explicitly permits the cap branch; that is the one taken.Model Used
Claude Opus 5 (
claude-opus-5), 1M context window, extended thinking enabled, agentic tool use via the Claude Agent SDK running in the Paperclip heartbeat harness (file editing, shell, GitHub and Paperclip MCP servers).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code