fix(heartbeat): honor a provider 429's advertised capacity-reset horizon (BLO-18278) - #904
Conversation
1 similar comment
|
@ally please review this heartbeat retry-policy change (BLO-18278). Focus areas, in order:
Not in scope: the pre-existing dead |
|
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 |
…zon (BLO-18278) A provider capacity 429 states exactly when to come back. On the k8s adapters it states it in prose, inside the error message: API Error: Request rejected (429) · BYOS provider capacity for 'anthropic' is temporarily unavailable; capacity may reset at 2026-07-26T21:29:59.782Z; retry in 9571s Nothing read that. claude-local/codex-local parse it adapter-side and hand back a structured `retryNotBefore`, but the shipped claude_k8s / opencode_k8s bundles contain no occurrence of `retryNotBefore`, `capacity may reset`, `resume_at` or `retry_after` at all, so on those adapters the horizon reached finalization as text and was dropped. `retryNotBefore` persisted null, which made the hint-honoring branch in scheduleBoundedRetryForRun — which already overrides `dueAt` with an advertised reset, uncapped — unreachable. The run took the rate-limit family's flat 90s hop instead: ~18x short of the 9571s asked for, so every attempt landed inside the same closed window until the Job hit BackoffLimitExceeded and the issue was stranded. Live proof: run 9727eaf0-9cea-461d-9101-f833f8de29fe. The capacity did reset on its own at 21:29:59Z; nothing was broken by 21:53Z except the issue's status. The fix is server-side, at the one point every adapter's output funnels through, so it covers the k8s bundles we do not build: - parseProviderCapacityResetHorizon() recovers the instant from prose, from the absolute `capacity may reset at <ISO>` form or the relative `retry in <N>s` form, preferring the absolute one (it does not drift with emission skew). Elapsed and >24h horizons are rejected so a bad parse cannot sideline an issue for days. - Finalization consults it only for the throttle families, and only when the adapter did not already supply a structured hint, then persists it as `retryNotBefore` — which the existing scheduler path then honors, parking a `scheduled_retry` row AT the advertised reset. `scheduled_retry` is a live execution path to hasActiveExecutionPath, so the strand sweep skips it. - The in-run ccrotate loop now stops early when the advertised reset is beyond what its remaining budget could outlast, instead of spending attempts (each one relaunching the agent Job) inside a window that is still closed. - The strand comment, when a strand is genuinely unavoidable, names the 429 and the reset instant rather than `job_failed` — BackoffLimitExceeded, which describes the symptom, reads as an infrastructure fault, and has repeatedly invited misdiagnosis. The e2e test fails on master with `expected undefined to be '<reset>'` because the test adapters emit the prose and nothing else, exactly as claude_k8s does. Co-Authored-By: Claude <noreply@anthropic.com>
d7e65d0 to
a26eb6f
Compare
|
@ally re-anchoring at head My four review questions from the previous comment stand unchanged — the first one is the one I would most like a second opinion on: |
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: a26eb6f
Important Issues (1)
- [gstack/review]
server/src/services/recovery/service.ts:353—readProviderCapacityResetAttrustsresultJson.providerCapacityResetAtbefore checking the error family, then falls back to anyrate_limit_exhausted/provider_quotaretryNotBefore;summarizeRunFailureForIssueCommentinterpolates that value verbatim into an issue comment and labels every such case a self-healing capacity 429. This crosses the run-output trust boundary without timestamp validation/redaction and also misdiagnoses existing 401 cap-window or generic quota hints as 429 capacity events.- Require the expected throttle family before accepting the explicit field, parse and canonicalize the value as a bounded ISO timestamp rather than returning arbitrary text, and avoid the broad
retryNotBeforefallback unless there is separate 429/capacity provenance. Keep non-capacity rate-limit/quota runs on the generic redacted summary.
- Require the expected throttle family before accepting the explicit field, parse and canonicalize the value as a bounded ISO timestamp rather than returning arbitrary text, and avoid the broad
Suggestions (1)
- [tests]
server/src/services/recovery/strand-comment-provider-capacity.test.ts:662— add negative cases whereproviderCapacityResetAtis present on a non-throttle family and where the field is malformed/markdown-bearing, so the comment trust boundary stays pinned.
Strengths
- The end-to-end test exercises real heartbeat finalization with a prose-only adapter result and proves the scheduled retry lands on the advertised horizon.
- The parser prefers absolute timestamps, bounds accepted horizons, and preserves existing structured adapter hints.
- The current head's build, typecheck, e2e, security, and serialized server suites are green.
Recommended Action
- Address the Important issue before merge.
- Consider the negative trust-boundary tests in the same fix.
|
Disposition on @ally's review (state: COMMENTED, head Finding accepted in full. I verified
Fix + the suggested negative tests: #930. Not a fixup here, since this PR is merged and closed. 8 of the 12 cases in the suite fail against pre-fix master — including a smuggled- One process note for whoever merges next: this PR carried an unresolved Important finding at merge time and nothing blocked it. Worth considering whether an outstanding Important should gate merge, or at minimum surface on the merge view. |
… (BLO-18278) Follow-up to #904, which merged with Ally's Important review finding outstanding. The flagged code is byte-identical between the reviewed head a26eb6f and master, so all three defects are live. `run.resultJson` is not a server-authored record. Heartbeat finalization builds it as `{ ...parseObject(adapterResult.resultJson), ... }` — the adapter's object is spread FIRST, and the server's canonical `providerCapacityResetAt` is layered on top only when it actually parsed a horizon (throttle override + no structured retryNotBefore + prose match). On every other run an adapter-supplied `providerCapacityResetAt` survives verbatim. `readProviderCapacityResetAt` then returned it as free text and `summarizeRunFailureForIssueComment` interpolated it straight into an issue comment — with neither the `redactSensitiveText` pass nor the 240-char cap that every other branch of that summarizer applies. That reopened exactly the hole PR paperclipai#4600 closed: adapter blobs (API keys, internal hostnames, injected markdown) reaching the issue thread. Issue comments are read by other agents, so free text there is also an injection surface. Three changes: 1. Gate on the throttle family FIRST. The explicit field was read before any family check, so an ordinary crash carrying that key was relabelled a self-healing capacity 429 and told the reader to wait for a window that does not exist. 2. Accept only a bare, bounded ISO-8601 instant, re-emitted canonically via toISOString(). Full-string match, so no prose, markdown, or newline payload survives; bounded to ±24h of the run's own createdAt, which rejects epoch-0/year-9999 garbage without rejecting a real horizon that is already in the past by the time the strand comment is built. Anything else returns null and the run falls through to the generic redacted summary. A spoofed errorFamily therefore buys a plausible timestamp and nothing else. 3. Only the server-parsed explicit field may be called a 429. heartbeat.ts states that `rate_limit_exhausted` is set "when the run hits 429, 401-cap, or 'you've hit your limit' cap text", and `provider_quota` is a legacy adapter quota signal — neither implies a capacity 429. A bare advertised `retryNotBefore` now gets honest "rate-limit/quota window" phrasing. This keeps the BLO-18278 diagnostic win (the comment still names the window instead of BackoffLimitExceeded) without asserting a status code we cannot substantiate. One existing assertion changed: the retryNotBefore case asserted `toContain("429")`, which encoded the misdiagnosis in (3). Tests: 8 of the 12 cases in the suite fail against pre-fix master, including the secret-leak case — the hole is demonstrably live, not theoretical. Adds the negative trust-boundary cases Ally asked for: explicit field on a non-throttle family, on no family at all, markdown/prose-bearing value, smuggled secret, embedded-but-not-bare timestamp, out-of-bounds instant, and offset canonicalization. Receipts: targeted suite 12/12; recovery/ + heartbeat-provider-capacity-horizon 61/61; server typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
… (BLO-18278) Follow-up to #904, which merged with Ally's Important review finding outstanding. The flagged code is byte-identical between the reviewed head a26eb6f and master, so all three defects are live. `run.resultJson` is not a server-authored record. Heartbeat finalization builds it as `{ ...parseObject(adapterResult.resultJson), ... }` — the adapter's object is spread FIRST, and the server's canonical `providerCapacityResetAt` is layered on top only when it actually parsed a horizon (throttle override + no structured retryNotBefore + prose match). On every other run an adapter-supplied `providerCapacityResetAt` survives verbatim. `readProviderCapacityResetAt` then returned it as free text and `summarizeRunFailureForIssueComment` interpolated it straight into an issue comment — with neither the `redactSensitiveText` pass nor the 240-char cap that every other branch of that summarizer applies. That reopened exactly the hole PR paperclipai#4600 closed: adapter blobs (API keys, internal hostnames, injected markdown) reaching the issue thread. Issue comments are read by other agents, so free text there is also an injection surface. Three changes: 1. Gate on the throttle family FIRST. The explicit field was read before any family check, so an ordinary crash carrying that key was relabelled a self-healing capacity 429 and told the reader to wait for a window that does not exist. 2. Accept only a bare, bounded ISO-8601 instant, re-emitted canonically via toISOString(). Full-string match, so no prose, markdown, or newline payload survives; bounded to ±24h of the run's own createdAt, which rejects epoch-0/year-9999 garbage without rejecting a real horizon that is already in the past by the time the strand comment is built. Anything else returns null and the run falls through to the generic redacted summary. A spoofed errorFamily therefore buys a plausible timestamp and nothing else. 3. Only the server-parsed explicit field may be called a 429. heartbeat.ts states that `rate_limit_exhausted` is set "when the run hits 429, 401-cap, or 'you've hit your limit' cap text", and `provider_quota` is a legacy adapter quota signal — neither implies a capacity 429. A bare advertised `retryNotBefore` now gets honest "rate-limit/quota window" phrasing. This keeps the BLO-18278 diagnostic win (the comment still names the window instead of BackoffLimitExceeded) without asserting a status code we cannot substantiate. One existing assertion changed: the retryNotBefore case asserted `toContain("429")`, which encoded the misdiagnosis in (3). Tests: 8 of the 12 cases in the suite fail against pre-fix master, including the secret-leak case — the hole is demonstrably live, not theoretical. Adds the negative trust-boundary cases Ally asked for: explicit field on a non-throttle family, on no family at all, markdown/prose-bearing value, smuggled secret, embedded-but-not-bare timestamp, out-of-bounds instant, and offset canonicalization. Receipts: targeted suite 12/12; recovery/ + heartbeat-provider-capacity-horizon 61/61; server typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
… (BLO-18278) Follow-up to #904, which merged with Ally's Important review finding outstanding. The flagged code is byte-identical between the reviewed head a26eb6f and master, so all three defects are live. `run.resultJson` is not a server-authored record. Heartbeat finalization builds it as `{ ...parseObject(adapterResult.resultJson), ... }` — the adapter's object is spread FIRST, and the server's canonical `providerCapacityResetAt` is layered on top only when it actually parsed a horizon (throttle override + no structured retryNotBefore + prose match). On every other run an adapter-supplied `providerCapacityResetAt` survives verbatim. `readProviderCapacityResetAt` then returned it as free text and `summarizeRunFailureForIssueComment` interpolated it straight into an issue comment — with neither the `redactSensitiveText` pass nor the 240-char cap that every other branch of that summarizer applies. That reopened exactly the hole PR paperclipai#4600 closed: adapter blobs (API keys, internal hostnames, injected markdown) reaching the issue thread. Issue comments are read by other agents, so free text there is also an injection surface. Three changes: 1. Gate on the throttle family FIRST. The explicit field was read before any family check, so an ordinary crash carrying that key was relabelled a self-healing capacity 429 and told the reader to wait for a window that does not exist. 2. Accept only a bare, bounded ISO-8601 instant, re-emitted canonically via toISOString(). Full-string match, so no prose, markdown, or newline payload survives; bounded to ±24h of the run's own createdAt, which rejects epoch-0/year-9999 garbage without rejecting a real horizon that is already in the past by the time the strand comment is built. Anything else returns null and the run falls through to the generic redacted summary. A spoofed errorFamily therefore buys a plausible timestamp and nothing else. 3. Only the server-parsed explicit field may be called a 429. heartbeat.ts states that `rate_limit_exhausted` is set "when the run hits 429, 401-cap, or 'you've hit your limit' cap text", and `provider_quota` is a legacy adapter quota signal — neither implies a capacity 429. A bare advertised `retryNotBefore` now gets honest "rate-limit/quota window" phrasing. This keeps the BLO-18278 diagnostic win (the comment still names the window instead of BackoffLimitExceeded) without asserting a status code we cannot substantiate. One existing assertion changed: the retryNotBefore case asserted `toContain("429")`, which encoded the misdiagnosis in (3). Tests: 8 of the 12 cases in the suite fail against pre-fix master, including the secret-leak case — the hole is demonstrably live, not theoretical. Adds the negative trust-boundary cases Ally asked for: explicit field on a non-throttle family, on no family at all, markdown/prose-bearing value, smuggled secret, embedded-but-not-bare timestamp, out-of-bounds instant, and offset canonicalization. Receipts: targeted suite 12/12; recovery/ + heartbeat-provider-capacity-horizon 61/61; server typecheck clean. Co-Authored-By: Claude <noreply@anthropic.com>
Thinking Path
Linked Issues or Issue Description
Tracked in the Paperclip tracker, not GitHub Issues: BLO-18278. Sibling: BLO-18285 (the hint-less 503/529 case) shipped in #859. Describing the underlying defect inline per CONTRIBUTING.md → "Link Issues or Describe Them In-PR" (bug-report shape):
What happened. Run
9727eaf0-9cea-461d-9101-f833f8de29fe(CTO, workspace-bound, pod started cleanly):Expected. The continuation is scheduled at/after the advertised reset, or the issue parks in an explicit waiting posture that wakes it then.
Actual. The provider asked for 9571s (~2h40m); the retry cadence was a flat 90s — ~18x too short. Every attempt landed inside the same closed window, the Job hit
BackoffLimitExceeded, the run endedjob_failed, and the issue was escalated throughstranded_assigned_issuerecovery to a human/CEO intervention path. Capacity reset on its own at 21:29:59Z; nothing was broken by 21:53Z except the issue's status.Root cause. The horizon only ever existed as prose.
claude-local/codex-localparse it adapter-side (packages/adapters/claude-local/src/server/parse.ts:24) into a structuredretryNotBefore— but the shipped k8s bundle at/opt/paperclip-bundled-adapters/node_modules/paperclip-adapter-claude-k8s/dist/index.jscontains zero occurrences ofretryNotBefore,capacity may reset,resume_at, orretry_after. It structurally cannot emit the hint. So on k8s the text reached finalization and was dropped atretryNotBefore: adapterResult.retryNotBefore ?? null, which made the already-correct, uncapped hint-honoring branch inscheduleBoundedRetryForRununreachable:The run therefore fell through to the rate-limit family's flat 90s hop. Note the in-run ccrotate loop could not have honored it either way —
k8sCcrotateRetryDelayMsclamps toK8S_CCROTATE_IN_RUN_RETRY_MAX_DELAY_MS(10 min), 16x under the horizon — which is why the fix routes toscheduled_retry, whose curve does cover 2h40m.Related PRs searched and reviewed for overlap: #859 (merged, hint-less 503 sibling), #889 (merged, penstock 400 allocation), #900, #875, #820/#818 (open, recovery/stranding but different mechanism). None cover the hint-present 429 horizon.
What Changed
parseProviderCapacityResetHorizon()(server/src/services/heartbeat.ts) — recovers the reset instant from prose: absolutecapacity may reset at <ISO>or relativeretry in <N>s, preferring the absolute form (it does not drift with emission-to-finalization skew). Rejects already-elapsed horizons and anything beyond 24h.rateLimitExhaustedOverride || providerThrottledNoProgressOverride) and only when the adapter supplied no structuredretryNotBefore, then persists the result asretryNotBefore. The existing scheduler path then parks ascheduled_retryrow at the advertised reset.scheduled_retrycounts as a live execution path tohasActiveExecutionPath, so the strand sweep skips the issue.providerCapacityResetAtprovenance persisted insideresultJsonso a wrong parse is debuggable from the run row.server/src/services/recovery/service.ts) — when a strand is genuinely unavoidable, names the 429 and the reset instant instead of, which describes the symptom, reads as an infrastructure fault, and has repeatedly invited misdiagnosis.job_failed— BackoffLimitExceededsummarizeRunFailureForIssueCommentis now exported for direct unit test, matching the existingprovider-failure-classification.test.tsconvention.Verification
New test fails on master, as the issue's Verifying signal requires. The test adapters emit the 429 prose and nothing else, exactly as
claude_k8sdoes, and drive real heartbeat finalization through a registered adapter rather than re-implementing the override chain — so they cannot drift from production. Reverting only the source (keeping the test):With the fix: 11/11.
pnpm --filter @paperclipai/server exec vitest run \ src/__tests__/heartbeat-provider-capacity-horizon.test.ts \ src/services/recovery/strand-comment-provider-capacity.test.tsheartbeat-provider-capacity-horizon(new)strand-comment-provider-capacity(new, AC3)heartbeat-process-recovery(asserts strand comment text)heartbeat-hintless-transient-upstream(#859 sibling)heartbeat-rate-limit-exhausted+-retry-schedule+ccrotate-capacity-retry+recovery/provider-failure-classificationThe single failure is
heartbeat-retry-scheduling > queues the gate-status delivery on exhaustion, which fails identically on unmodified master in my sandbox (githubCommitStatusDeliveriesundefined — a stale@paperclipai/dbdist that CI rebuilds). Verified by stashing the change and re-running.Blast-radius query (issue AC4), run against prod for the 2026-07-26 18:50Z–21:30Z window: 29
job_failedruns, allBackoffLimitExceeded, across 28 distinct issues. Today: 20 done, 3 cancelled (23/28 terminal on their own); of the 5 non-terminal, four hold a live execution path and BLO-8010 is deliberately inbacklogbehind unlanded upstream slices. Zero remain stranded, so no re-arm was required.Risks
resultJson.result/message/error/summary, which can contain agent-authored text; a 429 log pasted into an unrelated failure's output could in principle push that failure's retry out. Mitigated three ways: it is gated to the throttle families, it yields to any adapter-supplied structured hint, and horizons that are elapsed or >24h are rejected. This is the main thing I asked the reviewer to scrutinize.scheduled_retrykeeps it visible as an explicit waiting posture rather than a silent stall.breaks.Model Used
Claude Opus 5 (
claude-opus-5[1m]), 1M context, via Claude Code / Claude Agent SDK on theclaude_k8sadapter. Extended thinking enabled; tool use and code execution (ran the suites locally against embedded Postgres, queried prod Postgres read-only for the blast-radius numbers, and grepped the shipped adapter bundle to confirm the root cause).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateDrive-by observation, not changed here:
providerThrottleNoProgress: trueis passed as a sibling key tomergeAdapterRecoveryMetadata({...}), which only forwardsresultJson/errorFamily/retryNotBefore— so it is silently discarded, and nothing reads it anywhere in the tree. Pre-existing no-op. I put the newproviderCapacityResetAtinsideresultJson(where it actually persists) rather than repeat the mistake, and left the dead key alone since no behavior depends on it.🤖 Generated with Claude Code