fix(workflow): stop a provider spend limit classifying as transient_infra - #688
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refines fabro-workflow failure classification so permanent provider billing ceilings (specifically ACP “spend limit” failures) are no longer misclassified as transient_infra due to Cargo registry source-path provenance appearing inside structured spawned_at payloads. This improves operator-facing categorization and prevents loop_restart edges (which require transient_infra) from being taken for spend-limit failures.
Changes:
- Add a permanent provider spend-limit hint (
spend limit) that maps toFailureCategory::BudgetExhaustedbefore transient-infra hints are evaluated. - Sanitize structured
spawned_atvalues that match Cargoregistry/src/<registry>-<hash>/...source provenance so they don’t trigger transient-infra heuristics (while preserving unrelated fields and malformed JSON verbatim). - Add comprehensive unit tests covering spend-limit classification, structured provenance discounting, URL detection exceptions, malformed JSON boundaries, and non-weakening guards.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// These are permanent and human-actionable: only a person raising the limit | ||
| /// clears them, so retrying is always wasted work. This list is checked before | ||
| /// `TRANSIENT_INFRA_HINTS` so an explicit permanent ceiling wins if the same | ||
| /// provider payload also contains a transient phrase such as `rate limited`. |
| return captures | ||
| .get(0) | ||
| .expect("capture zero always contains the full regex match") | ||
| .as_str() | ||
| .to_string(); |
|
Hi @thewoolleyman -- Thanks for this. Just to zoom out for a moment... If I understand the PR properly it is saying that a spending limit was categorized as How would you expect a spending error to be categorized? |
…nfra An Anthropic org monthly spend-limit failure was labeled transient_infra because classification substring-matches the rendered message and full cause chain. The ACP internal cause includes a spawned_at Cargo extraction path whose index.crates.io component is itself a transient hint. In the representative payload, that provenance path is the only transient hint match. A second observed provider message, "You've hit your limit · resets ...", exposes the other side of the fallback: after provenance is removed, existing budget hints miss it and the resource limit becomes signature-tracked Deterministic. Before matching hints, decode JSON string keys and values and blank a structured spawned_at value when it contains a local registry/src/<registry>-<hash> extraction path. Do this before lowercasing or global hex masking so case-sensitive JSON escapes are validated as written, and escaped hashes, hex-like registry identifiers, and URI schemes retain their meaning. Escaped local paths are handled without sanitizing escaped-solidus, Unicode-escaped, protocol-relative, file:, or other multi-character URI forms. Other fields and invalid hashes remain untouched. Malformed JSON string pairs, including case-changing invalid escapes, are preserved so fault text in them cannot be consumed. Also classify spend limit as BudgetExhausted before transient hints. Add the measured "you've hit your limit" phrase to the ordinary budget hints after transient matching, so a real adjacent network fault still wins. Do not use errorKind=rate_limit as the discriminator: the permanent spend cap and ordinary throttling can carry the same value. Sanitizing provenance alone would otherwise leave these provider resource limits on the Deterministic fallback, which is signature-tracked. Per-stage Handler, Engine, and Io retry behavior is unchanged. The corrected category does prevent transient-only loop_restart routing; signature tracking remains disabled for both TransientInfra and BudgetExhausted. Regression coverage includes the representative direct and handler-with-source paths, the resetting-window direct and cause-chain forms, budget/transient precedence, local path variants, escaped keys and values, URL and invalid-hash boundaries, genuine adjacent network faults, and hint-count guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
49b043c to
3b37818
Compare
…class, close the epic fabro-sh/fabro#688 is OPEN, not draft, MERGEABLE, +527/-4 at head 3b3781888, CI run 30565354608 SUCCESS, three approvals. Every "unpushed / nothing pushed / no PR or issue / publication is the maintainer's call" claim across the handoff, the research note and the charter is marked SUPERSEDED with the date and the measurement — struck through rather than deleted, because they were stated as verified facts and repeated to the maintainer. Swept for the claim class rather than the named instances, which is the ladder's own rule and this was a live test of it: twelve sites across three files. The 165 insertions / 0 deletions non-weakening evidence is marked STALE, not re-asserted. It was measured against a commit that no longer exists while the PR head is +527/-4 and deletes lines. Nobody has re-measured it. Epic overseer-dtytju is closed with its evidence and, more importantly, with what the closure does not cover: upstream merge has not happened, the second-payload gap is unresolved and belongs to the PR reviewers, the retry residual is overseer-fs4, and the non-weakening claim needs re-measuring. Adds supervisor corrections C7 and C8. C7 is the purest instance the sweep produced — a fork branch's absence could never have observed an upstream PR, so the guard was not stale but incapable of detecting what it guarded. Also lands rung eleven: nothing holds a plan thread's two records in agreement, filed as overseer-bak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
An ACP provider spend-limit error is currently classified as
transient_infra. The classifier examines the rendered message and full causechain, and the embedded
spawned_atCargo registry source path containsindex.crates.io, one of the transient-infrastructure hints. Provenance texttherefore wins over the actual permanent, human-actionable billing failure.
A second observed provider message exposes the companion failure after that
provenance is removed:
You've hit your limit · resets Jul 31, 5am (UTC)matches none of the existing budget hints and would fall through to
Deterministic. A resetting usage quota is a resource limit, not a code fault,and
Deterministicwould incorrectly feed it to failure-signature tracking.This PR covers these two observed phrasings without claiming to recognize every
provider-limit message.
This does not change per-stage retry policy:
Handler,Engine, andIoerrors remain retryable regardless of
FailureCategory. It does correct theoperator-facing category and failure-class edge conditions, and it prevents
these failures from taking
loop_restartedges, which requiretransient_infra. Signature tracking also remains disabled: neither theobserved pre-fix
TransientInfranor the correctedBudgetExhaustedcategoryis tracked.
Observed payloads
The monthly spend-cap payload is permanent within its billing period and asks
for human action:
In this payload, the registry source path is the sole transient hint match:
errorKindusesrate_limitwith an underscore, while the classifier looksfor
rate limit.The second provider message reports a resetting usage window:
Provenance sanitization prevents a Cargo source path from deciding its class,
but sanitization alone would leave this phrase on the
Deterministicfallback.Fix
Before matching classifier hints, blank a
spawned_atvalue when it contains alocal Cargo
registry/src/<registry>-<hash>/…source path.spawned_atisprovenance: it identifies where the provider-side process raised the error,
not what failed. Restricting the sanitizer to that structured field avoids
interpreting arbitrary prose or consuming fault text outside that field value.
The matcher handles absolute, relative, non-default-registry, Windows, and
mixed-separator paths. It accepts both 7–64-character hexadecimal registry
hashes and, as a compatibility guard for callers that may have already applied
the classifier's hex normalization, the resulting
<hex>placeholder. Itdecodes the JSON key and value before matching, so escaped key spellings,
Unicode-escaped local paths, and partially escaped hashes behave like their
plain equivalents. The current classifier runs the sanitizer before case
normalization and global hex masking. JSON escapes are case-sensitive, so
validating the original spelling prevents malformed
\U,\N, and similarescapes from becoming valid after lowercasing; running before hex masking
ensures hex-like registry identifiers and URI schemes retain their meaning.
URL detection uses that same decoded value. Raw, escaped-solidus,
Unicode-escaped, protocol-relative,
file:, and other multi-character URIschemes remain untouched; one-character drive prefixes remain local Windows
paths. Invalid registry hashes, registry paths in unrelated fields, and
ordinary registry fetch/download errors also remain untouched.
Malformed JSON key/value strings are outside the structured boundary and are
preserved verbatim, including case-changing invalid escapes, so fault text in
them remains available to the classifier.
A separate permanent-provider-limit hint maps
spend limittoBudgetExhaustedbefore transient hints are checked. The measuredyou've hit your limitphrase joins the ordinary budget hints after transientmatching. That ordering is deliberate: the monthly cap overrides
transient-looking provider prose, while a genuine adjacent network fault still
wins over the resetting quota phrase.
Fabro's public failure documentation defines
budget_exhaustedas a resourcelimit and lists quota exhaustion as an example, so the resetting usage window
uses an existing category rather than inventing a new one. The embedded
errorKind: rate_limitis intentionally not used as the discriminator:ordinary throttling and the permanent monthly spend cap can carry the same
value, while Fabro's structured SDK
RateLimitcategory is normallyTransientInfra.Together, provenance sanitization and the two evidence-backed message hints
prevent these observed provider limits from landing on the signature-tracked
Deterministicfallback. This remains heuristic fallback classification forrendered errors, not a claim that all provider-limit prose is recognized.
Verification
BudgetExhaustedthrough both thedirect classifier and the
Error::handler_with_source→FailureDetailpath.form classify as
BudgetExhausted.connection resetstill wins over the ordinaryresetting-window budget hint and remains
TransientInfra.Deterministic, including absolute, relative, hex-masked, non-defaultregistry, Windows, and mixed-separator paths.
TransientInfra.spawned_atforms, andregistry paths in unrelated fields remain
TransientInfra, includingescaped URLs and hex-like multi-character URI schemes.
spawned_atkeys and values with invalid lowercase orcase-changing uppercase escapes, or a literal newline, are preserved,
including their real transient fault text.
ordinary budget-hint guard increases from 10 to 11, and the new one-entry
permanent list has its own guard.
guard is removed, weakened, or ignored.
cargo test -p fabro-workflow --lib error::— 175 passedcargo test -p fabro-workflow --lib— 1,146 passedcargo nextest run -p fabro-workflow— the unchangedhandler::parallel::tests::for_each_accepts_an_array_at_the_item_limitexceeds the repository's 6-second nextest timeout; it passes under
cargo test, including in the full library run abovecargo +nightly-2026-04-14 fmt --check --all— passedcargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings— passed