feat(subscriptions): a recovered subscription stops claiming it is rate-limited, and the row says when the limit returns (abilityai/trinity-enterprise#447) - #2362
Conversation
…te-limited, and the row says when the limit returns (abilityai/trinity-enterprise#447) Three defects stacked on one surface. Only the third is what an operator sees, so they ship together — fixing any one alone leaves the tile still lying. 1. `rate_limited_now` was an OR, so ground truth could not win. The derivation was `db_2h_predicate OR fresh_provider_verdict`. The db half is "a failure row exists in the last 2 hours", and nothing clears a failure row on success — `clear_rate_limit_events` has had zero production callers since #444 removed the one call (clearing was destroying auto-switch's detection signal). So it only decays with the clock, and being OR'd it outranked a fresh probe reporting `allowed · 32% used · resets 19:10`. Observed live: two subscriptions wearing LIMIT while every agent assigned to them answered test messages normally. `resolve_rate_limited_now` is now three-state — fresh says limited -> True, fresh says allowed -> False, no usable verdict -> the db predicate — and is the one gate both `decorate_usage` and `pressure_states` consume, so the tile and the per-agent chip cannot disagree (#2157 rule). `_headroom_indicates_healthy` is deliberately NOT the negation of `_headroom_indicates_limited`: "not limited" is also true for a stale snapshot, a rejected token and a transport error, none of which are evidence of headroom, so all three fall through to the predicate rather than clearing it (#2353's rule, preserved). Auto-switch candidate filtering is untouched — it reads the kind-blind `has_recent_subscription_failures`, so a just-recovered subscription is still skipped as a switch target and #444's ping-pong cannot return through this door. 2. Nothing asked the provider again. The fresh-verdict arm is only reachable if a probe runs, and ambient refresh is demand-driven: an unwatched instance never re-checked at all. The new `subscription_recovery_service` sweeps every 300s and probes ONLY the subscriptions currently presented as limited — normally an empty set, so the cycle costs nothing. It reuses the existing `max_tokens=1` Haiku probe (~a dozen tokens each) and the Settings-surfaced `subscription_headroom_auto_refresh` toggle rather than adding a second knob, since that setting already answers "may Trinity probe on its own?". It cannot feed itself: `_probe` records a 429 into the snapshot only, never `subscription_rate_limit_events`, so re-probing can never manufacture the db rows that keep a subscription limited (pinned by a static test). Leadership is fail-OPEN (a duplicated probe wastes a dozen tokens; failing closed would silently stop recovery detection) while the probe itself stays fail-CLOSED on Redis — without a readable cache the result could not be stored for anyone to see, so it would be quota spent for nothing. 3. The reset time was on the wire and rendered nowhere. `resets_at` has shipped since #471 and appeared only in a hover. On the row that needs it the old path structurally could not reach it: a 429 probe sets `status: 'rate_limited'`, so `decorate_usage` never promotes `source` to `anthropic`, so `headroomIsFresh` is false, so `windowReadings` returns null — while `headroom.five_hour.resets_at` sits populated in the same object. `resetReading` reads headroom directly and is therefore not behind the freshness gate, the same asymmetry `headroomStatus` documents (#2353) for the same reason: a number decays, an instant does not. Binding window is chosen by `representative_claim` first — live-verified populated, and both windows can report the SAME utilization, so a "fullest wins" tiebreak has no answer there and would pick by array order — then any non-`allowed` window, then the fullest. A lapsed instant reads `reset due`, never a stale future time; a limited row with no reset says `reset unknown`; a rejected token gets none at all, because a dead credential has no quota clock. Placement is decided in the pure module, so the SFC template is unchanged and every rule stays reachable by the node-environment suite: the reset takes whichever text slot is free — joining the headline when there are no bars, leading the second line when there are, since the fixed-width bars cannot take more without overflowing a row that clips silently. Also, operator-reported alongside: the two window groups now form columns down the tile (fixed-width label and right-aligned tabular percentage). Previously `0%` / `6%` / `24%` had different advances and pushed the `7d` group left by a different amount per row — the bars were always 30px but landed at three x positions and read as three lengths. `min-width` not `width`, so an overage plan reporting >100% overflows rather than being clipped. Tests: 21 backend (resolver across every status x predicate combination incl. the not-a-negation property; recovery probe skip/fail-closed/interval/error paths; static pin that `_probe` writes no failure event) and 16 frontend. Existing suites green: 234 backend subscription-related, 1103 frontend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011yce2aJH18eR1NxJfV6q8m
The new `subscription_recovery_service` is the fifth leader lease and the #1920 guard flagged its `set(..., nx=True, ex=ttl)` as an unlisted hand-rolled single-flight lock — the guard working as designed, forcing the author to either adopt `SingleFlightLock` or justify the divergence in one place. It is not adoptable. `SingleFlightLock` mints a UNIQUE token per acquire, so a lease could never recognise — and therefore never refresh — its own grant across cycles. A leader lease needs the opposite: one stable per-worker id it re-`EXPIRE`s for itself, which is exactly what this service keeps and what the four existing leases (monitoring #1464, opqueue #1632, skills:sync ent#236, canary #1881) are allowlisted for. So this is the prescribed second option: one justified row, stating why the divergence is structural rather than incidental. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011yce2aJH18eR1NxJfV6q8m
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr.
Found and fixed one blocker: the #1920 static guard was newly failing on HEAD — this service is the fifth leader lease and its set(..., nx=True, ex=ttl) was unlisted. Not adoptable onto SingleFlightLock (it mints a unique token per acquire, so a lease could never refresh its own grant across cycles), so applied the guard's prescribed second option: one justified allowlist row (9d58c4e).
Regression diff: ✅ no new failures — HEAD reports 0 failures across all three seeds. The pytest (base, seed 99999) red is a runner timeout on dev, not this PR.
One follow-up, not blocking: SUBSCRIPTION_RECOVERY_PROBE_SECONDS and SUBSCRIPTION_RECOVERY_DISABLED_POLL_SECONDS are wired into neither compose file nor .env.example, so the advertised env-tunability is inert on deploy (#1056 class). Left as-is because the sibling SUBSCRIPTION_HEADROOM_REFRESH_SECONDS from #471 is unwired the same way — worth one follow-up covering both rather than diverging here.
…2365 One conflict: src/backend/main.py router mounts — both sides appended at the same point (rooms routers from main's ent#443, portal_asks_router from dev's ent#428). Both kept; both mount before register_enterprise(app). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Three defects stacked on one surface. Only the third is what an operator sees, so they ship together — fixing any one alone leaves the tile still lying.
rate_limited_nowwasdb_2h_predicate OR fresh_provider_verdict, and nothing clears a failure row on success, so a fresh "allowed · 32% used · resets 19:10" from the provider was structurally powerless against a 2-hour-old inference. Observed live: two subscriptions wearingLIMITwhile every agent assigned to them answered test messages normally.Changes
A —
resolve_rate_limited_nowis three-state, not an OR (services/subscription_headroom_service.py)One gate, consumed by both
decorate_usageandpressure_states, so the tile and the per-agent chip cannot disagree about one subscription (#2157 rule)._headroom_indicates_healthyis deliberately not the negation of_headroom_indicates_limited: "not limited" is also true for a stale snapshot, a rejected token and a transport error — none of which are evidence of headroom — so all three fall through to the predicate rather than clearing it (#2353's rule, preserved).Auto-switch is untouched. It reads the kind-blind
has_recent_subscription_failures(#2352's split), so a just-recovered subscription is still skipped as a switch target until its failures age out — #444's ping-pong cannot return through this door.B — recovery probe (
services/subscription_recovery_service.py, new)Sweeps every 300s and probes only subscriptions currently presented as limited — normally an empty set, so the cycle costs nothing. Reuses the existing
max_tokens=1Haiku probe (~a dozen tokens each) and the Settings-surfacedsubscription_headroom_auto_refreshtoggle rather than adding a second knob, since that setting already answers may Trinity probe on its own?._proberecords a 429 into the snapshot only, neversubscription_rate_limit_events— otherwise the loop would manufacture the very db rows that keep a subscription limited (pinned by a static test)C — the reset on the row face (
utils/subscriptionPressureTile.js)resetReadingreadsheadroom.*.resets_atdirectly, bypassingwindowReadingsand therefore its freshness gate — the same asymmetryheadroomStatusdocuments (#2353), for the same reason one level along: a number decays, an instant does not. That gate is exactly what hid it: a 429 probe setsstatus: 'rate_limited', sodecorate_usagenever promotessourcetoanthropic, sowindowReadingsreturnsnull— whileresets_atsits populated in the same object.Binding window:
representative_claim→ any non-allowedwindow → the fullest. The claim comes first because it was live-verified populated, and both windows can report the same utilization (observed: 32% / 32%), so a "fullest wins" tiebreak has no answer there and would pick by array order.Honest states, none blank:
resets 19:10·reset due(lapsed — only another probe confirms the roll) ·reset unknown(limited, nothing in the payload) · nothing at all for a rejected token, because a dead credential has no quota clock.Placement is decided in the pure module, so the SFC template is unchanged and every rule stays reachable by the node-environment suite: the reset takes whichever text slot is free — joining the headline when there are no bars, leading the second line when there are, since the fixed-width bars cannot take more without overflowing a row that clips silently.
Row alignment (operator-reported alongside,
SubscriptionPressureTile.vue, CSS only)The two window groups now form columns down the tile. Previously
0%/6%/24%had different advances and pushed the7dgroup left by a different amount per row — the bars were always 30px but landed at three x positions and read as three lengths.min-widthnotwidth, so an overage plan reporting >100% overflows rather than being clipped (thebarWidthPctclamp-only-geometry rule).Test Plan
pytest tests/unit/test_447_subscription_reset_and_recovery.py -v— 21 passednpx vitest run tests/unit/subscriptionPressureTile.spec.js— 81 passed (65 pre-existing + 16 new)test_471_*+test_2352_*— 45 passedpytest tests/unit -k "subscription or headroom or pressure"— 234 passed, 4 skippedresets_at+representative_claimare populated on the wireScope note
The issue was filed as C only and grew during investigation, operator-directed (2026-08-21) after test messages to agents on two
LIMITsubscriptions came back fine. The issue description has been rewritten to match what shipped. The alignment fix is a genuinely separate concern bundled deliberately: it is CSS in the same block this PR already touches, and splitting it would have guaranteed a conflict.Residual, stated honestly
_probe()'s contract asserts it and the code path preserves the parsed windows on a 429, but no rate-limited subscription was available to observe. If false, the feature degrades toreset unknownon exactly that row — an already-specified state, not a wrong answer.utilization_5h_pctbut no reset field; surfacing one there is a backend change, left out to keep C zero-backend.Fixes abilityai/trinity-enterprise#447
Generated with Claude Code