agent-relay-server 0.100.0 → 0.101.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 (86) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DZ3cUoHO.js → MessageBubble-BEjK1-bi.js} +3 -3
  4. package/public/assets/{MessageBubble-DZ3cUoHO.js.map → MessageBubble-BEjK1-bi.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-B6G_RSZ3.js → PlanGraphPanel-AqFEg7Yg.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-B6G_RSZ3.js.map → PlanGraphPanel-AqFEg7Yg.js.map} +1 -1
  7. package/public/assets/{activity-BefDMYiE.js → activity-D5EZw4bj.js} +2 -2
  8. package/public/assets/{activity-BefDMYiE.js.map → activity-D5EZw4bj.js.map} +1 -1
  9. package/public/assets/{agent-profiles-D5VW943m.js → agent-profiles-CSISRm_L.js} +2 -2
  10. package/public/assets/{agent-profiles-D5VW943m.js.map → agent-profiles-CSISRm_L.js.map} +1 -1
  11. package/public/assets/{agents-CtsNY05B.js → agents-szyYnkgD.js} +2 -2
  12. package/public/assets/{agents-CtsNY05B.js.map → agents-szyYnkgD.js.map} +1 -1
  13. package/public/assets/{analytics-CUJbpoA7.js → analytics-KzLQfGMO.js} +2 -2
  14. package/public/assets/{analytics-CUJbpoA7.js.map → analytics-KzLQfGMO.js.map} +1 -1
  15. package/public/assets/{automation-B5TFhCGs.js → automation-DVSq4A1b.js} +2 -2
  16. package/public/assets/{automation-B5TFhCGs.js.map → automation-DVSq4A1b.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BLF9LPtu.js → branch-state-badge-DgwkJ2rN.js} +2 -2
  18. package/public/assets/{branch-state-badge-BLF9LPtu.js.map → branch-state-badge-DgwkJ2rN.js.map} +1 -1
  19. package/public/assets/{channels-Tg8l4n5J.js → channels-Dh_r7SnM.js} +2 -2
  20. package/public/assets/{channels-Tg8l4n5J.js.map → channels-Dh_r7SnM.js.map} +1 -1
  21. package/public/assets/chat-patMKjsi.js +2 -0
  22. package/public/assets/chat-patMKjsi.js.map +1 -0
  23. package/public/assets/{connectors-B5wbjyUd.js → connectors-DVKBHNRV.js} +2 -2
  24. package/public/assets/{connectors-B5wbjyUd.js.map → connectors-DVKBHNRV.js.map} +1 -1
  25. package/public/assets/{formatted-body-impl-Zk5WjWS7.js → formatted-body-impl-Csr2DuEk.js} +2 -2
  26. package/public/assets/{formatted-body-impl-Zk5WjWS7.js.map → formatted-body-impl-Csr2DuEk.js.map} +1 -1
  27. package/public/assets/index-D3woTUwp.js +23 -0
  28. package/public/assets/index-D3woTUwp.js.map +1 -0
  29. package/public/assets/{insights-C8iC-IB1.js → insights-D2_Qry06.js} +2 -2
  30. package/public/assets/{insights-C8iC-IB1.js.map → insights-D2_Qry06.js.map} +1 -1
  31. package/public/assets/{integrations-sYKtupOq.js → integrations-AW8b4h7T.js} +2 -2
  32. package/public/assets/{integrations-sYKtupOq.js.map → integrations-AW8b4h7T.js.map} +1 -1
  33. package/public/assets/maintenance-Dm56u8F9.js +2 -0
  34. package/public/assets/{maintenance-pl6U2a_y.js.map → maintenance-Dm56u8F9.js.map} +1 -1
  35. package/public/assets/{managed-agents-1CynkXEy.js → managed-agents-DpX-cHsm.js} +2 -2
  36. package/public/assets/{managed-agents-1CynkXEy.js.map → managed-agents-DpX-cHsm.js.map} +1 -1
  37. package/public/assets/{markdown-preview-impl-Dj6MExTR.js → markdown-preview-impl-Df6EWwbg.js} +2 -2
  38. package/public/assets/{markdown-preview-impl-Dj6MExTR.js.map → markdown-preview-impl-Df6EWwbg.js.map} +1 -1
  39. package/public/assets/{memory-DyWT5mwx.js → memory-b2AgvV7H.js} +2 -2
  40. package/public/assets/{memory-DyWT5mwx.js.map → memory-b2AgvV7H.js.map} +1 -1
  41. package/public/assets/{messages-LqXFX2yc.js → messages-C5yDD_oq.js} +2 -2
  42. package/public/assets/{messages-LqXFX2yc.js.map → messages-C5yDD_oq.js.map} +1 -1
  43. package/public/assets/{orchestrators-xdpBJOt1.js → orchestrators-D3LecmEw.js} +2 -2
  44. package/public/assets/{orchestrators-xdpBJOt1.js.map → orchestrators-D3LecmEw.js.map} +1 -1
  45. package/public/assets/{overview-BDetOVf7.js → overview-CnGkrBNe.js} +2 -2
  46. package/public/assets/{overview-BDetOVf7.js.map → overview-CnGkrBNe.js.map} +1 -1
  47. package/public/assets/{pairs-BxVd-YVM.js → pairs-DXUdZOqn.js} +2 -2
  48. package/public/assets/{pairs-BxVd-YVM.js.map → pairs-DXUdZOqn.js.map} +1 -1
  49. package/public/assets/{projects-BPI8z55X.js → projects-1h41atqJ.js} +2 -2
  50. package/public/assets/{projects-BPI8z55X.js.map → projects-1h41atqJ.js.map} +1 -1
  51. package/public/assets/{registry-BeoPmyhz.js → registry-BeGP37ot.js} +2 -2
  52. package/public/assets/{registry-BeoPmyhz.js.map → registry-BeGP37ot.js.map} +1 -1
  53. package/public/assets/{security-DKDgxf96.js → security-BMh_y43L.js} +2 -2
  54. package/public/assets/{security-DKDgxf96.js.map → security-BMh_y43L.js.map} +1 -1
  55. package/public/assets/{select-DpRN9C_1.js → select-Cvi5OHz0.js} +2 -2
  56. package/public/assets/{select-DpRN9C_1.js.map → select-Cvi5OHz0.js.map} +1 -1
  57. package/public/assets/settings-DKSEwIVG.js +6 -0
  58. package/public/assets/{settings-D_mLzA5O.js.map → settings-DKSEwIVG.js.map} +1 -1
  59. package/public/assets/store-Ch-QzxPq.js +9 -0
  60. package/public/assets/store-Ch-QzxPq.js.map +1 -0
  61. package/public/assets/{tasks-D5OVfd-K.js → tasks-BybIAX-y.js} +2 -2
  62. package/public/assets/{tasks-D5OVfd-K.js.map → tasks-BybIAX-y.js.map} +1 -1
  63. package/public/assets/{teams-CFJM0XlG.js → teams-CMteOToR.js} +2 -2
  64. package/public/assets/{teams-CFJM0XlG.js.map → teams-CMteOToR.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-B-2tqoEB.js → terminal-viewer-impl-DanOi7lh.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-B-2tqoEB.js.map → terminal-viewer-impl-DanOi7lh.js.map} +1 -1
  67. package/public/assets/{user-avatar-BGXTBaME.js → user-avatar-DG629icI.js} +2 -2
  68. package/public/assets/{user-avatar-BGXTBaME.js.map → user-avatar-DG629icI.js.map} +1 -1
  69. package/public/assets/{work-queue-DMwtDbBg.js → work-queue-DqW80DIy.js} +2 -2
  70. package/public/assets/{work-queue-DMwtDbBg.js.map → work-queue-DqW80DIy.js.map} +1 -1
  71. package/public/assets/{workspaces-BolccWZy.js → workspaces-CccVI6lA.js} +2 -2
  72. package/public/assets/{workspaces-BolccWZy.js.map → workspaces-CccVI6lA.js.map} +1 -1
  73. package/public/index.html +2 -2
  74. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  75. package/src/db/provider-quota-burn.ts +78 -6
  76. package/src/quota-band-transition.ts +101 -11
  77. package/src/settings/registry.ts +34 -0
  78. package/src/spawn-targets.ts +79 -10
  79. package/public/assets/chat-CoZcF6dA.js +0 -2
  80. package/public/assets/chat-CoZcF6dA.js.map +0 -1
  81. package/public/assets/index-B4cTW4A9.js +0 -23
  82. package/public/assets/index-B4cTW4A9.js.map +0 -1
  83. package/public/assets/maintenance-pl6U2a_y.js +0 -2
  84. package/public/assets/settings-D_mLzA5O.js +0 -6
  85. package/public/assets/store-pKbf_WYd.js +0 -9
  86. package/public/assets/store-pKbf_WYd.js.map +0 -1
