gh-glance 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/index.mjs +545 -183
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-09-06
11
+
12
+ ### Changed
13
+
14
+ - **Upgrading requires restarting older panes, and may wait for a quota reset.**
15
+ The coordination protocol moved from version 3 to 5. A pane still running an
16
+ older build holds a lease this one refuses to coordinate against, so it must
17
+ be closed; if that pane left API charges it never settled, admission waits for
18
+ the affected quota window to reset before spending against a counter it cannot
19
+ account for. Both are conservative on purpose, and both clear on their own.
20
+ - **A slow or failed budget observer no longer holds up the other resource.**
21
+ Core and GraphQL are claimed, published and retried independently. What still
22
+ couples them is the shared transport permit and any account-wide hold, which
23
+ are enforced where they belong.
24
+
25
+ ### Added
26
+
27
+ - **Secondary rate limits back off on a bounded ladder.** A `Retry-After` the
28
+ server supplies is honoured exactly, in seconds or as an HTTP date, and
29
+ concurrent holds merge by maximum so a shorter error cannot shorten a longer
30
+ hold. Without a supplied deadline the client waits 60, 120, 240, 480 then 900
31
+ seconds by consecutive throttle; that cap applies only to the delay gh-glance
32
+ chooses, never to one the server asked for. After five consecutive throttles
33
+ it stops choosing and waits for an explicit refresh.
34
+ - **A run of manual refreshes yields a turn to a waiting tab.** Manual work
35
+ still outranks background polling, but after three consecutive grants an
36
+ active tab that has been waiting goes next, so holding `r` can no longer
37
+ starve the rest of the dashboard.
38
+
39
+ ### Fixed
40
+
41
+ - **A permission error no longer pauses every pane.** A 403 carrying no
42
+ rate-limit headers and no secondary-limit marker is a permission failure, not
43
+ a throttle. It previously held the shared transport, so one repository the
44
+ token could not read stalled every pane using that account.
45
+ - **The external-use estimator can come back down.** When other clients were
46
+ busy, gh-glance would raise its estimate of their share and then keep pacing
47
+ against it: each sampling window closed on publication even when the sample
48
+ was too small to reconcile, discarding the local accumulation that would
49
+ eventually have made one. A window now closes only when it actually
50
+ reconciles, and a new quota window starts from no assumed external use rather
51
+ than inheriting the previous window's ratio.
52
+ - **A request that cost nothing no longer paces as though it had.** Work is
53
+ paced by the cost it reserves, which is a worst case. A conditional request
54
+ answered `304` spends nothing, and cancelled work spends nothing at all -- the
55
+ unused pacing is now returned instead of leaving the next request waiting out
56
+ a slot nobody used.
57
+ - **Older coordination files are readable again.** The version 3 to 4 change
58
+ altered the file's shape, and the legacy reader recognised only the version
59
+ number, so every file an older pane had actually written was rejected as
60
+ corrupt -- exactly the file the restart boundary has to read to see that an
61
+ older pane is still running.
62
+
10
63
  ## [0.12.0] - 2026-09-06
11
64
 
12
65
  ### Added
@@ -851,7 +904,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
851
904
  - The `main` field from `package.json`. It advertised the file as importable,
852
905
  but importing it took over the terminal or exited the host process.
853
906
 
