Skip to content

feat(subscriptions): a recovered subscription stops claiming it is rate-limited, and the row says when the limit returns (abilityai/trinity-enterprise#447) - #2362

Merged
vybe merged 2 commits into
devfrom
feature/447-subscription-reset-time
Aug 21, 2026
Merged

feat(subscriptions): a recovered subscription stops claiming it is rate-limited, and the row says when the limit returns (abilityai/trinity-enterprise#447)#2362
vybe merged 2 commits into
devfrom
feature/447-subscription-reset-time

Conversation

@trinity-ability

Copy link
Copy Markdown
Contributor

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.

  • A recovered subscription kept claiming it was rate-limited. rate_limited_now was db_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 wearing LIMIT while every agent assigned to them answered test messages normally.
  • 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 reset time was on the wire and rendered nowhere but a hover — and on the row that needs it, the old code path structurally could not reach it.

Changes

A — resolve_rate_limited_now is three-state, not an OR (services/subscription_headroom_service.py)

fresh verdict says limited  -> True    (unchanged)
fresh verdict says allowed  -> False   (the missing arm)
no usable verdict           -> the 2h db predicate (unchanged)

One gate, consumed by both decorate_usage and pressure_states, so the tile and the per-agent chip cannot disagree about one subscription (#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 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=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?.

Property Why
Cannot feed itself _probe records a 429 into the snapshot only, never subscription_rate_limit_events — otherwise the loop would manufacture the very db rows that keep a subscription limited (pinned by a static test)
Leadership fail-open A duplicated probe wastes a dozen tokens; failing closed would silently stop recovery detection — the mode the operator cannot see
Probe 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 (the ambient path's rule)

C — the reset on the row face (utils/subscriptionPressureTile.js)

resetReading reads headroom.*.resets_at directly, bypassing windowReadings and therefore its freshness gate — the same asymmetry headroomStatus documents (#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 sets status: 'rate_limited', so decorate_usage never promotes source to anthropic, so windowReadings returns null — while resets_at sits populated in the same object.

Binding window: representative_claim → any non-allowed window → 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 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 (the barWidthPct clamp-only-geometry rule).

Test Plan

  • New backend tests pass: pytest tests/unit/test_447_subscription_reset_and_recovery.py -v21 passed
  • New frontend tests pass: npx vitest run tests/unit/subscriptionPressureTile.spec.js81 passed (65 pre-existing + 16 new)
  • Sibling suites unaffected: test_471_* + test_2352_*45 passed
  • Broader backend sweep: pytest tests/unit -k "subscription or headroom or pressure"234 passed, 4 skipped
  • Full frontend unit suite: 1103 passed (52 files)
  • Live-probed a real instance to confirm resets_at + representative_claim are populated on the wire

Scope note

The issue was filed as C only and grew during investigation, operator-directed (2026-08-21) after test messages to agents on two LIMIT subscriptions 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

  • Code-verified, not live-verified: that a 429 response carries the unified rate-limit headers (so a limited subscription has a reset to show). _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 to reset unknown on exactly that row — an already-specified state, not a wrong answer.
  • Out of scope: the per-agent chip's batch row carries utilization_5h_pct but 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

claude added 2 commits August 21, 2026 10:54
…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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vybe
vybe merged commit 75cf605 into dev Aug 21, 2026
24 of 25 checks passed
vybe pushed a commit that referenced this pull request Aug 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants