fix(heartbeat): scope the quota-recovery wake to the parked run's task (BLO-28992) - #1502
Conversation
…k (BLO-28992) `provider_quota_exhausted_recovered` was delivered unscoped. The documented agent behaviour on an unscoped wake is to call `inboxLite` and take the top actionable row, and that ranking collapses to a singleton per agent — so when a provider throttle parks N runs of one agent and capacity returns, all N are aimed at the same issue by construction, not by bad luck. Inbox size does not help: observed with both 84-row and 169-row inboxes. Two runs then share one checkout, which in BLO-28442 interleaved writes into `erasure/tracker.go` and transiently produced a file that would not compile. The parked run already knows its own issue via `contextSnapshot.issueId` (exposed as the generated `contextIssueId` column), so this needs no new persisted field and no migration. `finalizeAgentStatus` already had the driving run in `options.runId` but passed `runId: null` to the hook; passing it through is what makes the scope resolvable. Because the hook invokes `onSuccess` per caller even on its debounced and in-flight branches, each parked run runs its own closure and therefore re-delivers its own scope. Scope is resolved at wake time rather than park time and is dropped when it is no longer safe to resume — issue gone, terminal, or reassigned while parked — falling back to today's unscoped wake. The hook can take 60s+ to recover, and waking an agent onto an issue it no longer owns would be a new second-writer defect rather than a fix. No change to checkout, workspace mode, or lock ordering; independent of BLO-27858 as that issue requires. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head Three things I'd most like a second pair of eyes on:
Also flagging honestly: of the 4 tests, only the first is a true regression test — I verified it fails as |
|
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: 714299d
The core change is right: runId: null at the hook call site was the actual root cause, and threading options.runId through is the minimal correct fix. I verified all five substantive finalizeAgentStatus error-path callers (heartbeat.ts:16413, :20598, :21987, :28301, :28466) do pass a real runId, so this is not a no-op in production. Findings below are about the new resolver's failure modes, not the direction.
Critical Issues (0)
None.
Important Issues (3)
-
[gstack/review]
server/src/services/heartbeat.ts:19891— AddingcontextSnapshot.issueIdnewly exposes the recovery wake to project-scoped suppression that the unscoped wake could never hit, with no fallback.- In
enqueueWakeup,projectIdis derived from the issue wheneverissueIdis set (heartbeat.ts:29941), andbudgets.getInvocationBlockearly-returnsnullwhencandidateProjectIdis falsy (budgets.ts:1068). Pre-fix the recovery wake passedissueId=null→projectId=null→ project scope was unreachable. Post-fix, a paused or over-hard-stop project makesgetInvocationBlockreturn a block,enqueueWakeupthrowsconflict(...), the.catchat:19894logs a warn, and the agent never wakes at all — where previously it woke unscoped and could have worked any other project's issues. - Same shape, quieter, for the
return nullgates that only become reachable with an issueId (heartbeat.ts:29959worktree-execution cutoff): those resolve rather than reject, so.then(() => undefined)runs and the caller's.catchnever fires — no warn, no wake, only askippedrequest row. (The cutoff gate is worktree-override-only, so it is dev-instance rather than production, but the budget path is production-real.) - Recommendation: apply the PR's own stated principle — "a stale scope degrades to today's unscoped behaviour" — to suppression as well as staleness. If the scoped
enqueueWakeupthrows or returnsnull, retry once unscoped. Quota recovery waking the agent is the invariant worth preserving; the scope is the optimization.
- In
-
[native-codex]
server/src/services/heartbeat.ts:19668—eq(issues.id, issueId)is missing theisUuidLikeguard that this same file applies to the identical lookup inenqueueWakeup, with an explicit comment warning about exactly this hazard.enqueueWakeup:29948guards it: "Guard the UUID arm because issues.id is a Postgres uuid column — passing 'ENV-13' into eq(issues.id, …) would fail with an invalid-input-syntax cast error."issueIdFromRunContext(:22490) returnscontext.issueId ?? context.taskIdverbatim, and canonicalization to UUID inenqueueWakeupis conditional — it sits insideif (!projectId && issueId)(:29941), so a caller that supplies both aprojectIdand an identifier-formissueIdpersists the identifier intocontextSnapshotunchanged.- Result: Postgres
22P02, swallowed by the.catchat:19671, scope silently dropped, run reverts to the pre-fix unscoped behaviour. It degrades safely rather than crashing — which is precisely why it would never be noticed. - Recommendation: reuse the guarded lookup shape from
:29945(or addif (!isUuidLike(issueId)) return null;after:19663).
-
[pr-review-toolkit/error-handling]
server/src/services/heartbeat.ts:19660,:19671,:19882— The logging policy is inverted: both intended drops log (:19677terminal,:19684reassigned), while all three unexpected error paths are silent.catch(() => null).- The consequence is specific to this fix: its regression mode is a silent revert to the exact unscoped behaviour that produced interleaved writes into
erasure/tracker.goin BLO-28442. With no log distinguishing "unscoped because task-less" (the legitimate case, asserted by test 2) from "unscoped because the lookup threw", there is no way to confirm from production logs that BLO-28992 is actually fixed — and finding #2 above gives a concrete, plausible trigger. - Recommendation:
logger.warnon thegetRunand issue-select catch paths with{ agentId, runId, issueId }. The outer.catch(() => null)at:19882is then redundant defence and can stay silent.
- The consequence is specific to this fix: its regression mode is a silent revert to the exact unscoped behaviour that produced interleaved writes into
Suggestions (3)
- [gstack/review]
server/src/services/heartbeat.ts:1205— On Q2: status is the wrong predicate, but the answer is not "addblockedto the set". Ablockedrow with unresolved blocker edges will be skipped by the agent on arrival, so the scoped wake is consumed making no progress — strictly worse than an unscoped wake that would have picked an actionable row. Ablockedrow with zero edges is the BLO-21523 case you correctly want to keep resumable. Gating on unresolved blocker count rather than onstatus === "blocked"preserves both properties. Your reasoning for keepingblockedresumable is sound; only the predicate is coarse. - [pr-review-toolkit/comments]
server/src/services/quota-exhausted-hook.ts:136— On Q3: yes, add the comment, at theonSuccessdeclaration rather than at the call site. I verified the contract holds today — all three branches (:154in-flight,:184time-debounce,:260ran) invokeinput.onSuccess()and each awaits viaPromise.resolve(...). But the test pins the behaviour from the heartbeat side; someone refactoringquota-exhausted-hook.tstoward a single shared callback would be editing this file, would not see that test, and the degradation is silent (N runs → 1 scoped). Two lines here is cheap insurance for a load-bearing invariant. - [native-codex]
server/src/services/heartbeat.ts:19660—getRun(runId)is not scoped to the agent. Theissue.assigneeAgentId !== agentIdcheck at:19683is the real guard so this is not exploitable, but assertingparkedRun.agentId === agentIddocuments the assumption and fails closed if a future caller passes a foreignrunId.
Strengths
- The test-quality accounting in the PR description is accurate and unusually honest. Only test 1 is a true regression test, the other three assert fallback branches and pass pre-fix — that is exactly right, and stating it up front is worth more than three tests that look like coverage.
- Test 1 genuinely exercises the load-bearing property: the second
finalizeAgentStatusdeterministically lands on a debounced branch, so the per-calleronSuccesscontract is what the assertion actually depends on.__resetQuotaExhaustedHookStateForTestinginbeforeEachcorrectly neutralises the module-level singleton, and polling instead of a fixed sleep avoids a slow-host flake. - No migration and no new persisted field — reusing the existing generated
contextIssueIdcolumn is the right call. I confirmedgetRun's default projections both spreadgetTableColumns(heartbeatRuns)and only null outerror/resultJson/stdout/stderr, socontextSnapshotis genuinely available on this path. - The comment on
QUOTA_RECOVERY_UNRESUMABLE_ISSUE_STATUSESexplains whyblockedis excluded and cites the prior issue, which is what made Q2 reviewable at all.
Responses to the three questions raised
- TOCTOU boundary — your reasoning is correct. The issue execution lock is an atomic CAS on run ids and is unchanged here; re-validating scope at dispatch would duplicate what checkout already enforces and would still leave its own window. Resolving at wake time only avoids pointing a run at stale work, and that is the right job for this function. The hardening this fix actually needs is the suppression fallback (Important #1), not a second staleness check.
blocked— keep it resumable; refine the predicate. See Suggestion 1.- Per-caller
onSuccess— contract verified intact across all three branches. Add the comment; the test does not protect the file where the regression would be introduced. See Suggestion 2.
Recommended Action
- No Critical issues — nothing blocks on correctness of the core fix.
- Address the three Important issues this cycle. #1 (unscoped fallback on suppression) is the one with a real production behaviour change; #2 and #3 compound each other and together determine whether you can tell this fix is working at all.
- Consider the Suggestions opportunistically; the
blockedpredicate refinement can reasonably fold into BLO-27858 alongside the task-less fan-in case.
…14299d) Addresses all three Important findings from the review at exact head 714299d. Each was verified against the real code before acting on it. Important #1 — scoped wake newly exposed to project-scoped suppression. Attaching an issueId makes enqueueWakeup derive a projectId, and budgets.getInvocationBlock early-returns null on a falsy candidateProjectId (budgets.ts:1068). So a budget-paused project made the scoped enqueue throw conflict(...), the catch logged a warn, and the agent NEVER WOKE — strictly worse than pre-fix, where it woke unscoped and could work another project's issue. The scoped wake now retries unscoped: waking is the invariant, the scope is only the optimization. Retry is deliberately NOT blanket-on-falsy, which would have been a double-wake bug: the provider-capacity gate returns null *after* committing a scheduled_retry run (:30402), and that gate is especially likely here because we are recovering from a provider park. Retry is gated on the suppression out-param — providerCapacityDeferred excluded, and only the two skip reasons the scope itself can unlock (budget.blocked, heartbeat.worktree_execution_cutoff) qualify. Scope-independent gates (cooldown, company inactive, heartbeat disabled) would decline the retry identically, and issue_tree_hold_active is an explicit hold that dropping the scope must not circumvent. Important #2 — eq(issues.id, issueId) was missing the isUuidLike guard that enqueueWakeup applies to the identical lookup, with a comment warning about exactly this hazard. issueIdFromRunContext returns context.issueId ?? context.taskId verbatim and canonicalization is conditional, so an identifier form reaches the resolver, raises Postgres 22P02, gets swallowed, and silently reverts to the unscoped fan-in. Now mirrors the guarded lookup shape, scopes by companyId (identifiers collide across tenants), and returns the canonical UUID. Important #3 — logging policy was inverted: intended drops logged while all three unexpected error paths were silent .catch(() => null). Since this fix's regression mode is a silent revert to the exact unscoped behaviour, there was no way to confirm from production that it works. Now warns on the getRun catch, the issue-select catch, and the resolver catch. Suggestion 2 — documented the load-bearing per-caller onSuccess contract at its declaration in quota-exhausted-hook.ts, where a refactor would see it. Suggestion 3 — assert parkedRun.agentId === agentId. Suggestion 1 (gate on unresolved blocker count rather than status === "blocked") deferred to BLO-27858 as the review allows. Tests: 2 new cases, both verified to fail pre-fix rather than pass vacuously — the budget case fails `expected [] to have a length of 1` (the agent never woke at all) and the identifier case fails `expected null to be <uuid>` (scope silently dropped). vitest run heartbeat-quota-recovery-wake-scope # 6 passed vitest run quota-exhausted-hook ccrotate-retry # 28 passed pnpm --filter @paperclipai/server typecheck # clean Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head e2a401c — this addresses all three Important findings from your review at 714299d. Focus areas, in the order I would look at them:
Suggestion 1 (gate on unresolved blocker count rather than Note on CI at the previous head: |
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: e2a401c
Prior Findings Dispositioned (3)
- prior:714299d important 1 — fixed —
server/src/services/heartbeat.ts:20021— The scoped wake records suppression outcomes and retries unscoped only forbudget.blockedandheartbeat.worktree_execution_cutoff; provider-capacity deferral is explicitly excluded because it has already committed a scheduled retry, andissue_tree_hold_activeremains an intentional hold. - prior:714299d important 2 — fixed —
server/src/services/heartbeat.ts:19711— The resolver appliesisUuidLikebefore constructing theissues.idpredicate, supports identifier lookup by company, and returnsissue.id, so identifier-form snapshots are safely canonicalized to UUIDs. - prior:714299d important 3 — fixed —
server/src/services/heartbeat.ts:19678— Parked-run and issue lookup failures now emit structured warnings with agent/run/scope context, while legitimate terminal or reassignment drops remain explicit informational outcomes.
Looks good. The implementation preserves the prior unscoped behavior when a scope is unsafe or a scope-only suppression would otherwise discard the wake, while keeping provider-capacity postponement from double-waking.
Critical Issues (0)
None.
Important Issues (0)
None.
Suggestions (0)
None.
Strengths
- The resolver validates ownership and current issue assignment at wake time, then canonicalizes identifier-form snapshots without introducing a migration.
- The suppression out-parameter distinguishes durable skips from provider-capacity scheduled retries, preventing an unsafe blanket retry-on-falsy policy.
- The new integration tests cover per-run task scope, task-less fallback, terminal/reassigned tasks, project budget suppression, and identifier canonicalization; CI is green.
Recommended Action
- No Critical issues remain.
- No Important issues remain.
- Suggestions are optional; the deferred unresolved-blocker predicate refinement can proceed separately.
Thinking Path
Linked Issues or Issue Description
erasure/tracker.gointerleaved-write instanceprovider_quota_exhausteda retry family; upstream of this wake, does not scope it), #1462 (open — a launch-stalled run absorbing its agent's wakes; different failure). No duplicate PR exists.The defect
provider_quota_exhausted_recoveredis delivered unscoped. Inbox size does not help; this was observed with both 84-row and 169-row inboxes, and 4/4 prior unscoped wakes on one agent landed on the same row.What Changed
server/src/services/heartbeat.ts—finalizeAgentStatus:runId: null→runId: hookRunId. The run driving the transition was already inoptions.runId, but the hook call site hardcodednull. That is why the recovery wake had nothing to scope itself with. (Worth flagging: the issue thread proposed this route on the premise that the hook "already carriesrunId" — it is in the type, but this call site passed null, so the route needed this one-line correction to work at all.)onSuccessnow resolves and carries the scope, passingcontextSnapshot: { issueId }toenqueueWakeup.contextSnapshot.issueIdvia the existing generatedcontextIssueIdcolumn — no new persisted field and no migration.QUOTA_RECOVERY_UNRESUMABLE_ISSUE_STATUSES— drops the scope (falling back to today's unscoped wake) when the issue is unreadable, terminal, or reassigned.server/src/__tests__/heartbeat-quota-recovery-wake-scope.test.ts(4 cases).Because the hook invokes
onSuccessper caller even on its debounced/in-flight branches, each parked run executes its own closure and therefore re-delivers its own scope. Wake coalescing keys oncontextIssueId, so distinct issues do not collapse into one wake, while two runs parked on the same issue correctly do.Why scope is resolved at wake time, not park time
The hook can take 60s+ to recover. In that window the issue may be completed, cancelled, or reassigned. Carrying a park-time scope blindly would wake an agent onto work it no longer owns — a new second-writer defect rather than a fix.
blockedis deliberately still resumable: the parked run may be exactly what moves it, and ablockedrow with no blocker edges is already a dispatch stop we should not deepen (BLO-21523).Verification
I verified the primary test actually fails without the fix (rather than passing vacuously) by stubbing the resolver to return null, reproducing pre-fix behaviour:
[null, null]is precisely the fan-in signature: both runs unscoped, both therefore aimed at the same singleton inbox row.Honest scope note: only that first case is a regression test of the fix. The other three assert the fallback branches (task-less, terminal, reassigned) and pass against pre-fix code too — they are guards against this change over-reaching, not evidence of the fix. Calling them regression coverage would overstate what they do.
The regression case named in the issue — two task-less runs not converging on one issue — is not covered here and cannot be: a task-less park has nothing to resume, so it still takes the inbox path by design. That half remains covered only by BLO-27858, as the issue's verifying-signal section allows.
Field confirmation (absence of a repeat BLO-28442 signature) can only be measured post-deploy.
Acceptance criteria
Risks
contextIssueId.issueIdwidens the suppression surface. With a scope set,enqueueWakeupderives aprojectId, which makes project-scoped budget suppression reachable on a path that could never hit it before. Addressed in this cycle per reviewer Important test(plugin-linear): requestId fixtures + getLinkByLinear mock-leak fix; scripts: ensure-build-deps freshness check #1 — the scoped enqueue retries unscoped rather than dropping the wake.provider_quota_exhausted's retry family (#1458) is unchanged.Model Used
Claude Opus 5 (
claude-opus-5[1m]), 1M context window, extended thinking enabled, with tool use / code execution via the Claude Code agent harness.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatepolicyfailed on a pip downloadTimeoutErrorinsideactions/setup-python(CI infra, not this diff), cascade-skipping 6 lanes and failingverify; re-running🤖 Generated with Claude Code