854
- [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.12.0...HEAD
907
+ [Unreleased]: https://github.com/juan294/gh-glance/compare/v0.13.0...HEAD
908
+ [0.13.0]: https://github.com/juan294/gh-glance/compare/v0.12.0...v0.13.0
855
909
  [0.12.0]: https://github.com/juan294/gh-glance/compare/v0.11.2...v0.12.0
856
910
  [0.11.2]: https://github.com/juan294/gh-glance/compare/v0.11.1...v0.11.2
857
911
  [0.11.1]: https://github.com/juan294/gh-glance/compare/v0.11.0...v0.11.1
package/index.mjs CHANGED
@@ -2098,6 +2098,7 @@ function scheduleIntents({
2098
2098
  cursors = {},
2099
2099
  nowMs,
2100
2100
  maxGrants = Number.POSITIVE_INFINITY,
2101
+ manualStreak = 0,
2101
2102
  }) {
2102
2103
  const valid = [];
2103
2104
  const prunedIntentIds = [];
@@ -2138,11 +2139,29 @@ function scheduleIntents({
2138
2139
  const liveLeaseCount = countLiveLeases(leases, nowMs);
2139
2140
  const priorities = [...new Set(valid.map((intent) => intent.normalizedPriority))].sort((a, b) => a - b);
2140
2141
 
2141
- for (const priority of priorities) {
2142
- const pending = valid.filter((intent) => intent.normalizedPriority === priority);
2142
+ let streak = Number.isSafeInteger(manualStreak) && manualStreak > 0 ? manualStreak : 0;
2143
+ const grantedIds = new Set();
2144
+ // Priority still decides every other question. This says only that "higher
2145
+ // priority" cannot mean "always": once manual work has taken the last three
2146
+ // turns and an active owner is waiting, that owner takes this one. The turn
2147
+ // is a single grant, and it is subject to exactly the same budget, phase and
2148
+ // lane checks as any other -- fairness reorders work, it never admits work
2149
+ // that could not otherwise be paid for.
2150
+ const owedActiveTurn = streak >= MANUAL_GRANT_STREAK_LIMIT &&
2151
+ valid.some((intent) => intent.normalizedPriority === REQUEST_PRIORITIES.active);
2152
+ const passes = owedActiveTurn
2153
+ ? [{ priority: REQUEST_PRIORITIES.active, limit: 1, record: false },
2154
+ ...priorities.map((priority) => ({ priority, limit: Number.POSITIVE_INFINITY, record: true }))]
2155
+ : priorities.map((priority) => ({ priority, limit: Number.POSITIVE_INFINITY, record: true }));
2156
+
2157
+ for (const pass of passes) {
2158
+ const priority = pass.priority;
2159
+ const pending = valid.filter((intent) =>
2160
+ intent.normalizedPriority === priority && !grantedIds.has(intent.id));
2143
2161
  const roundRobinState = createRoundRobinState(pending, updatedCursors);
2162
+ let grantedThisPass = 0;
2144
2163
  while (pending.length > 0) {
2145
- if (grants.length >= maxGrants) break;
2164
+ if (grants.length >= maxGrants || grantedThisPass >= pass.limit) break;
2146
2165
  const intent = pending.splice(nextRoundRobinIndex(pending, roundRobinState), 1)[0];
2147
2166
  const resources = RATE_RESOURCES.filter((resource) => intent.costs[resource] > 0);
2148
2167
  const decisions = Object.fromEntries(resources.map((resource) => [
@@ -2158,7 +2177,10 @@ function scheduleIntents({
2158
2177
  ]));
2159
2178
  const blocked = resources.find((resource) => decisions[resource].mode !== "open");
2160
2179
  if (blocked) {
2161
- denied.push({ intentId: intent.id, resource: blocked, ...decisions[blocked] });
2180
+ // Only the ordinary pass records a denial. The owed-turn pass looks at
2181
+ // the same intents again immediately afterwards, and recording here
2182
+ // would report every one of them twice.
2183
+ if (pass.record) denied.push({ intentId: intent.id, resource: blocked, ...decisions[blocked] });
2162
2184
  continue;
2163
2185
  }
2164
2186
 
@@ -2173,6 +2195,7 @@ function scheduleIntents({
2173
2195
  );
2174
2196
  const expiring = resources.find((resource) => notBefore >= decisions[resource].resetMs);
2175
2197
  if (expiring) {
2198
+ if (!pass.record) continue;
2176
2199
  denied.push({
2177
2200
  intentId: intent.id,
2178
2201
  resource: expiring,
@@ -2207,6 +2230,12 @@ function scheduleIntents({
2207
2230
  reservation.sharingOwnerLeaseIds = sharingOwnerLeaseIds;
2208
2231
  }
2209
2232
  grants.push(reservation);
2233
+ grantedIds.add(intent.id);
2234
+ grantedThisPass += 1;
2235
+ // A manual grant extends the run; anything else ends it. The counter has
2236
+ // to persist, because the run this bounds is one manual intent per
2237
+ // planning pass rather than several within one.
2238
+ streak = priority === REQUEST_PRIORITIES.manual ? streak + 1 : 0;
2210
2239
  for (const resource of resources) {
2211
2240
  chargedTotals[resource] += intent.costs[resource];
2212
2241
  updatedLanes[resource] = {
@@ -2225,6 +2254,7 @@ function scheduleIntents({
2225
2254
  cursors: updatedCursors,
2226
2255
  denied,
2227
2256
  prunedIntentIds,
2257
+ manualStreak: streak,
2228
2258
  };
2229
2259
  }
2230
2260
 
@@ -2373,10 +2403,16 @@ function normalizeIdentityRegistry(raw) {
2373
2403
  if (!isRecord(transport)) return null;
2374
2404
  const keys = ["lastStartedAt", "cooldownUntil", "permit"];
2375
2405
  if (Object.hasOwn(transport, "waiters")) keys.push("waiters");
2406
+ if (Object.hasOwn(transport, "throttle")) keys.push("throttle");
2376
2407
  if (!normalizeHost(host) || !exactKeys(transport, keys) ||
2377
2408
  !time(transport.lastStartedAt) || !time(transport.cooldownUntil)) return null;
2378
2409
  if (transport.permit !== null && (!exactKeys(transport.permit, ["pid", "nonce", "startedAt"]) ||
2379
2410
  !Number.isSafeInteger(transport.permit.pid) || transport.permit.pid < 1 || !validGovernorId(transport.permit.nonce) || !time(transport.permit.startedAt))) return null;
2411
+ if (!Object.hasOwn(transport, "throttle")) transport.throttle = emptyTransportThrottle();
2412
+ if (!isRecord(transport.throttle) || !exactKeys(transport.throttle, ["attempts", "lastAt", "paused"]) ||
2413
+ !Number.isSafeInteger(transport.throttle.attempts) || transport.throttle.attempts < 0 ||
2414
+ transport.throttle.attempts > THROTTLE_PAUSE_AFTER ||
2415
+ !time(transport.throttle.lastAt) || typeof transport.throttle.paused !== "boolean") return null;
2380
2416
  if (!Object.hasOwn(transport, "waiters")) transport.waiters = [];
2381
2417
  if (!Array.isArray(transport.waiters) || transport.waiters.length > HTTP_MAX_WAITERS ||
2382
2418
  new Set(transport.waiters.map((waiter) => waiter?.nonce)).size !== transport.waiters.length ||
@@ -2474,7 +2510,7 @@ function inspectLegacyMigration(root, state, now) {
2474
2510
  // binary fail closed on its own gate, but this side must still recognise
2475
2511
  // that binary's file well enough to see it holds a live lease.
2476
2512
  const legacy = LEGACY_GOVERNOR_VERSIONS
2477
- .map((version) => normalizeGovernorState(raw, now, { prune: false, acceptVersion: version }))
2513
+ .map((version) => readLegacyGovernorState(raw, now, version))
2478
2514
  .find(Boolean) ?? migrateGovernorState(raw, now);
2479
2515
  if (legacy && raw.version === 1 && raw.budgets?.core) {
2480
2516
  const core = raw.budgets.core;
@@ -2840,7 +2876,13 @@ async function acquireIdentityHttpPermit(coordinator, { signal, now = Date.now }
2840
2876
  const migration = inspectLegacyMigration(coordinator.root, state, now());
2841
2877
  if (!migration.ok) return migration;
2842
2878
  if (now() >= deadline) return { ok: false, reason: "transport-busy", retryAt: deadline };
2843
- const transport = state.hosts[identity.host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null, waiters: [] };
2879
+ const transport = state.hosts[identity.host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null, waiters: [], throttle: emptyTransportThrottle() };
2880
+ // Five consecutive throttles means the ladder has stopped being useful.
2881
+ // Waiting out the deadline is what produced the last one, so the pause
2882
+ // holds until a person retries or a primary reset opens a recovery.
2883
+ if (transport.throttle?.paused) {
2884
+ return { ok: false, reason: "throttle-paused", retryAt: transport.cooldownUntil };
2885
+ }
2844
2886
  if (transport.permit && staleHttpPermit(transport.permit, now())) transport.permit = null;
2845
2887
  // Admission can happen during the mandatory start gap. Persist call
2846
2888
  // order once so later polling cannot overtake an earlier caller.
@@ -2886,17 +2928,92 @@ async function acquireIdentityHttpPermit(coordinator, { signal, now = Date.now }
2886
2928
  // stops applying to the other.
2887
2929
  const SECONDARY_LIMIT_PATTERN = /secondary rate|abuse/i;
2888
2930
 
2889
- function transportCooldownDeadline({ retryAfter, status, secondary, at }) {
2890
- const deadline = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
2931
+ // The locally chosen delay climbs by consecutive throttle and stops climbing at
2932
+ // fifteen minutes. The cap bounds only what this client invents for itself: a
2933
+ // server that asks for two hours is given two hours, because it knows something
2934
+ // we do not.
2935
+ const THROTTLE_LADDER_MS = [60_000, 120_000, 240_000, 480_000, 900_000];
2936
+ // After this many consecutive throttles the transport stops choosing new delays
2937
+ // and waits for a person or a primary reset. Climbing forever keeps a wedged
2938
+ // credential politely hammering a limit it has no way to satisfy.
2939
+ const THROTTLE_PAUSE_AFTER = 5;
2940
+
2941
+ function throttleLadderMs(priorThrottles) {
2942
+ const step = Number.isSafeInteger(priorThrottles) && priorThrottles > 0 ? priorThrottles : 0;
2943
+ return THROTTLE_LADDER_MS[Math.min(step, THROTTLE_LADDER_MS.length - 1)];
2944
+ }
2945
+
2946
+ // Reads throttle evidence without inventing any. The case this exists to get
2947
+ // right is the permission-only 403: it carries the same status as an exhausted
2948
+ // primary limit and is not a throttle at all, so treating it as one pauses a
2949
+ // pane that was never limited -- it was just not allowed.
2950
+ function classifyThrottle({ status = null, headers = null, graphqlErrors = null, stderr = "", body = null } = {}) {
2951
+ const header = (name) => {
2952
+ const value = headers?.[name];
2953
+ return typeof value === "string" ? value.trim() : null;
2954
+ };
2955
+ const integer = (name) => {
2956
+ const value = Number(header(name));
2957
+ return Number.isSafeInteger(value) ? value : null;
2958
+ };
2959
+ const secondary = SECONDARY_LIMIT_PATTERN.test([
2960
+ String(body?.message ?? ""),
2961
+ String(stderr ?? ""),
2962
+ ...(Array.isArray(graphqlErrors)
2963
+ ? graphqlErrors.map((error) => `${error?.type ?? ""} ${error?.message ?? ""}`)
2964
+ : []),
2965
+ ].join("\n"));
2966
+ const retryAfter = header("retry-after");
2967
+ const remaining = integer("x-ratelimit-remaining");
2968
+ const reset = integer("x-ratelimit-reset");
2969
+ const resource = header("x-ratelimit-resource")?.toLowerCase() ?? null;
2970
+ // The counter itself says there is nothing left. That holds its own resource
2971
+ // until its own reset, and says nothing about the other one.
2972
+ if (!secondary && remaining === 0 && reset !== null && RATE_RESOURCES.includes(resource) &&
2973
+ (status === 403 || status === 429)) {
2974
+ return { kind: "primary", resource, resetMs: reset * 1000 };
2975
+ }
2976
+ if (retryAfter !== null || secondary || status === 429) {
2977
+ return { kind: "secondary", retryAfter, secondary, status };
2978
+ }
2979
+ return { kind: "none" };
2980
+ }
2981
+
2982
+ function transportCooldownDeadline({ retryAfter, status, secondary, at, attempts = 0 }) {
2983
+ const supplied = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
2891
2984
  ? at + Number(retryAfter) * 1000
2892
2985
  : Date.parse(retryAfter ?? "");
2893
- if (Number.isFinite(deadline)) return deadline;
2894
- return status === 429 || secondary === true ? at + 60_000 : null;
2986
+ // A deadline the server supplied is honoured exactly, however long. Only the
2987
+ // delay chosen here is laddered, and only it is capped.
2988
+ if (Number.isFinite(supplied)) return supplied;
2989
+ return status === 429 || secondary === true ? at + throttleLadderMs(attempts) : null;
2990
+ }
2991
+
2992
+ function emptyTransportThrottle() {
2993
+ return { attempts: 0, lastAt: 0, paused: false };
2895
2994
  }
2896
2995
 
2897
2996
  function applyTransportCooldown(transport, evidence) {
2898
- const deadline = transportCooldownDeadline(evidence);
2899
- if (deadline !== null) transport.cooldownUntil = Math.max(transport.cooldownUntil, deadline);
2997
+ const throttle = transport.throttle ?? emptyTransportThrottle();
2998
+ const deadline = transportCooldownDeadline({ ...evidence, attempts: throttle.attempts });
2999
+ if (deadline === null) return null;
3000
+ // Merged by maximum, so a shorter concurrent error never shortens a hold
3001
+ // another response already established, and the hold survives a primary epoch
3002
+ // change: secondary limits are not accounted in the primary counter.
3003
+ transport.cooldownUntil = Math.max(transport.cooldownUntil, deadline);
3004
+ const attempts = Math.min(throttle.attempts + 1, THROTTLE_PAUSE_AFTER);
3005
+ transport.throttle = { attempts, lastAt: evidence.at, paused: attempts >= THROTTLE_PAUSE_AFTER };
3006
+ return transport.cooldownUntil;
3007
+ }
3008
+
3009
+ // A request that came back carrying no throttle evidence is the only thing that
3010
+ // proves the hold is over, so it is the only thing that resets the ladder.
3011
+ // Merely waiting out a deadline does not: that is what produced the next
3012
+ // throttle last time.
3013
+ function clearTransportThrottle(transport) {
3014
+ if (transport && transport.throttle && transport.throttle.attempts > 0) {
3015
+ transport.throttle = emptyTransportThrottle();
3016
+ }
2900
3017
  }
2901
3018
 
2902
3019
  function releaseIdentityHttpPermit(coordinator, permit, error = null) {
@@ -2905,22 +3022,48 @@ function releaseIdentityHttpPermit(coordinator, permit, error = null) {
2905
3022
  if (transport?.permit?.nonce === permit.nonce) transport.permit = null;
2906
3023
  if (transport && error) {
2907
3024
  const response = parseGhApiResponse(error.stdout ?? "");
2908
- applyTransportCooldown(transport, {
2909
- retryAfter: response.headers["retry-after"],
3025
+ const verdict = classifyThrottle({
2910
3026
  status: response.status,
2911
- secondary: SECONDARY_LIMIT_PATTERN.test(String(error.stderr ?? "")),
2912
- at: Date.now(),
3027
+ headers: response.headers,
3028
+ stderr: error.stderr,
2913
3029
  });
3030
+ // A permission-only 403 reaches here too. It is a failure, but it is not a
3031
+ // throttle, and holding the shared transport for it would pause every
3032
+ // pane over one repository the user cannot read.
3033
+ if (verdict.kind === "secondary") {
3034
+ applyTransportCooldown(transport, {
3035
+ retryAfter: verdict.retryAfter,
3036
+ status: verdict.status,
3037
+ secondary: verdict.secondary,
3038
+ at: Date.now(),
3039
+ });
3040
+ }
3041
+ } else if (transport && !error) {
3042
+ clearTransportThrottle(transport);
2914
3043
  }
2915
3044
  return { ok: true };
2916
3045
  });
2917
3046
  }
2918
3047
 
3048
+ // The explicit retry a paused transport waits for. Clears the ladder and the
3049
+ // deadline together: a person choosing to retry is choosing to spend the next
3050
+ // request finding out, which is exactly the recovery election the pause defers.
3051
+ function retryThrottledTransport(root, host, { now = Date.now } = {}) {
3052
+ return withIdentityRegistry(root, (state) => {
3053
+ const transport = state.hosts[host];
3054
+ if (!transport?.throttle?.paused) return { ok: true, value: { resumed: false } };
3055
+ transport.throttle = emptyTransportThrottle();
3056
+ transport.cooldownUntil = Math.min(transport.cooldownUntil, now());
3057
+ return { ok: true, value: { resumed: true } };
3058
+ }, { now: now() });
3059
+ }
3060
+
2919
3061
  function identityCoordinationMessage(reason) {
2920
3062
  if (reason === "restart-required") return "Restart required: close older gh-glance panes";
2921
3063
  if (["migration-hold", "legacy-unresolved"].includes(reason)) return "Upgrade waiting for legacy quota reset";
2922
3064
  if (["legacy-corrupt", "corrupt"].includes(reason)) return "Coordination state unavailable; evidence preserved";
2923
3065
  if (reason === "transport-busy") return "Shared HTTP request or cooldown in progress";
3066
+ if (reason === "throttle-paused") return "Paused after repeated rate limits; refresh to retry";
2924
3067
  if (retryableCoordination(reason)) return "Coordinating with your other panes";
2925
3068
  if (reason === "identity-capacity") return "Coordination state full; retrying after the current window";
2926
3069
  return "Verified identity unavailable; waiting to retry";
@@ -2937,9 +3080,27 @@ const GOVERNOR_SCOPE_VERSION = 1;
2937
3080
  // turns one rejected budget into total loss, discarding every live pane's
2938
3081
  // leases, intents and reservations. The version gate makes it fail closed on the
2939
3082
  // file instead, which is the whole point of having one.
2940
- const GOVERNOR_STATE_VERSION = 3;
2941
- // Readable-as-evidence, never written: 2 is the shape a 0.11.x pane still holds.
2942
- const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 2];
3083
+ // 4: observer claims, readiness and outcomes are per resource, so the schema
3084
+ // shape changed rather than a field's contents. Replaced atomically -- an older
3085
+ // build must fail closed on the version gate rather than half-read it.
3086
+ // 5: scheduling fairness is persisted, because the starvation it prevents
3087
+ // happens *between* planning passes -- each manual refresh is its own pass, so
3088
+ // a counter that lives only inside one cannot see a run of them.
3089
+ const GOVERNOR_STATE_VERSION = 5;
3090
+ // Readable-as-evidence, never written: the shapes a still-running older pane
3091
+ // holds. Recognising that such a pane owns a live lease is what the restart
3092
+ // boundary depends on.
3093
+ const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 4, 3, 2];
3094
+ // The first version whose file is already in the per-resource observer shape.
3095
+ // Anything below it is adapted before it can be read; see readLegacyGovernorState.
3096
+ const GOVERNOR_OBSERVER_SPLIT_VERSION = 4;
3097
+ // The first version carrying persisted scheduling fairness. A version 4 file is
3098
+ // otherwise in the current shape and only wants the field defaulted.
3099
+ const GOVERNOR_FAIRNESS_VERSION = 5;
3100
+ // Manual work outranks active work, which is right until it means "always". A
3101
+ // user holding the refresh key issues one manual intent per planning pass, and
3102
+ // without a bound the active owner behind them never gets a turn at all.
3103
+ const MANUAL_GRANT_STREAK_LIMIT = 3;
2943
3104
  const GOVERNOR_MAX_LEASES = 128;
2944
3105
  const GOVERNOR_MAX_INTENTS = 512;
2945
3106
  const GOVERNOR_MAX_RESERVATIONS = 512;
@@ -3052,11 +3213,16 @@ function emptyGovernorState() {
3052
3213
  version: GOVERNOR_STATE_VERSION,
3053
3214
  epochs: { core: null, graphql: null },
3054
3215
  budgets: {},
3055
- observers: {
3056
- core: { etag: null, outcome: "idle", at: 0, nextAt: 0 },
3057
- },
3058
- probeClaim: null,
3059
- probeOutcome: { status: "idle", at: 0, nextAt: 0 },
3216
+ fairness: { manualStreak: 0 },
3217
+ // Symmetric on purpose. Core observer state lived in `observers.core` while
3218
+ // the GraphQL one lived in a differently-shaped `probeOutcome`, and a single
3219
+ // `probeClaim` serialized both -- so a slow or failed observer for one
3220
+ // resource held up the other's readiness for no reason the resources
3221
+ // themselves impose.
3222
+ observers: Object.fromEntries(RATE_RESOURCES.map((resource) => [
3223
+ resource, { etag: null, outcome: "idle", at: 0, nextAt: 0 },
3224
+ ])),
3225
+ probeClaims: Object.fromEntries(RATE_RESOURCES.map((resource) => [resource, null])),
3060
3226
  leases: {},
3061
3227
  intents: {},
3062
3228
  reservations: {},
@@ -3180,7 +3346,7 @@ function normalizeGovernorReservation(raw, nowMs) {
3180
3346
  return { ...raw, costs, actualCosts, accountedCosts, epochs };
3181
3347
  }
3182
3348
 
3183
- function normalizeCoreObserver(raw, nowMs) {
3349
+ function normalizeResourceObserver(raw, nowMs) {
3184
3350
  if (!exactKeys(raw, ["etag", "outcome", "at", "nextAt"])) return null;
3185
3351
  if (
3186
3352
  raw.etag !== null && (typeof raw.etag !== "string" || raw.etag.length === 0) ||
@@ -3194,24 +3360,19 @@ function normalizeCoreObserver(raw, nowMs) {
3194
3360
  function normalizeProbeClaim(raw, nowMs) {
3195
3361
  if (raw === null) return null;
3196
3362
  if (!exactKeys(raw, [
3197
- "ownerLeaseId", "nonce", "leaseUntil", "nextAt", "claimAt", "startedReservationIds", "resources",
3363
+ "ownerLeaseId", "nonce", "leaseUntil", "nextAt", "claimAt", "startedReservationIds",
3198
3364
  ])) {
3199
3365
  return undefined;
3200
3366
  }
3201
- const resources = Array.isArray(raw.resources) && raw.resources.length > 0 &&
3202
- raw.resources.every((resource) => RATE_RESOURCES.includes(resource))
3203
- ? [...new Set(raw.resources)]
3204
- : null;
3205
3367
  if (
3206
3368
  !validGovernorId(raw.ownerLeaseId) || !validGovernorId(raw.nonce) ||
3207
3369
  finiteTimestamp(raw.leaseUntil, nowMs) === undefined ||
3208
3370
  finiteTimestamp(raw.nextAt, nowMs) === undefined ||
3209
3371
  finiteTimestamp(raw.claimAt, nowMs) === undefined || raw.claimAt > nowMs ||
3210
3372
  !Array.isArray(raw.startedReservationIds) || raw.startedReservationIds.length > GOVERNOR_MAX_RESERVATIONS ||
3211
- raw.startedReservationIds.some((id) => !id.startsWith("reservation:") || !validGovernorId(id.slice(12))) ||
3212
- !resources
3373
+ raw.startedReservationIds.some((id) => !id.startsWith("reservation:") || !validGovernorId(id.slice(12)))
3213
3374
  ) return undefined;
3214
- return { ...raw, startedReservationIds: [...new Set(raw.startedReservationIds)], resources };
3375
+ return { ...raw, startedReservationIds: [...new Set(raw.startedReservationIds)] };
3215
3376
  }
3216
3377
 
3217
3378
  function normalizeManualProbe(raw, nowMs) {
@@ -3232,27 +3393,28 @@ function normalizeManualProbe(raw, nowMs) {
3232
3393
  // version this build does not write is unreadable and fails closed.
3233
3394
  function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVERNOR_STATE_VERSION } = {}) {
3234
3395
  if (!exactKeys(raw, [
3235
- "version", "epochs", "budgets", "observers", "probeClaim", "probeOutcome", "leases",
3396
+ "version", "epochs", "budgets", "fairness", "observers", "probeClaims", "leases",
3236
3397
  "intents", "reservations", "manualProbe",
3237
3398
  ]) || raw.version !== acceptVersion) return null;
3399
+ if (!exactKeys(raw.fairness, ["manualStreak"]) ||
3400
+ !Number.isSafeInteger(raw.fairness.manualStreak) || raw.fairness.manualStreak < 0) return null;
3238
3401
  if (
3239
3402
  !exactKeys(raw.epochs, RATE_RESOURCES) ||
3240
3403
  RATE_RESOURCES.some((resource) => raw.epochs[resource] !== null && !validGovernorEpoch(raw.epochs[resource])) ||
3241
3404
  !isRecord(raw.budgets) || Object.keys(raw.budgets).some((resource) => !RATE_RESOURCES.includes(resource)) ||
3242
- !exactKeys(raw.observers, ["core"]) ||
3243
- !isRecord(raw.leases) || !isRecord(raw.intents) || !isRecord(raw.reservations) ||
3244
- !exactKeys(raw.probeOutcome, ["status", "at", "nextAt"]) ||
3245
- !GOVERNOR_PROBE_STATUSES.has(raw.probeOutcome.status) ||
3246
- finiteTimestamp(raw.probeOutcome.at, nowMs) === undefined ||
3247
- raw.probeOutcome.at > nowMs ||
3248
- finiteTimestamp(raw.probeOutcome.nextAt, nowMs) === undefined
3405
+ !exactKeys(raw.observers, RATE_RESOURCES) ||
3406
+ !exactKeys(raw.probeClaims, RATE_RESOURCES) ||
3407
+ !isRecord(raw.leases) || !isRecord(raw.intents) || !isRecord(raw.reservations)
3249
3408
  ) return null;
3250
3409
 
3251
3410
  const state = emptyGovernorState();
3252
3411
  state.epochs = { ...raw.epochs };
3253
- state.probeOutcome = { ...raw.probeOutcome };
3254
- state.observers.core = normalizeCoreObserver(raw.observers.core, nowMs);
3255
- if (!state.observers.core) return null;
3412
+ state.fairness = { ...raw.fairness };
3413
+ for (const resource of RATE_RESOURCES) {
3414
+ const observer = normalizeResourceObserver(raw.observers[resource], nowMs);
3415
+ if (!observer) return null;
3416
+ state.observers[resource] = observer;
3417
+ }
3256
3418
  for (const resource of Object.keys(raw.budgets)) {
3257
3419
  const budget = normalizeGovernorBudget(raw.budgets[resource], nowMs, resource);
3258
3420
  if (!budget) return null;
@@ -3282,21 +3444,75 @@ function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVE
3282
3444
  ) state.reservations[id] = reservation;
3283
3445
  }
3284
3446
  if (Object.keys(state.reservations).length > GOVERNOR_MAX_RESERVATIONS) return null;
3285
- state.probeClaim = normalizeProbeClaim(raw.probeClaim, nowMs);
3286
- if (state.probeClaim === undefined) return null;
3287
- if (state.probeClaim?.leaseUntil <= nowMs) state.probeClaim = null;
3447
+ for (const resource of RATE_RESOURCES) {
3448
+ const claim = normalizeProbeClaim(raw.probeClaims[resource], nowMs);
3449
+ if (claim === undefined) return null;
3450
+ state.probeClaims[resource] = claim?.leaseUntil > nowMs ? claim : null;
3451
+ }
3288
3452
  state.manualProbe = normalizeManualProbe(raw.manualProbe, nowMs);
3289
3453
  if (state.manualProbe === undefined) return null;
3290
3454
  return state;
3291
3455
  }
3292
3456
 
3457
+ // Reads a still-running older pane's file as evidence. A version number alone
3458
+ // is not enough to identify a document: versions 2 and 3 share a *shape* that
3459
+ // version 4 replaced, so accepting their version against the current key set
3460
+ // would reject every real file they wrote. Read-only -- the result is never
3461
+ // published and the file is never rewritten.
3462
+ function readLegacyGovernorState(raw, nowMs, version) {
3463
+ if (!isRecord(raw) || raw.version !== version) return null;
3464
+ const options = { prune: false, acceptVersion: version };
3465
+ if (version >= GOVERNOR_FAIRNESS_VERSION) return normalizeGovernorState(raw, nowMs, options);
3466
+ // A version 4 file is already in the per-resource observer shape and differs
3467
+ // only by the field this version added, so defaulting it is the whole
3468
+ // adaptation. Older files need their observer shape rewritten first.
3469
+ const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION ? { ...raw } : adaptPreSplitGovernorShape(raw);
3470
+ if (!shaped) return null;
3471
+ shaped.fairness = { manualStreak: 0 };
3472
+ return normalizeGovernorState(shaped, nowMs, options);
3473
+ }
3474
+
3475
+ // Versions 2 and 3 held one observer for core, a differently shaped
3476
+ // `probeOutcome` standing in for GraphQL, and a single claim naming the
3477
+ // resources it covered. Rewrite that into the current shape so the inspector
3478
+ // above can see the leases, deadlines and uncertain work such a pane holds.
3479
+ // The claim's own `resources` list says which resources it covered, so
3480
+ // splitting it in two reads the file rather than guessing at it.
3481
+ function adaptPreSplitGovernorShape(raw) {
3482
+ if (!exactKeys(raw, [
3483
+ "version", "epochs", "budgets", "observers", "probeClaim", "probeOutcome",
3484
+ "leases", "intents", "reservations", "manualProbe",
3485
+ ])) return null;
3486
+ if (!exactKeys(raw.observers, ["core"])) return null;
3487
+ if (!exactKeys(raw.probeOutcome, ["status", "at", "nextAt"])) return null;
3488
+ if (raw.probeClaim !== null && !isRecord(raw.probeClaim)) return null;
3489
+ const { resources, ...claim } = raw.probeClaim ?? {};
3490
+ const covered = Array.isArray(resources) ? resources : [];
3491
+ const adapted = structuredClone(raw);
3492
+ delete adapted.probeClaim;
3493
+ delete adapted.probeOutcome;
3494
+ adapted.observers = {
3495
+ core: raw.observers.core,
3496
+ graphql: {
3497
+ etag: null,
3498
+ outcome: raw.probeOutcome.status,
3499
+ at: raw.probeOutcome.at,
3500
+ nextAt: raw.probeOutcome.nextAt,
3501
+ },
3502
+ };
3503
+ adapted.probeClaims = Object.fromEntries(RATE_RESOURCES.map((resource) => [
3504
+ resource, raw.probeClaim && covered.includes(resource) ? structuredClone(claim) : null,
3505
+ ]));
3506
+ return adapted;
3507
+ }
3508
+
3293
3509
  function serializeGovernorState(state) {
3294
3510
  return `${JSON.stringify(state)}\n`;
3295
3511
  }
3296
3512
 
3297
3513
  function migrateGovernorState(raw, nowMs) {
3298
3514
  if (!exactKeys(raw, [
3299
- "version", "epochs", "budgets", "probeClaim", "probeOutcome", "leases",
3515
+ "version", "epochs", "budgets", "probeClaim", "probeOutcome", "leases", // v1 shape
3300
3516
  "intents", "reservations", "manualProbe",
3301
3517
  ]) || raw.version !== 1 || !isRecord(raw.budgets) || !isRecord(raw.reservations)) return null;
3302
3518
  const migrated = structuredClone(raw);
@@ -3318,7 +3534,16 @@ function migrateGovernorState(raw, nowMs) {
3318
3534
  for (const reservation of Object.values(migrated.reservations)) {
3319
3535
  reservation.accountedCosts = { core: 0, graphql: 0 };
3320
3536
  }
3321
- if (migrated.probeClaim) migrated.probeClaim.resources = [...RATE_RESOURCES];
3537
+ // v1 carried one claim for both resources; the new shape has one per resource
3538
+ // and a migrated claim belongs to neither, so it is dropped rather than
3539
+ // guessed at. The next probe re-claims what is actually due.
3540
+ delete migrated.probeClaim;
3541
+ delete migrated.probeOutcome;
3542
+ migrated.observers = Object.fromEntries(RATE_RESOURCES.map((resource) => [
3543
+ resource, { etag: null, outcome: "idle", at: 0, nextAt: 0 },
3544
+ ]));
3545
+ migrated.probeClaims = Object.fromEntries(RATE_RESOURCES.map((resource) => [resource, null]));
3546
+ migrated.fairness = { manualStreak: 0 };
3322
3547
  return normalizeGovernorState(migrated, nowMs);
3323
3548
  }
3324
3549
 
@@ -3601,6 +3826,33 @@ function mutateGovernor(scope, nowMs, mutate) {
3601
3826
  });
3602
3827
  }
3603
3828
 
3829
+ // The largest single operation the governor will ever admit. Returned pacing is
3830
+ // capped at one of these so an idle stretch cannot accumulate into a burst: at
3831
+ // most one operation's worth of the lane is ever given back at a time.
3832
+ const GOVERNOR_MAX_ATOMIC_COST = Math.max(...Object.values(OPERATION_COSTS)
3833
+ .flatMap((costs) => RATE_RESOURCES.map((resource) => costs?.[resource] ?? 0)));
3834
+
3835
+ // A grant paces the lane by what it *reserved*, which is a worst case. When the
3836
+ // request costs less than that -- a 304 costs nothing -- or never happens at
3837
+ // all, the difference is capacity paced away for nobody, and the next request
3838
+ // waits out a slot no one used.
3839
+ //
3840
+ // The lane is never pulled earlier than the transport gap, which is what keeps
3841
+ // this from becoming a burst. Recomputing the rate here can differ from the
3842
+ // rate at grant time if capacity has since changed, so the returned credit is
3843
+ // an estimate rather than an exact reversal; that is safe because admission
3844
+ // still re-checks affordability against the reserve before anything starts.
3845
+ // Pacing decides when work may go, never whether it may.
3846
+ function returnPacingCredit(state, resource, unusedCost, nowMs) {
3847
+ const budget = state.budgets[resource];
3848
+ if (!budget || !(unusedCost > 0)) return;
3849
+ const decision = resourceDecision({ budget, resource, nowMs, cost: 0, chargedCost: 0 });
3850
+ const callsPerMs = decision?.callsPerMs;
3851
+ if (!Number.isFinite(callsPerMs) || callsPerMs <= 0) return;
3852
+ const credit = Math.min(unusedCost, GOVERNOR_MAX_ATOMIC_COST) / callsPerMs;
3853
+ budget.laneNextAt = Math.max(nowMs + HTTP_START_GAP_MS, budget.laneNextAt - credit);
3854
+ }
3855
+
3604
3856
  function scheduleGovernorState(state, nowMs) {
3605
3857
  if (Object.keys(state.intents).length === 0) return { grants: [], denied: [] };
3606
3858
  let reservationCount = Object.keys(state.reservations).length;
@@ -3612,7 +3864,7 @@ function scheduleGovernorState(state, nowMs) {
3612
3864
  ? [[resource, state.budgets[resource].roundRobinCursor]]
3613
3865
  : [],
3614
3866
  ));
3615
- const deferredBackground = state.probeOutcome.status === "failed"
3867
+ const deferredBackground = state.observers.graphql.outcome === "failed"
3616
3868
  ? Object.entries(state.intents)
3617
3869
  .filter(([, intent]) => intentPriority(intent) === REQUEST_PRIORITIES.background)
3618
3870
  .map(([id]) => id)
@@ -3633,7 +3885,9 @@ function scheduleGovernorState(state, nowMs) {
3633
3885
  cursors,
3634
3886
  nowMs,
3635
3887
  maxGrants: GOVERNOR_MAX_RESERVATIONS - reservationCount,
3888
+ manualStreak: state.fairness.manualStreak,
3636
3889
  });
3890
+ state.fairness.manualStreak = result.manualStreak;
3637
3891
  result.denied.push(...deferredBackground.map((intentId) => ({
3638
3892
  intentId,
3639
3893
  mode: "paused",
@@ -3720,76 +3974,79 @@ function maintainControlLease(scope, leaseId, floorMs, activeTab, nowMs) {
3720
3974
  : registered;
3721
3975
  }
3722
3976
 
3723
- function claimProbe(scope, leaseId, nowMs) {
3977
+ // Claims the observer for one resource. Independence is the point: a slow or
3978
+ // failed GraphQL observer must not hold up core readiness, because nothing about
3979
+ // the resources themselves couples them. What still couples them is the shared
3980
+ // HTTP permit and any account-wide secondary hold, and those are enforced
3981
+ // elsewhere -- not by making one claim stand for both.
3982
+ function claimProbe(scope, leaseId, nowMs, resource) {
3983
+ if (!RATE_RESOURCES.includes(resource)) return { ok: false, reason: "corrupt" };
3724
3984
  return mutateGovernor(scope, nowMs, (state, at) => {
3725
3985
  if (!state.leases[leaseId]) return { ok: false, reason: "stale" };
3726
- if (state.probeClaim && state.probeClaim.leaseUntil > at) {
3727
- return { value: { status: "waiting", leaseUntil: state.probeClaim.leaseUntil } };
3986
+ const held = state.probeClaims[resource];
3987
+ if (held && held.leaseUntil > at) {
3988
+ return { value: { status: "waiting", leaseUntil: held.leaseUntil } };
3728
3989
  }
3990
+ const resetAt = Number.isFinite(state.budgets[resource]?.resetMs)
3991
+ ? state.budgets[resource].resetMs + BUDGET_RESET_GRACE_MS
3992
+ : Number.POSITIVE_INFINITY;
3993
+ // A core reset starts a new shared accounting epoch, so the GraphQL counter
3994
+ // is also due then: external-spend baselines and the protected
3995
+ // one-publication reset contract have to advance together.
3729
3996
  const coreResetAt = Number.isFinite(state.budgets.core?.resetMs)
3730
3997
  ? state.budgets.core.resetMs + BUDGET_RESET_GRACE_MS
3731
3998
  : Number.POSITIVE_INFINITY;
3732
- const graphqlResetAt = Number.isFinite(state.budgets.graphql?.resetMs)
3733
- ? state.budgets.graphql.resetMs + BUDGET_RESET_GRACE_MS
3999
+ // ...but only until it has actually observed since that reset. Left
4000
+ // unqualified, every pane keeps qualifying for as long as core has not
4001
+ // published its new epoch, so a reset draws a stampede of GraphQL observers
4002
+ // instead of the one the shared epoch needs. The publication used to close
4003
+ // that window as a side effect of being read first; saying it here means it
4004
+ // does not depend on which resource a refresh happens to reach first.
4005
+ const dueAtCoreReset = resource === "graphql" &&
4006
+ state.observers.graphql.at < coreResetAt
4007
+ ? coreResetAt
3734
4008
  : Number.POSITIVE_INFINITY;
3735
- const dueAt = {
3736
- core: Math.min(
3737
- state.observers.core.nextAt,
3738
- coreResetAt,
3739
- ),
3740
- graphql: Math.min(
3741
- state.probeOutcome.nextAt,
3742
- graphqlResetAt,
3743
- // A core reset starts a new shared accounting epoch. Refresh the free
3744
- // GraphQL counter in that same nonce so external-spend baselines and
3745
- // the protected one-publication reset contract advance together.
3746
- coreResetAt,
3747
- ),
3748
- };
3749
- const nextAt = Math.min(...Object.values(dueAt));
4009
+ const nextAt = Math.min(
4010
+ state.observers[resource].nextAt,
4011
+ resetAt,
4012
+ dueAtCoreReset,
4013
+ );
3750
4014
  if (nextAt > at) return { value: { status: "waiting", nextAt } };
3751
- // One nonce owns the control-plane transition, but only the sources whose
3752
- // persisted clocks are due may be read. In particular, a GraphQL minute
3753
- // sample cannot spend or retry the held core observer before its reset.
3754
- const resources = RATE_RESOURCES.filter((resource) => dueAt[resource] <= at);
3755
4015
  const nonce = randomUUID();
3756
4016
  const startedReservationIds = Object.entries(state.reservations)
3757
4017
  // An expired owner can no longer settle its request. Keep that uncertain
3758
4018
  // cost charged, but do not make every later probe wait for it to finish.
4019
+ // Only work charging this resource is drained -- an unrelated lane's
4020
+ // outstanding request says nothing about this counter.
3759
4021
  .filter(([, reservation]) => reservation.status === "started" &&
3760
- state.leases[reservation.leaseId]?.expiresAt > at)
4022
+ state.leases[reservation.leaseId]?.expiresAt > at &&
4023
+ reservationCost(reservation, resource, state.leases, at) > 0)
3761
4024
  .map(([id]) => id);
3762
- state.probeClaim = {
4025
+ state.probeClaims[resource] = {
3763
4026
  ownerLeaseId: leaseId,
3764
4027
  nonce,
3765
4028
  leaseUntil: at + GOVERNOR_PROBE_LEASE_MS,
3766
4029
  nextAt: at,
3767
4030
  claimAt: at,
3768
4031
  startedReservationIds,
3769
- resources,
3770
4032
  };
3771
- if (resources.includes("core")) {
3772
- state.observers.core.outcome = "waiting";
3773
- state.observers.core.at = at;
3774
- state.observers.core.nextAt = at + GOVERNOR_PROBE_LEASE_MS;
3775
- }
3776
- if (resources.includes("graphql")) {
3777
- state.probeOutcome = { status: "waiting", at, nextAt: at + GOVERNOR_PROBE_LEASE_MS };
3778
- }
4033
+ state.observers[resource].outcome = "waiting";
4034
+ state.observers[resource].at = at;
4035
+ state.observers[resource].nextAt = at + GOVERNOR_PROBE_LEASE_MS;
3779
4036
  return { value: {
3780
4037
  status: "claimed",
3781
4038
  nonce,
3782
- leaseUntil: state.probeClaim.leaseUntil,
4039
+ resource,
4040
+ leaseUntil: state.probeClaims[resource].leaseUntil,
3783
4041
  startedReservationIds,
3784
- resources,
3785
- coreEtag: state.observers.core.etag,
4042
+ coreEtag: state.observers[resource].etag,
3786
4043
  } };
3787
4044
  });
3788
4045
  }
3789
4046
 
3790
- function renewProbeClaim(scope, leaseId, nonce, nowMs) {
4047
+ function renewProbeClaim(scope, leaseId, nonce, nowMs, resource) {
3791
4048
  return mutateGovernor(scope, nowMs, (state, at) => {
3792
- const claim = state.probeClaim;
4049
+ const claim = state.probeClaims[resource];
3793
4050
  if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce || claim.leaseUntil <= at) {
3794
4051
  return { ok: false, reason: "stale" };
3795
4052
  }
@@ -3847,7 +4104,13 @@ function budgetFromObservation(raw, previous, nowMs, {
3847
4104
  blockReason: blockActive ? previous.blockReason : null,
3848
4105
  laneNextAt: epochChanged ? nowMs : previous.laneNextAt,
3849
4106
  roundRobinCursor: previous?.roundRobinCursor ?? null,
3850
- lastExternalFactor: previous?.lastExternalFactor ?? 1,
4107
+ // A new epoch is a new accounting window, so what other clients did in the
4108
+ // last one is not evidence about this one. The baseline and the local
4109
+ // accumulation below are already reset here; carrying the factor across
4110
+ // kept throttling this window on a ratio measured in a window that is over.
4111
+ // Only a claimed observer can open an epoch, so this cannot be moved by a
4112
+ // stale or reordered response.
4113
+ lastExternalFactor: epochChanged ? 1 : (previous?.lastExternalFactor ?? 1),
3851
4114
  epoch,
3852
4115
  source,
3853
4116
  factorBaseline: epochChanged || !previous?.factorBaseline
@@ -3858,23 +4121,23 @@ function budgetFromObservation(raw, previous, nowMs, {
3858
4121
  return { status: "accepted", budget: next, epochChanged };
3859
4122
  }
3860
4123
 
3861
- function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
4124
+ function publishProbe(scope, leaseId, nonce, budgets, nowMs, resource) {
3862
4125
  return mutateGovernor(scope, nowMs, (state, at) => {
3863
- const claim = state.probeClaim;
4126
+ const claim = state.probeClaims[resource];
3864
4127
  if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce || claim.leaseUntil <= at) {
3865
4128
  return { ok: false, reason: "stale" };
3866
4129
  }
3867
4130
  const nextBudgets = { ...state.budgets };
3868
4131
  const nextEpochs = { ...state.epochs };
4132
+ // Captured before the publication overwrites it: the reset this epoch
4133
+ // change supersedes is the boundary the GraphQL observer is measured against.
4134
+ const supersededCoreResetMs = state.budgets.core?.resetMs;
3869
4135
  const changedResources = [];
3870
4136
  const resetResources = [];
3871
- if (!claim.resources.every((resource) => budgets?.[resource])) {
3872
- return { ok: false, reason: "corrupt" };
3873
- }
4137
+ if (!budgets?.[resource]) return { ok: false, reason: "corrupt" };
3874
4138
  const observerCosts = {};
3875
- for (const resource of claim.resources) {
3876
- const supplied = budgets?.[resource];
3877
- if (!supplied) continue;
4139
+ {
4140
+ const supplied = budgets[resource];
3878
4141
  const raw = supplied.budget ?? supplied;
3879
4142
  // The owner source names what actually established the number. Calling a
3880
4143
  // GraphQL budget a "rate-limit-probe" was accurate when /rate_limit supplied
@@ -3890,19 +4153,20 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
3890
4153
  allowEpochChange: true,
3891
4154
  });
3892
4155
  if (observed.status === "invalid") return { ok: false, reason: "corrupt" };
3893
- if (observed.status === "ignored") continue;
3894
- // The observer's own cost is local spend. Left out, the external-factor
3895
- // reconciliation below attributes it to other clients, and the governor
3896
- // throttles the user's real work to make room for its own probing.
3897
- observerCosts[resource] = Number.isSafeInteger(supplied.cost) && supplied.cost > 0 ? supplied.cost : 0;
3898
- nextBudgets[resource] = observed.budget;
3899
- if (resource === "core" && supplied.blocked === true) {
3900
- nextBudgets[resource].blockUntil = observed.budget.resetMs;
3901
- nextBudgets[resource].blockReason = "rate-limit";
4156
+ if (observed.status !== "ignored") {
4157
+ // The observer's own cost is local spend. Left out, the external-factor
4158
+ // reconciliation below attributes it to other clients, and the governor
4159
+ // throttles the user's real work to make room for its own probing.
4160
+ observerCosts[resource] = Number.isSafeInteger(supplied.cost) && supplied.cost > 0 ? supplied.cost : 0;
4161
+ nextBudgets[resource] = observed.budget;
4162
+ if (resource === "core" && supplied.blocked === true) {
4163
+ nextBudgets[resource].blockUntil = observed.budget.resetMs;
4164
+ nextBudgets[resource].blockReason = "rate-limit";
4165
+ }
4166
+ nextEpochs[resource] = observed.budget.epoch;
4167
+ changedResources.push(resource);
4168
+ if (observed.epochChanged && state.epochs[resource] !== null) resetResources.push(resource);
3902
4169
  }
3903
- nextEpochs[resource] = observed.budget.epoch;
3904
- changedResources.push(resource);
3905
- if (observed.epochChanged && state.epochs[resource] !== null) resetResources.push(resource);
3906
4170
  }
3907
4171
  if (changedResources.length === 0) return { ok: false, reason: "corrupt" };
3908
4172
  const completedBeforeClaim = Object.entries(state.reservations)
@@ -3920,19 +4184,32 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
3920
4184
  total + (reservation.completedAt > baseline.observedAt
3921
4185
  ? reservationCost(reservation, resource, state.leases, at)
3922
4186
  : 0), 0);
4187
+ const globalUsedDelta = nextBudgets[resource].used - baseline.used;
4188
+ const sharedCompletedDelta = previous.knownLocalUsed + legacyCompletedDelta + (observerCosts[resource] ?? 0);
3923
4189
  const factor = nextExternalFactor({
3924
4190
  lastExternalFactor: previous.lastExternalFactor,
3925
- globalUsedDelta: nextBudgets[resource].used - baseline.used,
3926
- sharedCompletedDelta: previous.knownLocalUsed + legacyCompletedDelta + (observerCosts[resource] ?? 0),
4191
+ globalUsedDelta,
4192
+ sharedCompletedDelta,
3927
4193
  });
3928
4194
  if (factor === null) return { ok: false, reason: "corrupt" };
3929
4195
  nextBudgets[resource].lastExternalFactor = factor;
3930
- nextBudgets[resource].factorBaseline = {
3931
- epoch: nextBudgets[resource].epoch,
3932
- used: nextBudgets[resource].used,
3933
- observedAt: nextBudgets[resource].observedAt,
3934
- };
3935
- nextBudgets[resource].knownLocalUsed = 0;
4196
+ // Closing the window costs the evidence in it, so only a window that
4197
+ // actually reconciled may close. Four local units observed twice are
4198
+ // eight, and eight are enough; discarding each four because neither
4199
+ // reached five on its own is what kept a factor of seven alive through
4200
+ // any amount of purely local spend.
4201
+ if (externalSampleIsUsable({ globalUsedDelta, sharedCompletedDelta })) {
4202
+ nextBudgets[resource].factorBaseline = {
4203
+ epoch: nextBudgets[resource].epoch,
4204
+ used: nextBudgets[resource].used,
4205
+ observedAt: nextBudgets[resource].observedAt,
4206
+ };
4207
+ nextBudgets[resource].knownLocalUsed = 0;
4208
+ }
4209
+ // Otherwise both are left exactly as the observation produced them, which
4210
+ // is the previous baseline and the previous local total. Rewriting them
4211
+ // here would count the same completed reservations twice: they are still
4212
+ // in the ledger precisely because the baseline did not advance past them.
3936
4213
  }
3937
4214
  for (const [id, reservation] of completedBeforeClaim) {
3938
4215
  const accountedByEveryResource = RATE_RESOURCES.every((resource) =>
@@ -3942,24 +4219,41 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
3942
4219
  }
3943
4220
  state.budgets = nextBudgets;
3944
4221
  state.epochs = nextEpochs;
3945
- state.probeClaim = null;
3946
- if (claim.resources.includes("core")) {
3947
- const core = nextBudgets.core;
3948
- state.observers.core = {
3949
- etag: typeof budgets.core.etag === "string" ? budgets.core.etag : state.observers.core.etag,
4222
+ state.probeClaims[resource] = null;
4223
+ {
4224
+ const published = nextBudgets[resource];
4225
+ state.observers[resource] = {
4226
+ etag: typeof budgets[resource].etag === "string" ? budgets[resource].etag : state.observers[resource].etag,
3950
4227
  outcome: "healthy",
3951
4228
  at,
3952
- nextAt: core.remaining === 0
3953
- ? core.resetMs + BUDGET_RESET_GRACE_MS
4229
+ // A resource observed as empty is due again at its own reset, not at the
4230
+ // ordinary cadence: probing an exhausted counter to be told it is still
4231
+ // exhausted spends against it for nothing.
4232
+ nextAt: published.remaining === 0
4233
+ ? published.resetMs + BUDGET_RESET_GRACE_MS
3954
4234
  : at + BUDGET_PROBE_MS,
3955
4235
  };
3956
4236
  }
3957
- if (claim.resources.includes("graphql")) {
3958
- state.probeOutcome = {
3959
- status: "healthy",
3960
- at,
3961
- nextAt: at + BUDGET_PROBE_MS,
3962
- };
4237
+ // A core reset opens a new shared accounting epoch, so the GraphQL counter
4238
+ // is due with it: external-spend baselines and the protected
4239
+ // one-publication reset contract have to advance together. A refresh that
4240
+ // reads GraphQL before core has already done this, because core's old reset
4241
+ // time still said GraphQL was due when that claim was evaluated. What
4242
+ // cannot see it is a pane whose GraphQL observer last ran before the reset
4243
+ // -- another pane published this epoch first, moving the reset out of
4244
+ // reach. Only that case still owes an observation, so only that case is
4245
+ // pulled forward; marking it unconditionally spends a second point to
4246
+ // re-read a counter this same cycle has just read. An exhausted GraphQL
4247
+ // counter waits for its own reset either way.
4248
+ if (resource === "core" && resetResources.includes("core")) {
4249
+ const graphql = nextBudgets.graphql;
4250
+ const heldToReset = graphql && graphql.remaining === 0 &&
4251
+ at < graphql.resetMs + BUDGET_RESET_GRACE_MS;
4252
+ const observedSinceReset = Number.isFinite(supersededCoreResetMs) &&
4253
+ state.observers.graphql.at >= supersededCoreResetMs;
4254
+ if (!heldToReset && !observedSinceReset) {
4255
+ state.observers.graphql.nextAt = Math.min(state.observers.graphql.nextAt || at, at);
4256
+ }
3963
4257
  }
3964
4258
  if (resetResources.length > 0) state.manualProbe = null;
3965
4259
  else if (state.manualProbe &&
@@ -3976,24 +4270,22 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
3976
4270
  });
3977
4271
  }
3978
4272
 
3979
- function failProbeClaim(scope, leaseId, nonce, nowMs) {
4273
+ function failProbeClaim(scope, leaseId, nonce, nowMs, resource) {
3980
4274
  return mutateGovernor(scope, nowMs, (state, at) => {
3981
- const claim = state.probeClaim;
4275
+ const claim = state.probeClaims[resource];
3982
4276
  if (!claim || claim.ownerLeaseId !== leaseId || claim.nonce !== nonce) {
3983
4277
  return { ok: false, reason: "stale" };
3984
4278
  }
3985
- state.probeClaim = null;
3986
- if (claim.resources.includes("graphql")) {
3987
- state.probeOutcome = { status: "failed", at, nextAt: at + BUDGET_PROBE_MS };
3988
- }
3989
- if (claim.resources.includes("core")) {
3990
- state.observers.core = {
3991
- ...state.observers.core,
3992
- outcome: "failed",
3993
- at,
3994
- nextAt: at + BUDGET_PROBE_MS,
3995
- };
3996
- }
4279
+ // Only this resource's readiness is affected. A failed GraphQL observer that
4280
+ // also marked core failed would erase authority core had legitimately
4281
+ // established, and stall a lane with nothing wrong with it.
4282
+ state.probeClaims[resource] = null;
4283
+ state.observers[resource] = {
4284
+ ...state.observers[resource],
4285
+ outcome: "failed",
4286
+ at,
4287
+ nextAt: at + BUDGET_PROBE_MS,
4288
+ };
3997
4289
  return { value: { retryAt: at + BUDGET_PROBE_MS } };
3998
4290
  });
3999
4291
  }
@@ -4015,7 +4307,7 @@ function requestManualProbe(scope, leaseId, epoch, observedAt, nowMs) {
4015
4307
  : Math.min(state.observers.core.nextAt || at, at);
4016
4308
  }
4017
4309
  if (state.epochs.graphql === epoch) {
4018
- state.probeOutcome.nextAt = Math.min(state.probeOutcome.nextAt || at, at);
4310
+ state.observers.graphql.nextAt = Math.min(state.observers.graphql.nextAt || at, at);
4019
4311
  }
4020
4312
  return { value: { status: "pending", ...state.manualProbe } };
4021
4313
  });
@@ -4118,6 +4410,10 @@ function cancelIntent(scope, intentId, nowMs) {
4118
4410
  return { ok: false, reason: "stale" };
4119
4411
  }
4120
4412
  delete state.reservations[reservationId];
4413
+ // The request never started, so the whole slot it was paced into is free.
4414
+ for (const resource of RATE_RESOURCES) {
4415
+ returnPacingCredit(state, resource, reservation.costs[resource], nowMs ?? Date.now());
4416
+ }
4121
4417
  return { value: { status: "cancelled", intentId, reservationId } };
4122
4418
  });
4123
4419
  }
@@ -4131,8 +4427,14 @@ function startReservation(scope, reservationId, nowMs) {
4131
4427
  return { ok: false, reason: "stale", write: Boolean(reservation) };
4132
4428
  }
4133
4429
  if (reservation.notBefore > at) return { value: { status: "waiting", notBefore: reservation.notBefore } };
4134
- if (state.probeClaim && state.probeClaim.leaseUntil > at) {
4135
- return { value: { status: "waiting", reason: "probe", notBefore: state.probeClaim.leaseUntil } };
4430
+ // Any live observer claim defers a start: the claim owns the shared HTTP
4431
+ // permit for its window, so starting here would contend for it.
4432
+ const liveClaim = RATE_RESOURCES
4433
+ .map((resource) => state.probeClaims[resource])
4434
+ .filter((claim) => claim && claim.leaseUntil > at)
4435
+ .sort((left, right) => right.leaseUntil - left.leaseUntil)[0];
4436
+ if (liveClaim) {
4437
+ return { value: { status: "waiting", reason: "probe", notBefore: liveClaim.leaseUntil } };
4136
4438
  }
4137
4439
  for (const resource of RATE_RESOURCES.filter((name) => reservation.costs[name] > 0)) {
4138
4440
  const budget = state.budgets[resource];
@@ -4267,6 +4569,15 @@ function settleReservationWithBudgetObservations(
4267
4569
  acceptedAfterBaseline[resource],
4268
4570
  );
4269
4571
  }
4572
+ // Only a measured outcome knows what it spent. A timeout, abort or process
4573
+ // loss proves nothing, so its worst case stays charged and its pacing stays
4574
+ // spent -- refunding there would let a run of timeouts pace as though
4575
+ // nothing had been sent.
4576
+ if (completion.outcome === "measured-success") {
4577
+ for (const resource of RATE_RESOURCES) {
4578
+ returnPacingCredit(state, resource, reservation.costs[resource] - measured[resource], at);
4579
+ }
4580
+ }
4270
4581
  scheduleGovernorState(state, at);
4271
4582
  return { value: {
4272
4583
  status: "completed",
@@ -4289,8 +4600,8 @@ function recordResourceBlock(scope, resource, resetMs, reason) {
4289
4600
  resetMs + BUDGET_RESET_GRACE_MS,
4290
4601
  );
4291
4602
  } else {
4292
- state.probeOutcome.nextAt = Math.min(
4293
- state.probeOutcome.nextAt || resetMs + BUDGET_RESET_GRACE_MS,
4603
+ state.observers.graphql.nextAt = Math.min(
4604
+ state.observers.graphql.nextAt || resetMs + BUDGET_RESET_GRACE_MS,
4294
4605
  resetMs + BUDGET_RESET_GRACE_MS,
4295
4606
  );
4296
4607
  }
@@ -4326,7 +4637,7 @@ function governorHealth(result, nowMs = Date.now()) {
4326
4637
  if (!result?.ok) return { status: "unavailable", leases: 0, resources: {} };
4327
4638
  const state = result.value;
4328
4639
  let status = "healthy";
4329
- if (state.probeClaim?.leaseUntil > nowMs) status = "waiting for probe";
4640
+ if (RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > nowMs)) status = "waiting for probe";
4330
4641
  else if (RATE_RESOURCES.some((resource) => !state.budgets[resource] ||
4331
4642
  nowMs - state.budgets[resource].observedAt > budgetSnapshotTtl(resource))) status = "stale";
4332
4643
  else if (RATE_RESOURCES.some((resource) => state.budgets[resource].blockUntil > nowMs)) status = "blocked";
@@ -4354,7 +4665,40 @@ async function retryGovernorMutation(run, { now, wait, deadline, signal }) {
4354
4665
  return result;
4355
4666
  }
4356
4667
 
4357
- async function refreshSharedBudget(scope, leaseId, signal, {
4668
+ // Each resource refreshes on its own claim. A slow or failing observer for one
4669
+ // must not delay or invalidate the other's readiness -- nothing about the
4670
+ // resources couples them. What still couples them is the shared HTTP permit and
4671
+ // any account-wide secondary hold, and those are enforced where they belong.
4672
+ // Core is refreshed last because its publication is what reopens data
4673
+ // admission. Finishing the cycle's control work before that happens stops a
4674
+ // pane that merely published the reset from getting a head start, over the
4675
+ // shared permit, on higher-priority work waiting for the same lane. It is also
4676
+ // the order readSharedBudgetSources reads its sources in, and the order the
4677
+ // single claim this replaced already used, so the sequence of actual HTTP
4678
+ // calls is unchanged.
4679
+ const BUDGET_REFRESH_ORDER = ["graphql", "core"];
4680
+
4681
+ async function refreshSharedBudget(scope, leaseId, signal, options = {}) {
4682
+ const outcomes = [];
4683
+ for (const resource of BUDGET_REFRESH_ORDER) {
4684
+ outcomes.push([resource, await refreshResourceBudget(scope, leaseId, signal, resource, options)]);
4685
+ }
4686
+ const published = outcomes.filter(([, result]) => result.ok);
4687
+ if (published.length === 0) return outcomes[0][1];
4688
+ const inspect = options.inspect ?? inspectGovernor;
4689
+ const now = options.now ?? (() => scopeNow(scope));
4690
+ const snapshot = inspect(scope, now());
4691
+ return {
4692
+ ok: true,
4693
+ value: {
4694
+ status: "published",
4695
+ budgets: snapshot.ok ? snapshot.value.budgets : {},
4696
+ resources: published.map(([resource]) => resource),
4697
+ },
4698
+ };
4699
+ }
4700
+
4701
+ async function refreshResourceBudget(scope, leaseId, signal, resource, {
4358
4702
  readBudgets = readSharedBudgetSources,
4359
4703
  now = () => scopeNow(scope),
4360
4704
  wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -4363,7 +4707,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
4363
4707
  publish = publishProbe,
4364
4708
  fail = failProbeClaim,
4365
4709
  } = {}) {
4366
- let claim = claimProbe(scope, leaseId, now());
4710
+ let claim = claimProbe(scope, leaseId, now(), resource);
4367
4711
  if (!claim.ok) return claim;
4368
4712
  if (claim.value.status !== "claimed") {
4369
4713
  const startedAt = now();
@@ -4376,32 +4720,33 @@ async function refreshSharedBudget(scope, leaseId, signal, {
4376
4720
  inspections += 1;
4377
4721
  const snapshot = inspect(scope, now());
4378
4722
  if (!snapshot.ok) return snapshot;
4723
+ // Only this resource's freshness matters here. Waiting on the other's
4724
+ // observer is what made one failing lane stall an unrelated healthy one.
4379
4725
  if (
4380
- RATE_RESOURCES.every((resource) =>
4381
- snapshot.value.budgets[resource] &&
4382
- now() - snapshot.value.budgets[resource].observedAt <= budgetSnapshotTtl(resource),
4383
- ) && snapshot.value.probeOutcome.status === "healthy"
4726
+ snapshot.value.budgets[resource] &&
4727
+ now() - snapshot.value.budgets[resource].observedAt <= budgetSnapshotTtl(resource) &&
4728
+ snapshot.value.observers[resource].outcome === "healthy"
4384
4729
  ) {
4385
4730
  return { ok: true, value: { status: "published", budgets: snapshot.value.budgets } };
4386
4731
  }
4387
4732
  if (now() >= deadline || inspections >= 10) break;
4388
4733
  await wait(Math.min(100, Math.max(1, deadline - now())));
4389
4734
  }
4390
- claim = claimProbe(scope, leaseId, now());
4735
+ claim = claimProbe(scope, leaseId, now(), resource);
4391
4736
  if (!claim.ok || claim.value.status !== "claimed") return claim;
4392
4737
  }
4393
- const { nonce, startedReservationIds, resources = RATE_RESOURCES, coreEtag = null } = claim.value;
4738
+ const { nonce, startedReservationIds, coreEtag = null } = claim.value;
4394
4739
  const drainUntil = now() + GOVERNOR_PROBE_DRAIN_MS;
4395
4740
  while (startedReservationIds.length > 0 && now() < drainUntil) {
4396
4741
  const snapshot = inspect(scope, now());
4397
4742
  if (!snapshot.ok) {
4398
- failProbeClaim(scope, leaseId, nonce, now());
4743
+ failProbeClaim(scope, leaseId, nonce, now(), resource);
4399
4744
  return snapshot;
4400
4745
  }
4401
4746
  const stillStarted = startedReservationIds.some((id) => snapshot.value.reservations[id]?.status === "started");
4402
4747
  if (!stillStarted) break;
4403
4748
  if (signal?.aborted) {
4404
- failProbeClaim(scope, leaseId, nonce, now());
4749
+ failProbeClaim(scope, leaseId, nonce, now(), resource);
4405
4750
  return { ok: false, reason: "stale" };
4406
4751
  }
4407
4752
  await wait(Math.min(100, Math.max(1, drainUntil - now())));
@@ -4412,11 +4757,11 @@ async function refreshSharedBudget(scope, leaseId, signal, {
4412
4757
  renewalStartedAt + GOVERNOR_PROBE_TRANSITION_MS,
4413
4758
  );
4414
4759
  const renewed = await retryGovernorMutation(
4415
- () => renew(scope, leaseId, nonce, now()),
4760
+ () => renew(scope, leaseId, nonce, now(), resource),
4416
4761
  { now, wait, deadline: renewalDeadline, signal },
4417
4762
  );
4418
4763
  if (!renewed.ok) {
4419
- failProbeClaim(scope, leaseId, nonce, now());
4764
+ failProbeClaim(scope, leaseId, nonce, now(), resource);
4420
4765
  return renewed;
4421
4766
  }
4422
4767
  // Known exhaustion waits for its actual reset. The GraphQL observer costs a
@@ -4424,24 +4769,23 @@ async function refreshSharedBudget(scope, leaseId, signal, {
4424
4769
  // the hole deeper -- the /rate_limit read this replaced was free and could
4425
4770
  // afford to be unconditional.
4426
4771
  const beforeRead = inspect(scope, now());
4427
- const spendable = resources.filter((resource) => {
4428
- if (resource !== "graphql" || !beforeRead.ok) return true;
4429
- const budget = beforeRead.value.budgets.graphql;
4430
- return !(budget && budget.remaining <= 0 && now() < budget.resetMs + BUDGET_RESET_GRACE_MS);
4431
- });
4432
- if (spendable.length === 0) {
4433
- failProbeClaim(scope, leaseId, nonce, now());
4772
+ const exhausted = resource === "graphql" && beforeRead.ok &&
4773
+ beforeRead.value.budgets.graphql &&
4774
+ beforeRead.value.budgets.graphql.remaining <= 0 &&
4775
+ now() < beforeRead.value.budgets.graphql.resetMs + BUDGET_RESET_GRACE_MS;
4776
+ if (exhausted) {
4777
+ failProbeClaim(scope, leaseId, nonce, now(), resource);
4434
4778
  return { ok: false, reason: "budget-reset" };
4435
4779
  }
4436
4780
  let budgets;
4437
4781
  try {
4438
4782
  budgets = await requestIdentityStorage.run(scope, () => readBudgets(signal, scope.host, {
4439
- resources: spendable,
4783
+ resources: [resource],
4440
4784
  coreEtag,
4441
4785
  renewClaim: async () => {
4442
4786
  const startedAt = now();
4443
4787
  const extended = await retryGovernorMutation(
4444
- () => renew(scope, leaseId, nonce, now()),
4788
+ () => renew(scope, leaseId, nonce, now(), resource),
4445
4789
  {
4446
4790
  now,
4447
4791
  wait,
@@ -4462,7 +4806,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
4462
4806
  now() + GOVERNOR_PROBE_TRANSITION_MS,
4463
4807
  );
4464
4808
  await retryGovernorMutation(
4465
- () => fail(scope, leaseId, nonce, now()),
4809
+ () => fail(scope, leaseId, nonce, now(), resource),
4466
4810
  { now, wait, deadline: failureDeadline, signal },
4467
4811
  );
4468
4812
  return { ok: false, reason: "stale" };
@@ -4472,12 +4816,12 @@ async function refreshSharedBudget(scope, leaseId, signal, {
4472
4816
  now() + GOVERNOR_PROBE_TRANSITION_MS,
4473
4817
  );
4474
4818
  const published = await retryGovernorMutation(
4475
- () => publish(scope, leaseId, nonce, budgets, now()),
4819
+ () => publish(scope, leaseId, nonce, budgets, now(), resource),
4476
4820
  { now, wait, deadline: transitionDeadline, signal },
4477
4821
  );
4478
4822
  if (!published.ok) {
4479
4823
  await retryGovernorMutation(
4480
- () => fail(scope, leaseId, nonce, now()),
4824
+ () => fail(scope, leaseId, nonce, now(), resource),
4481
4825
  { now, wait, deadline: transitionDeadline, signal },
4482
4826
  );
4483
4827
  }
@@ -7499,14 +7843,20 @@ function retryPollAfterAdmissionFailure({
7499
7843
 
7500
7844
  function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
7501
7845
  const budgets = Object.values(state?.budgets ?? {});
7502
- const observedCandidates = state?.probeOutcome?.status === "failed"
7503
- ? []
7504
- : budgets.map((budget) => budget.observedAt + BUDGET_PROBE_MS);
7846
+ // A failed observer's own retry, not its stale sample's cadence, says when
7847
+ // that resource is next worth reading. Suppress the cadence per resource: a
7848
+ // global suppression let one failure silence a healthy lane's wake, and no
7849
+ // suppression let the failed lane spin on the sample it already knows is old.
7850
+ const observedCandidates = RATE_RESOURCES.flatMap((resource) => {
7851
+ const budget = state?.budgets?.[resource];
7852
+ if (!budget || state?.observers?.[resource]?.outcome === "failed") return [];
7853
+ return [budget.observedAt + BUDGET_PROBE_MS];
7854
+ });
7505
7855
  const controlAt = Math.min(
7506
7856
  ...observedCandidates,
7507
7857
  ...budgets.map((budget) => budget.resetMs + BUDGET_RESET_GRACE_MS),
7508
- state?.probeClaim?.leaseUntil ?? Number.POSITIVE_INFINITY,
7509
- state?.probeOutcome?.nextAt ?? Number.POSITIVE_INFINITY,
7858
+ ...RATE_RESOURCES.map((resource) => state?.probeClaims?.[resource]?.leaseUntil ?? Number.POSITIVE_INFINITY),
7859
+ ...RATE_RESOURCES.map((resource) => state?.observers?.[resource]?.nextAt ?? Number.POSITIVE_INFINITY),
7510
7860
  );
7511
7861
  const reservationAt = Object.values(state?.reservations ?? {})
7512
7862
  .filter((reservation) => reservation.status === "scheduled" &&
@@ -7521,7 +7871,8 @@ function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
7521
7871
  function governorProtocolReady(refreshResult, snapshot, nowMs) {
7522
7872
  if (!refreshResult?.ok || !snapshot?.ok) return false;
7523
7873
  if (["waiting", "paused", "probe"].includes(refreshResult.value?.status)) return false;
7524
- if (snapshot.value.probeClaim || snapshot.value.probeOutcome?.status !== "healthy") return false;
7874
+ if (RATE_RESOURCES.some((resource) => snapshot.value.probeClaims[resource]) ||
7875
+ RATE_RESOURCES.some((resource) => snapshot.value.observers[resource]?.outcome !== "healthy")) return false;
7525
7876
  return RATE_RESOURCES.every((resource) => {
7526
7877
  const budget = snapshot.value.budgets[resource];
7527
7878
  return budget && nowMs - budget.observedAt <= budgetSnapshotTtl(resource);
@@ -7691,7 +8042,7 @@ function rateLimitBlockDecision(results, resetMs) {
7691
8042
  const IDENTITY_COORDINATION_REASONS = [
7692
8043
  "restart-required", "migration-hold", "legacy-unresolved", "legacy-corrupt",
7693
8044
  "credential-unavailable", "identity-backoff", "identity-busy",
7694
- "identity-unavailable", "identity-capacity", "transport-busy",
8045
+ "identity-unavailable", "identity-capacity", "transport-busy", "throttle-paused",
7695
8046
  ];
7696
8047
 
7697
8048
  function coordinationNotice(reason) {
@@ -10776,6 +11127,7 @@ export {
10776
11127
  governorPhaseOffset,
10777
11128
  scheduleIntents,
10778
11129
  GOVERNOR_STATE_VERSION,
11130
+ RATE_RESOURCES,
10779
11131
  GOVERNOR_MAX_LEASES,
10780
11132
  GOVERNOR_MAX_INTENTS,
10781
11133
  GOVERNOR_MAX_RESERVATIONS,
@@ -10801,6 +11153,16 @@ export {
10801
11153
  heartbeatLease,
10802
11154
  maintainControlLease,
10803
11155
  claimProbe,
11156
+ classifyThrottle,
11157
+ applyTransportCooldown,
11158
+ clearTransportThrottle,
11159
+ emptyTransportThrottle,
11160
+ throttleLadderMs,
11161
+ transportCooldownDeadline,
11162
+ retryThrottledTransport,
11163
+ THROTTLE_LADDER_MS,
11164
+ THROTTLE_PAUSE_AFTER,
11165
+ MANUAL_GRANT_STREAK_LIMIT,
10804
11166
  renewProbeClaim,
10805
11167
  publishProbe,
10806
11168
  failProbeClaim,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-glance",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A live-refreshing GitHub dashboard for your terminal: Actions, Issues, Pull Requests, and Security alerts in one narrow pane",
5
5
  "type": "module",
6
6
  "bin": {