@@ -1,8 +1,20 @@
1
- import { quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
1
+ import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
2
2
  import type { ProviderQuotaBurn, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaSnapshot, QuotaState } from "../types";
3
3
 
4
4
  const QUOTA_BURN_MIN_SAMPLE_SPAN_MS = 10 * 60_000;
5
5
 
6
+ // #760 — nominal window length per role, used to scale the recency half-life.
7
+ const WINDOW_NOMINAL_MS: Record<ProviderQuotaBurnRole, number> = {
8
+ "5h": 5 * 3_600_000,
9
+ "7d": 7 * 24 * 3_600_000,
10
+ };
11
+
12
+ // #760 — recency half-life as a fraction of the window length. 0.25 → ~75min for the 5h
13
+ // window (within the issue's ~60–90min trailing-window guidance) and ~42h for the 7d window.
14
+ // A burst at the START of the history span decays out as it ages, so an idle stretch pulls the
15
+ // observed rate (and burnRatio) back toward 0 instead of freezing the endpoint slope.
16
+ const RECENCY_HALFLIFE_FRACTION = 0.25;
17
+
6
18
  type QuotaWindow = QuotaState["windows"][number];
7
19
 
8
20
  // Provider-agnostic: window role is expressed by name only. Each adapter uses the
@@ -36,9 +48,38 @@ function lowConfidenceBurnWindow(role: ProviderQuotaBurnRole, window: QuotaWindo
36
48
  timeToWallMs: null,
37
49
  projectedWallAt: null,
38
50
  earlyWallMs: null,
51
+ observedRatePerMs: null,
52
+ projectedUtilAtReset: null,
53
+ slack: null,
54
+ target: null,
39
55
  };
40
56
  }
