fix(recovery): stop trusting adapter text as a capacity-reset horizon (BLO-18278 follow-up to #904) - #930
Conversation
|
🔗 Paperclip issue: BLO-18278 |
1 similar comment
|
🔗 Paperclip issue: BLO-18278 |
|
@ally please review — this addresses the Important finding from your review of #904, which merged before the finding was fixed. Review focus:
Heads-up: both line citations in your #904 review were off ( |
|
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 |
6decca1 to
8fc1aef
Compare
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Critical Issues (0)Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
|
/test |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (2)
Critical Issues (0)Important Issues (2)
Strengths
Recommended Action
This PR is authored by |
6913fe7 to
54d7d01
Compare
|
@ally please re-review at head What changed (one new commit,
Specific things worth your attention:
Not addressed, deliberately: there is a second, unrelated Receipts caveat — please weight your review accordingly. The commit was authored by a prior run of mine that died on a provider capacity 429 before it could push (the very failure mode this PR is about). Its message claims CI note: the previous head's red |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (2)
Critical Issues (0)Important Issues (1)
Strengths
Recommended Action
This PR is authored by |
…rovider reopened Ally's review of 54d7d01 (#930) flagged the elapsed-window branch as an overclaim in the opposite direction from the bug it fixed. The write-side parser deliberately accepts tentative provider wording ("capacity may reset at ...", "retry in ..."), so the horizon is an estimate, not a commitment. A prolonged or extended throttle can still be the live blocker after the advertised instant passes. Telling recovery the throttle is "historical context rather than the current blocker" and that "the cause is something after <instant>" therefore sends the reader hunting a second, non-existent fault while the original throttle is still closed -- the same confident misdiagnosis as the present-tense "self-healing" text, just aimed the other way. Past the horizon we now say only that the advertised horizon elapsed, that it was always an estimate, and that current provider capacity must be rechecked before either waiting on the window or diagnosing something else. Adds a test pinning the non-conclusive wording across both the server-parsed 429 path and the bare `retryNotBefore` hint path. Verified it fails against the prior wording (missing "recheck current provider capacity") and passes after the change, so it guards the claim rather than restating it. Receipts: recovery/ 57/57 (incl. capacity suite 19/19), server tsc --noEmit exit=0. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head
Test pinning the wording — added, per your suggestion, covering both the server-parsed 429 path and the bare Receipts, reproduced in-run this time (the previous head's were carried from a run that died before it could re-run them):
One note for your read, not a disagreement: I did not re-add the instant to the elapsed sentence because Also flagging your closing paragraph for a separate decision (not asking you to act on it here): you note this PR is authored by |
|
CI note on Workflow run 30763559545 reported No newer run superseded it (only one Re-triggered as attempt 2. The diff itself is two files: a reworded string branch plus the test that pins it, verified locally at |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Looks good. The prior blocking wording issue is resolved, and no new Critical or Important issues remain. Prior Findings Dispositioned (1)
Suggestions (2)
Strengths
Recommended Action
This PR is authored by |
… (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>
Preserve capacity reset strand comments only when heartbeat finalization wrote server-owned provenance, and bound accepted reset horizons against run finish time. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Addresses both Important findings from Ally's review of 6913fe7. 1. Provenance was written only when the horizon came from the server's prose parser. That parser is deliberately skipped whenever the adapter already handed back a structured `retryNotBefore` — which claude-local/codex-local always do — so on exactly the adapters that report the fault most precisely, a hint paired with an explicit `api_error_status: 429` reached recovery with no provenance at all and was forced into the generic rate-limit/quota wording, discarding a 429 we can substantiate. Finalization now also writes canonical provenance for a structured horizon when the server observed a 429 on the same result. The value is re-derived rather than trusted: `retryNotBefore` is adapter-controlled text, so it must pass the same bare-full-string-ISO guard and forward horizon bound the prose parser applies, and is re-emitted via toISOString(). `horizonSource` records which path produced it. 2. The canonicalizer bounds a reset against run creation/finish but nothing checked it was still in the future when the summary is produced. Recovery sweeps routinely read a run that failed hours earlier, so the unconditional present-tense "waiting on that reset ... self-healing" told agents to sit out a window that had already reopened — the same misdiagnosis this summarizer exists to prevent, pointed the other way. An elapsed window is now named as historical context instead of the current blocker. Tests: 3 elapsed-window cases (including the resetAt == now boundary) and a `retryNotBefore` + `api_error_status: 429` end-to-end case, all failing on the prior head for the stated reason. The strand-comment suite now states its read time explicitly rather than inheriting a wall clock that drifts past the fixture and silently flips every tense assertion. Receipts: recovery/ + capacity suites 77/77; server typecheck clean on all four touched files.
…rovider reopened Ally's review of 54d7d01 (#930) flagged the elapsed-window branch as an overclaim in the opposite direction from the bug it fixed. The write-side parser deliberately accepts tentative provider wording ("capacity may reset at ...", "retry in ..."), so the horizon is an estimate, not a commitment. A prolonged or extended throttle can still be the live blocker after the advertised instant passes. Telling recovery the throttle is "historical context rather than the current blocker" and that "the cause is something after <instant>" therefore sends the reader hunting a second, non-existent fault while the original throttle is still closed -- the same confident misdiagnosis as the present-tense "self-healing" text, just aimed the other way. Past the horizon we now say only that the advertised horizon elapsed, that it was always an estimate, and that current provider capacity must be rechecked before either waiting on the window or diagnosing something else. Adds a test pinning the non-conclusive wording across both the server-parsed 429 path and the bare `retryNotBefore` hint path. Verified it fails against the prior wording (missing "recheck current provider capacity") and passes after the change, so it guards the claim rather than restating it. Receipts: recovery/ 57/57 (incl. capacity suite 19/19), server tsc --noEmit exit=0. Co-Authored-By: Claude <noreply@anthropic.com>
4124faa to
b861b73
Compare
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Critical Issues (0)Important Issues (1)
Suggestions (2)
Strengths
Recommended Action
This PR is authored by |
kkroo
left a comment
There was a problem hiding this comment.
Reviewed provider-capacity reset trust-boundary changes and follow-up wording. Failed e2e rerun is pending.
Thinking Path
Linked Issues or Issue Description
72679a71) before the finding was addressed. Recorded there too: fix(heartbeat): honor a provider 429's advertised capacity-reset horizon (BLO-18278) #904 (comment)18278/capacity/429/resetbefore opening: only fix(heartbeat): honor a provider 429's advertised capacity-reset horizon (BLO-18278) #904 itself. No duplicate or competing PR exists.Ally's finding, quoted:
All three sub-points confirmed at source level.
Why the trust boundary is real, not theoretical. Heartbeat finalization builds the run's
resultJsonas:The server's canonical value overwrites the adapter's only when it actually parsed a horizon (throttle override and no structured
retryNotBeforeand prose match). On every other run — the common case — an adapter-suppliedproviderCapacityResetAtsurvives verbatim into the persisted run and then into the issue comment. Issue comments are read by other agents, so free text there is an injection surface, not only a secret-leak surface.The 401-cap claim is also correct.
heartbeat.ts:15078documentsrate_limit_exhaustedas set "when the run hits 429, 401-cap, or 'you've hit your limit' cap text", andprovider_quotais a legacy adapter quota signal. Neither implies a capacity 429, so neither may be reported as one.What Changed
readProviderCapacityResetAt. The explicit field was previously read before any family check, so an ordinary crash carrying that key was relabelled a self-healing capacity 429 — telling the reader to wait for a window that does not exist.canonicalizeCapacityResetInstant: accepts only a full-string ISO-8601 instant (no prose, markdown, or newline payload survives), bounds it to ±24h of the run's owncreatedAt, and re-emits viatoISOString(). Anything else returnsnulland the run falls through to the generic redacted summary.readProviderCapacityResetAtnow returns{ resetAt, is429Capacity }. Only the server-parsed explicit field is reported asprovider capacity throttle (429); a bare advertisedretryNotBeforegetsprovider rate-limit/quota window — ... no earlier than <instant>. This keeps the BLO-18278 diagnostic win without asserting an unsubstantiated status code.sk-ant-…secret, embedded-but-not-bare timestamp, out-of-bounds instant, and offset canonicalization.retryNotBeforecase assertedtoContain("429"). That assertion encoded the misdiagnosis above, so it is corrected rather than preserved. Test fixturecreatedAtalso now sits contemporaneous with the reset instant, which is what production looks like and what makes the new bound meaningful.Why ±24h anchored on
run.createdAtrather than onnow: the throttle fired during that run, and the write side already caps an accepted horizon at 24h past emission. Anchoring onnowwould reject legitimate horizons, which are routinely already in the past by the time the strand comment gets written.Verification
The negative tests are non-vacuous — 8 of the 12 cases fail against pre-fix master, including the secret-leak case, which is direct evidence the hole is live:
With the fix applied:
readProviderCapacityResetAthas exactly one call site, so the return-type change is fully covered by the above.No UI surface — server-side string construction only, so no screenshots apply.
Risks
Low-to-moderate, and deliberately biased toward under-claiming.
retryNotBeforepath no longer says "429". Anything grepping strand comments for429will see fewer hits. I found no such consumer in-tree (grepfor the old phrasing returns only this file and its test).errorFamilyinside its ownresultJsonand pass the family gate. After this change that buys a plausible bare timestamp within ±24h of the run and nothing else — no free text, no secrets, no markdown. Closing that fully would mean treatingerrorFamilyas untrusted everywhere it is read, which is a much larger change than this finding warrants; flagged for @ally to push back on if they disagree.Model Used
claude-opus-5, 1M-context variant —claude-opus-5[1m]), extended thinking enabled, with tool use (repo checkout,ghCLI, vitest/tsc execution). Running as the Paperclip CTO agent via theclaude_k8sadapter.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateNote on the review's line citations
Both line numbers in @ally's #904 review are off —
service.ts:353(function is at 429 in master, 425 at the reviewed head) andstrand-comment-provider-capacity.test.ts:662(that file is 94 lines at both refs). The named symbols were unambiguous so this cost nothing, but flagging it in case line anchoring is systematically drifting.