41
57
 
58
+ // #760 — recency-weighted consumption rate (utilization-fraction per ms). A weighted slope:
59
+ // each consecutive interval contributes its actual Δutilization and Δt, decayed by the age of
60
+ // its midpoint with the given half-life. A recent idle interval (large Δt, ~0 Δutil, high
61
+ // weight) dominates the denominator and drives the rate toward 0; an old burst is down-weighted.
62
+ function recencyWeightedRatePerMs(
63
+ samples: Array<{ capturedAt: number; utilization: number }>,
64
+ now: number,
65
+ halfLifeMs: number,
66
+ ): number {
67
+ let weightedDelta = 0;
68
+ let weightedDt = 0;
69
+ for (let i = 1; i < samples.length; i++) {
70
+ const prev = samples[i - 1]!;
71
+ const cur = samples[i]!;
72
+ const dt = cur.capturedAt - prev.capturedAt;
73
+ if (dt <= 0) continue;
74
+ const midAge = Math.max(0, now - (prev.capturedAt + cur.capturedAt) / 2);
75
+ const weight = Math.pow(0.5, midAge / halfLifeMs);
76
+ weightedDelta += weight * (cur.utilization - prev.utilization);
77
+ weightedDt += weight * dt;
78
+ }
79
+ if (weightedDt <= 0) return 0;
80
+ return Math.max(0, weightedDelta / weightedDt);
81
+ }
82
+
42
83
  function deriveProviderQuotaBurnWindow(input: {
43
84
  provider: string;
44
85
  currentWindow: QuotaWindow;
@@ -82,13 +123,39 @@ function deriveProviderQuotaBurnWindow(input: {
82
123
  const remainingQuota = quotaWindowMetrics(input.currentWindow).remaining;
83
124
  const timeToResetMs = resetAt - input.now;
84
125
  if (remainingQuota <= 0 || timeToResetMs <= 0) return low;
85
- const observedRatePerMs = Math.max(0, (utilizationNow - oldest.utilization) / (current.capturedAt - oldest.capturedAt));
86
- const optimalRatePerMs = remainingQuota / timeToResetMs;
87
- if (observedRatePerMs <= 0 || optimalRatePerMs <= 0) {
126
+
127
+ // #760 (Defect 1) recency-weighted rate so an old burst decays and idle relaxes toward 0,
128
+ // instead of a frozen two-point endpoint slope over the whole span.
129
+ //
130
+ // Idle is invisible in the raw samples: identical utilization snapshots are deduped, so during
131
+ // an idle stretch no new rows arrive and the latest sample stays frozen at the last CHANGE
132
+ // (this is why codex sat at burn×6.3 — a stale burst slope). We make idle observable by
133
+ // appending a synthetic "utilization is still X as of now" point, which adds a recent
134
+ // zero-delta interval [lastSample, now] that the recency weighting lets dominate as it grows.
135
+ const halfLifeMs = WINDOW_NOMINAL_MS[role] * RECENCY_HALFLIFE_FRACTION;
136
+ const rateSamples = input.now > current.capturedAt
137
+ ? [...samples, { capturedAt: input.now, utilization: utilizationNow }]
138
+ : samples;
139
+ const observedRatePerMs = recencyWeightedRatePerMs(rateSamples, input.now, halfLifeMs);
140
+ // #760 (Defect 2) — pace against a configurable target (<100%); slack carries the underburn
141
+ // signal. The query layer (spawn-targets) may re-derive these against a per-provider target;
142
+ // the ingest-time values use the default so direct burn consumers (dashboard) have a number.
143
+ const pace = quotaPaceMetrics({
144
+ utilization: utilizationNow,
145
+ observedRatePerMs,
146
+ timeToResetMs,
147
+ target: DEFAULT_QUOTA_TARGET_UTILIZATION,
148
+ });
149
+ if (observedRatePerMs <= 0) {
150
+ // Idle (or decayed-to-idle): no projected wall, but slack still flags the headroom.
88
151
  return {
89
152
  ...low,
90
153
  confidence: "high",
91
154
  burnRatio: 0,
155
+ observedRatePerMs: 0,
156
+ projectedUtilAtReset: pace.projectedUtilAtReset,
157
+ slack: pace.slack,
158
+ target: pace.target,
92
159
  };
93
160
  }
94
161
  const timeToWallMs = remainingQuota / observedRatePerMs;
@@ -96,10 +163,15 @@ function deriveProviderQuotaBurnWindow(input: {
96
163
  return {
97
164
  ...low,
98
165
  confidence: "high",
99
- burnRatio: observedRatePerMs / optimalRatePerMs,
166
+ burnRatio: pace.burnRatio,
167
+ observedRatePerMs,
100
168
  timeToWallMs,
101
169
  projectedWallAt,
102
- earlyWallMs: Math.max(0, resetAt - projectedWallAt),
170
+ // #760 (Defect 2) symmetric: negative means the wall lands AFTER reset (headroom left).
171
+ earlyWallMs: resetAt - projectedWallAt,
172
+ projectedUtilAtReset: pace.projectedUtilAtReset,
173
+ slack: pace.slack,
174
+ target: pace.target,
103
175
  };
104
176
  }
105
177
 
@@ -7,6 +7,13 @@
7
7
  // prevents rapid flapping from bursty burn rates. Low-confidence windows
8
8
  // (burnRatio: null) are skipped entirely.
9
9
  //
10
+ // #756 — asymmetric hysteresis deadband: recovery (improving) direction
11
+ // requires the penalty to drop HYSTERESIS_MARGIN below the band entry
12
+ // threshold before the transition is recognised, so a penalty parked on a
13
+ // boundary can't ping-pong. Recovery notifications are also rate-limited to
14
+ // at most one per RECOVERY_MIN_INTERVAL_MS — degradation is the actionable
15
+ // signal; fleet-wake on recovery churn is suppressed.
16
+ //
10
17
  // Called from handleProviderQuotaAdvisory in quota-advisory.ts so the check
11
18
  // runs on the same event hook as the utilization-threshold advisory — no
12
19
  // parallel delivery channel.
@@ -14,15 +21,27 @@ import { emitRelayEvent } from "./events";
14
21
  import { routeNotification } from "./notification-router";
15
22
  import { getSpawnCapableAgentIds } from "./routing-policy-push";
16
23
  import { getProviderBandInfo } from "./spawn-targets";
17
- import type { ProviderBandWindow, QuotaBand } from "./spawn-targets";
24
+ import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
18
25
  import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
19
26
 
20
27
  const DEBOUNCE_SAMPLES = 3;
28
+ // Penalty must drop this far below the band entry threshold before recovery is recognised.
29
+ // #760: widened 0.05 → 0.10. Live evidence (Claude 7d parked at avoidLargeAt with burn×1.4)
30
+ // showed the 0.05 deadband was too tight for real penalty jitter — it still ping-ponged
31
+ // avoid-large → caution → avoid-large. Degradation stays faster-to-fire than recovery
32
+ // (recovery alone carries the rate-limit), so the actionable direction is unaffected.
33
+ const HYSTERESIS_MARGIN = 0.1;
34
+ // #760 — slack must fall this far below preferSlackAt before "prefer" is released, so a provider
35
+ // parked near the prefer boundary doesn't ping-pong normal↔prefer.
36
+ const PREFER_HYSTERESIS_MARGIN = 0.05;
37
+ // Minimum ms between recovery notifications — degradation fires immediately; recovery is rate-limited.
38
+ const RECOVERY_MIN_INTERVAL_MS = 5 * 60_000;
21
39
 
22
40
  interface TransitionState {
23
41
  lastNotifiedBand: QuotaBand;
24
42
  pendingBand: QuotaBand | null;
25
43
  pendingCount: number;
44
+ lastTransitionAt: number;
26
45
  }
27
46
 
28
47
  const bandState = new Map<string, TransitionState>();
@@ -31,36 +50,63 @@ export function _resetBandTransitionStateForTesting(): void {
31
50
  bandState.clear();
32
51
  }
33
52
 
53
+ // Test hook: supply a controlled band-info function to exercise hysteresis logic
54
+ // without a full DB setup. Set to null to restore the real implementation.
55
+ let _bandInfoSupplier: ((provider: string, now: number) => ProviderBandInfo | undefined) | null = null;
56
+ export function _setBandInfoSupplierForTesting(fn: (provider: string, now: number) => ProviderBandInfo | undefined): void {
57
+ _bandInfoSupplier = fn;
58
+ }
59
+ export function _clearBandInfoSupplierForTesting(): void {
60
+ _bandInfoSupplier = null;
61
+ }
62
+
34
63
  export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void {
35
- const info = getProviderBandInfo(input.provider, input.now);
64
+ const info = (_bandInfoSupplier ?? getProviderBandInfo)(input.provider, input.now);
36
65
  if (!info) return;
37
66
 
38
67
  for (const window of info.windows) {
39
68
  if (window.confidence === "low" || window.burnRatio === null) continue;
40
69
 
41
70
  const key = `${input.provider}:${window.role}`;
42
- const state: TransitionState = bandState.get(key) ?? { lastNotifiedBand: "normal", pendingBand: null, pendingCount: 0 };
43
- const currentBand = window.band;
44
-
45
- if (currentBand === state.lastNotifiedBand) {
71
+ const state: TransitionState = bandState.get(key) ?? {
72
+ lastNotifiedBand: "normal",
73
+ pendingBand: null,
74
+ pendingCount: 0,
75
+ lastTransitionAt: 0,
76
+ };
77
+
78
+ // #756: asymmetric hysteresis — recovery needs penalty HYSTERESIS_MARGIN below the entry
79
+ // threshold of the current band to prevent ping-pong at a boundary. #760 extends this with a
80
+ // slack deadband for the "prefer" boundary.
81
+ const effectiveBand = applyHysteresis(window.band, window.penalty, window.slack, state.lastNotifiedBand, info.quotaBands, info.preferSlackAt);
82
+
83
+ if (effectiveBand === state.lastNotifiedBand) {
46
84
  if (state.pendingBand !== null) {
47
85
  bandState.set(key, { ...state, pendingBand: null, pendingCount: 0 });
48
86
  }
49
87
  continue;
50
88
  }
51
89
 
52
- if (currentBand === state.pendingBand) {
90
+ if (effectiveBand === state.pendingBand) {
53
91
  state.pendingCount += 1;
54
92
  } else {
55
- state.pendingBand = currentBand;
93
+ state.pendingBand = effectiveBand;
56
94
  state.pendingCount = 1;
57
95
  }
58
96
 
59
97
  if (state.pendingCount >= DEBOUNCE_SAMPLES) {
98
+ // #756: rate-limit recovery — it is not an actionable signal for coordinators.
99
+ const isRecovery = bandRank(effectiveBand) < bandRank(state.lastNotifiedBand);
100
+ if (isRecovery && input.now - state.lastTransitionAt < RECOVERY_MIN_INTERVAL_MS) {
101
+ bandState.set(key, state);
102
+ continue;
103
+ }
104
+
60
105
  const previousBand = state.lastNotifiedBand;
61
- state.lastNotifiedBand = currentBand;
106
+ state.lastNotifiedBand = effectiveBand;
62
107
  state.pendingBand = null;
63
108
  state.pendingCount = 0;
109
+ state.lastTransitionAt = input.now;
64
110
  bandState.set(key, state);
65
111
  emitBandTransition({
66
112
  provider: input.provider,
@@ -75,6 +121,43 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
75
121
  }
76
122
  }
77
123
 
124
+ // Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
125
+ // the penalty to fall HYSTERESIS_MARGIN below the entry threshold of the last notified band.
126
+ // #760: the "prefer" boundary uses a slack deadband instead (penalty is 0 throughout prefer).
127
+ function applyHysteresis(
128
+ rawBand: QuotaBand,
129
+ penalty: number,
130
+ slack: number | null,
131
+ lastNotifiedBand: QuotaBand,
132
+ quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
133
+ preferSlackAt: number,
134
+ ): QuotaBand {
135
+ // #760 — hold "prefer" until slack falls PREFER_HYSTERESIS_MARGIN below the entry threshold;
136
+ // a real over-pace penalty (rawBand worse than normal) exits prefer immediately.
137
+ if (lastNotifiedBand === "prefer") {
138
+ if (rawBand === "prefer") return "prefer";
139
+ if (rawBand === "normal" && slack !== null && slack >= preferSlackAt - PREFER_HYSTERESIS_MARGIN) return "prefer";
140
+ return rawBand;
141
+ }
142
+ // #760 — entering prefer: the slack entry threshold is already applied upstream (window.prefer).
143
+ // Debounce + recovery rate-limit downstream keep prefer from fleet-spamming.
144
+ if (rawBand === "prefer") return "prefer";
145
+ if (bandRank(rawBand) >= bandRank(lastNotifiedBand)) return rawBand;
146
+ const exitThreshold = bandEntryThreshold(lastNotifiedBand, quotaBands) - HYSTERESIS_MARGIN;
147
+ if (penalty >= exitThreshold) return lastNotifiedBand;
148
+ return rawBand;
149
+ }
150
+
151
+ function bandEntryThreshold(
152
+ band: QuotaBand,
153
+ quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
154
+ ): number {
155
+ if (band === "hard-avoid") return quotaBands.hardAvoidAt;
156
+ if (band === "avoid-large") return quotaBands.avoidLargeAt;
157
+ if (band === "caution") return quotaBands.cautionAt;
158
+ return 0;
159
+ }
160
+
78
161
  function emitBandTransition(input: {
79
162
  provider: string;
80
163
  window: ProviderBandWindow;
@@ -95,6 +178,9 @@ function emitBandTransition(input: {
95
178
  utilization: window.utilization,
96
179
  burnRatio: window.burnRatio,
97
180
  projectedWallAt: window.projectedWallAt,
181
+ // #760 — carry the symmetric underburn signal so coordinators see "prefer", not just "avoid".
182
+ slack: window.slack,
183
+ prefer: window.band === "prefer",
98
184
  recommendedAction,
99
185
  };
100
186
 
@@ -134,11 +220,15 @@ function buildBody(input: {
134
220
  const proj = window.projectedWallAt !== null
135
221
  ? ` (wall ~${new Date(window.projectedWallAt).toISOString()})`
136
222
  : "";
137
- return `${providerLabel(provider)} ${window.role} quota band: ${previousBand} ${window.band}. Utilization ${util}%${burn}${proj}. ${recommendedAction}`;
223
+ // #760 for the prefer band there is no early wall; surface the projected unused headroom instead.
224
+ const slackHint = window.band === "prefer" && window.slack !== null ? ` slack ${Math.round(window.slack * 100)}%` : "";
225
+ return `${providerLabel(provider)} ${window.role} quota band: ${previousBand} → ${window.band}. Utilization ${util}%${burn}${proj}${slackHint}. ${recommendedAction}`;
138
226
  }
139
227
 
140
228
  function bandRank(band: QuotaBand): number {
141
- const ranks: Record<QuotaBand, number> = { "normal": 0, "caution": 1, "avoid-large": 2, "hard-avoid": 3 };
229
+ // #760 "prefer" is the best state, ranked below "normal", so normal→prefer reads as a
230
+ // recovery (rate-limited; no fleet-spam) and prefer→normal reads as a degradation.
231
+ const ranks: Record<QuotaBand, number> = { "prefer": 0, "normal": 1, "caution": 2, "avoid-large": 3, "hard-avoid": 4 };
142
232
  return ranks[band];
143
233
  }
144
234
 
@@ -234,6 +234,29 @@ const DISPLAY_PREFS_SCHEMA: JSONSchema = {
234
234
  },
235
235
  };
236
236
 
237
+ // Per-conversation chat scroll memory (#759), tier:"user" so a human's reading position +
238
+ // sticky-bottom intent follow them across devices instead of dying in per-browser module state.
239
+ // The value is a MAP keyed by conversation/agent id — one user-settings row holds every
240
+ // conversation's anchor, so this is ONE descriptor (not a per-agent descriptor explosion).
241
+ // Each entry is a LOGICAL anchor, never a pixel scrollTop: `topKey` is the timeline entry key
242
+ // (`msg-<id>` — a server-stable id), `offset` the within-row pixel offset, `stick` the
243
+ // follow-the-bottom intent. `maxProperties` bounds unbounded conversation growth; the dashboard
244
+ // store evicts oldest-touched before it can be hit.
245
+ const CHAT_SCROLL_SCHEMA: JSONSchema = {
246
+ type: "object",
247
+ maxProperties: 200,
248
+ additionalProperties: {
249
+ type: "object",
250
+ additionalProperties: false,
251
+ required: ["stick", "topKey", "offset"],
252
+ properties: {
253
+ stick: { type: "boolean" },
254
+ topKey: { type: ["string", "null"], maxLength: 200 },
255
+ offset: { type: "number", minimum: 0, maximum: 10_000_000 },
256
+ },
257
+ },
258
+ };
259
+
237
260
  const VOICE_PREFS_SCHEMA: JSONSchema = {
238
261
  type: "object",
239
262
  additionalProperties: false,
@@ -404,6 +427,17 @@ function seedBuiltInSettings(): void {
404
427
  display: { group: "Preferences", label: "Display", description: "Per-user theme and reasoning visibility (follows you across devices).", order: 70 },
405
428
  });
406
429
 
430
+ // Per-conversation chat scroll memory (#759) — one row per user, keyed internally by agent id.
431
+ registerSetting({
432
+ namespace: "chat",
433
+ key: "scroll",
434
+ schema: CHAT_SCROLL_SCHEMA,
435
+ defaults: {},
436
+ scope: { read: "settings:read:chat", write: "settings:write:chat" },
437
+ tier: "user",
438
+ display: { group: "Preferences", label: "Chat scroll", description: "Per-conversation scroll position and sticky-bottom flag (follows you across devices).", order: 72 },
439
+ });
440
+
407
441
  registerSetting({
408
442
  namespace: "voice",
409
443
  key: "default",
@@ -4,6 +4,7 @@ import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
4
  import { getRoutingPolicy } from "./routing-policy-store";
5
5
  import { isPathWithinBase } from "./utils";
6
6
  import { McpNotFoundError } from "./mcp-errors";
7
+ import { DEFAULT_QUOTA_PREFER_SLACK_AT, DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics } from "agent-relay-sdk/provider-quota";
7
8
  import type { ProviderQuotaRoutingConfig } from "agent-relay-sdk";
8
9
  import type { Orchestrator, ProviderCatalogSummary, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaRecord, QuotaState, SpawnProvider } from "./types";
9
10
 
@@ -64,7 +65,8 @@ interface SpawnCatalogish {
64
65
  models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean; unavailable?: string }>;
65
66
  }
66
67
 
67
- type SpawnQuotaBurnBand = "normal" | "caution" | "avoid-large" | "hard-avoid";
68
+ // #760 "prefer" is the underburn counterpart to the over-pace penalty ladder.
69
+ type SpawnQuotaBurnBand = "prefer" | "normal" | "caution" | "avoid-large" | "hard-avoid";
68
70
 
69
71
  interface SpawnQuotaBurnWindow {
70
72
  role: ProviderQuotaBurnRole;
@@ -79,6 +81,11 @@ interface SpawnQuotaBurnWindow {
79
81
  projectedWallAt: number | null;
80
82
  earlyWallMs: number | null;
81
83
  earlyFraction: number | null;
84
+ // #760 — symmetric underburn signal (see ProviderQuotaBurnWindow.slack), re-derived against
85
+ // the per-provider target, plus the "prefer" flag for this window.
86
+ projectedUtilAtReset: number | null;
87
+ slack: number | null;
88
+ prefer: boolean;
82
89
  weight: 1 | 3;
83
90
  penalty: number;
84
91
  blockedNow: boolean;
@@ -88,6 +95,9 @@ interface SpawnQuotaAdvisory {
88
95
  quotaBurn: { windows: SpawnQuotaBurnWindow[] };
89
96
  quotaPenalty: number;
90
97
  quotaPenaltyBand: SpawnQuotaBurnBand;
98
+ // #760 — provider underburn: every selected window projects ≥ preferSlackAt headroom at reset.
99
+ prefer: boolean;
100
+ slack: number | null;
91
101
  blockedNow: boolean;
92
102
  recommendedAction: string;
93
103
  }
@@ -96,6 +106,8 @@ const DEFAULT_QUOTA_ADVISORY: SpawnQuotaAdvisory = {
96
106
  quotaBurn: { windows: [] },
97
107
  quotaPenalty: 0,
98
108
  quotaPenaltyBand: "normal",
109
+ prefer: false,
110
+ slack: null,
99
111
  blockedNow: false,
100
112
  recommendedAction: "No quota burn projection available; spawn normally.",
101
113
  };
@@ -173,13 +185,23 @@ function quotaAdvisoryFor(
173
185
  const windows = [...selected.values()].sort((a, b) => roleSort(a.role) - roleSort(b.role));
174
186
  const quotaPenalty = Math.max(0, ...windows.map((window) => window.penalty));
175
187
  const blockedNow = windows.some((window) => window.blockedNow);
176
- const quotaPenaltyBand = quotaPenaltyBandFor(quotaPenalty, quotaBands);
188
+ const penaltyBand = quotaPenaltyBandFor(quotaPenalty, quotaBands);
189
+ // #760 — provider-level "prefer": no over-pace penalty AND every selected window is projected
190
+ // to land ≥ preferSlackAt below target. The binding constraint is the LEAST-slack window, so a
191
+ // tight 5h window vetoes prefer even if 7d is wide open.
192
+ const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
193
+ const slacks = windows.map((window) => window.slack).filter((slack): slack is number => slack !== null);
194
+ const minSlack = slacks.length === windows.length && slacks.length > 0 ? Math.min(...slacks) : null;
195
+ const prefer = !blockedNow && penaltyBand === "normal" && minSlack !== null && minSlack >= preferSlackAt;
196
+ const quotaPenaltyBand: SpawnQuotaBurnBand = prefer ? "prefer" : penaltyBand;
177
197
  return {
178
198
  quotaBurn: { windows },
179
199
  quotaPenalty,
180
200
  quotaPenaltyBand,
201
+ prefer,
202
+ slack: minSlack,
181
203
  blockedNow,
182
- recommendedAction: recommendedAction({ quotaPenaltyBand, blockedNow, windows }),
204
+ recommendedAction: recommendedAction({ quotaPenaltyBand, blockedNow, prefer, slack: minSlack, windows }),
183
205
  };
184
206
  }
185
207
 
@@ -199,14 +221,37 @@ function spawnQuotaWindow(
199
221
  ): SpawnQuotaBurnWindow | undefined {
200
222
  if (!quotaWindow) return undefined;
201
223
  const timeToResetMs = typeof quotaWindow.resetsAt === "number" ? Math.max(0, quotaWindow.resetsAt - now) : null;
224
+ // earlyWallMs may be negative (#760 symmetric); the over-pace penalty only sees the positive half.
202
225
  const earlyFraction = burnWindow.earlyWallMs !== null && timeToResetMs && timeToResetMs > 0
203
226
  ? Math.max(0, burnWindow.earlyWallMs) / timeToResetMs
204
227
  : null;
205
228
  const weight = burnWindow.role === "7d" ? 3 : 1;
206
229
  const hardBackstop = providerCfg?.hardBackstopUtilization ?? 0.99;
207
- const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0.5;
230
+ // #760 floor default is now 0 (off). The projection/pace gate (earlyFraction≈0 when the
231
+ // recency-weighted rate projects no early wall) replaces the blunt utilization floor, which
232
+ // used to hide a legitimate low-util-high-burst alarm. Operators may still opt back in.
233
+ const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0;
208
234
  const rawPenalty = (earlyFraction ?? 0) * weight;
209
235
  const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
236
+
237
+ // #760 — re-derive the pace projection against the PER-PROVIDER target (the stored burn used
238
+ // the default). Slack is the symmetric underburn signal; prefer flags a window with no
239
+ // over-pace penalty and ≥ preferSlackAt projected headroom at reset.
240
+ const target = providerCfg?.targetUtilization ?? DEFAULT_QUOTA_TARGET_UTILIZATION;
241
+ const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
242
+ const blockedNow = quotaWindow.utilization >= hardBackstop;
243
+ let burnRatio = burnWindow.burnRatio;
244
+ let projectedUtilAtReset = burnWindow.projectedUtilAtReset;
245
+ let slack = burnWindow.slack;
246
+ if (burnWindow.observedRatePerMs !== null && timeToResetMs !== null && timeToResetMs > 0) {
247
+ const pace = quotaPaceMetrics({ utilization: quotaWindow.utilization, observedRatePerMs: burnWindow.observedRatePerMs, timeToResetMs, target });
248
+ burnRatio = pace.burnRatio;
249
+ projectedUtilAtReset = pace.projectedUtilAtReset;
250
+ slack = pace.slack;
251
+ }
252
+ const prefer = !blockedNow && penalty === 0
253
+ && quotaPenaltyBandFor(penalty) === "normal"
254
+ && slack !== null && slack >= preferSlackAt;
210
255
  return {
211
256
  role: burnWindow.role,
212
257
  name: burnWindow.name,
@@ -215,27 +260,34 @@ function spawnQuotaWindow(
215
260
  resetsAt: quotaWindow.resetsAt ?? null,
216
261
  timeToResetMs,
217
262
  confidence: burnWindow.confidence,
218
- burnRatio: burnWindow.burnRatio,
263
+ burnRatio,
219
264
  timeToWallMs: burnWindow.timeToWallMs,
220
265
  projectedWallAt: burnWindow.projectedWallAt,
221
266
  earlyWallMs: burnWindow.earlyWallMs,
222
267
  earlyFraction,
268
+ projectedUtilAtReset,
269
+ slack,
270
+ prefer,
223
271
  weight,
224
272
  penalty,
225
- blockedNow: quotaWindow.utilization >= hardBackstop,
273
+ blockedNow,
226
274
  };
227
275
  }
228
276
 
229
277
  // === Public band-info API for quota-band-transition (#738) ===
230
278
 
231
- export type QuotaBand = "normal" | "caution" | "avoid-large" | "hard-avoid";
279
+ // #760 "prefer" ranks below "normal" (it is the BEST state): see bandRank in quota-band-transition.
280
+ export type QuotaBand = "prefer" | "normal" | "caution" | "avoid-large" | "hard-avoid";
232
281
 
233
282
  export interface ProviderBandWindow {
234
283
  role: ProviderQuotaBurnRole;
235
284
  band: QuotaBand;
285
+ penalty: number;
236
286
  utilization: number;
237
287
  burnRatio: number | null;
238
288
  projectedWallAt: number | null;
289
+ // #760 — symmetric underburn signal; the band-transition deadband uses it for the prefer boundary.
290
+ slack: number | null;
239
291
  confidence: "high" | "low";
240
292
  }
241
293
 
@@ -243,6 +295,9 @@ export interface ProviderBandInfo {
243
295
  windows: ProviderBandWindow[];
244
296
  providerBand: QuotaBand;
245
297
  recommendedAction: string;
298
+ quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number };
299
+ // #760 — slack threshold at which a window enters "prefer"; the deadband holds it preferMargin below.
300
+ preferSlackAt: number;
246
301
  }
247
302
 
248
303
  /**
@@ -255,15 +310,24 @@ export function getProviderBandInfo(provider: string, now: number): ProviderBand
255
310
  const records = listProviderQuotas({ provider, historyLimit: 0, now }).quotas;
256
311
  if (records.length === 0) return undefined;
257
312
  const advisory = quotaAdvisoryFor(records, now, policy.quotaBands, policy.providerQuota?.[provider as SpawnProvider]);
313
+ const quotaBands = {
314
+ cautionAt: policy.quotaBands?.cautionAt ?? 0.15,
315
+ avoidLargeAt: policy.quotaBands?.avoidLargeAt ?? 0.5,
316
+ hardAvoidAt: policy.quotaBands?.hardAvoidAt ?? 1.0,
317
+ };
258
318
  const windows: ProviderBandWindow[] = advisory.quotaBurn.windows.map((w) => ({
259
319
  role: w.role,
260
- band: quotaPenaltyBandFor(w.penalty, policy.quotaBands),
320
+ // #760 — per-window prefer overlay: w.prefer already encodes "no penalty + ≥ preferSlackAt slack".
321
+ band: w.prefer ? "prefer" : quotaPenaltyBandFor(w.penalty, policy.quotaBands),
322
+ penalty: w.penalty,
261
323
  utilization: w.utilization,
262
324
  burnRatio: w.burnRatio,
263
325
  projectedWallAt: w.projectedWallAt,
326
+ slack: w.slack,
264
327
  confidence: w.confidence,
265
328
  }));
266
- return { windows, providerBand: advisory.quotaPenaltyBand, recommendedAction: advisory.recommendedAction };
329
+ const preferSlackAt = policy.providerQuota?.[provider as SpawnProvider]?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
330
+ return { windows, providerBand: advisory.quotaPenaltyBand, recommendedAction: advisory.recommendedAction, quotaBands, preferSlackAt };
267
331
  }
268
332
 
269
333
  function compareQuotaWindows(a: SpawnQuotaBurnWindow, b: SpawnQuotaBurnWindow): number {
@@ -285,7 +349,7 @@ function quotaPenaltyBandFor(
285
349
  return "normal";
286
350
  }
287
351
 
288
- function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; windows: SpawnQuotaBurnWindow[] }): string {
352
+ function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; prefer: boolean; slack: number | null; windows: SpawnQuotaBurnWindow[] }): string {
289
353
  if (input.blockedNow) {
290
354
  const hasSevenDayBlock = input.windows.some((window) => window.role === "7d" && window.blockedNow);
291
355
  return hasSevenDayBlock
@@ -295,6 +359,11 @@ function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blocke
295
359
  if (input.quotaPenaltyBand === "hard-avoid") return "Hard avoid unless explicitly requested or no healthier alternate exists.";
296
360
  if (input.quotaPenaltyBand === "avoid-large") return "Avoid large jobs if a healthier alternate exists.";
297
361
  if (input.quotaPenaltyBand === "caution") return "Caution: prefer cheaper effort/model when practical.";
362
+ // #760 — surface the underburn signal so coordinators load-shift big/deferrable jobs here.
363
+ if (input.quotaPenaltyBand === "prefer") {
364
+ const headroom = input.slack !== null ? ` (~${Math.round(input.slack * 100)}% quota would otherwise go unused at reset)` : "";
365
+ return `Prefer for large or deferrable jobs${headroom}.`;
366
+ }
298
367
  return "Spawn normally.";
299
368
  }
300
369