agent-relay-server 0.123.2 → 0.124.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 (68) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-NCggGKTA.js → MessageBubble-SQK8-Mzp.js} +2 -2
  4. package/public/assets/{MessageBubble-NCggGKTA.js.map → MessageBubble-SQK8-Mzp.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CSdszmGk.js → PlanGraphPanel-CS-aow4S.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CSdszmGk.js.map → PlanGraphPanel-CS-aow4S.js.map} +1 -1
  7. package/public/assets/{activity-Cyp2nolX.js → activity-W31XvhC2.js} +2 -2
  8. package/public/assets/{activity-Cyp2nolX.js.map → activity-W31XvhC2.js.map} +1 -1
  9. package/public/assets/{agent-profiles-D5Izg0Vn.js → agent-profiles-DXysA9To.js} +2 -2
  10. package/public/assets/{agent-profiles-D5Izg0Vn.js.map → agent-profiles-DXysA9To.js.map} +1 -1
  11. package/public/assets/{agents-DIOxuJ6M.js → agents-B5GRNRnN.js} +2 -2
  12. package/public/assets/{agents-DIOxuJ6M.js.map → agents-B5GRNRnN.js.map} +1 -1
  13. package/public/assets/{automation-Bn6mOLAR.js → automation-DYlgHYcb.js} +2 -2
  14. package/public/assets/{automation-Bn6mOLAR.js.map → automation-DYlgHYcb.js.map} +1 -1
  15. package/public/assets/{chat-eUaPz399.js → chat-Clz6qOtn.js} +2 -2
  16. package/public/assets/{chat-eUaPz399.js.map → chat-Clz6qOtn.js.map} +1 -1
  17. package/public/assets/{formatted-body-CvrQ2D1J.js → formatted-body-BYQ-SPtj.js} +3 -3
  18. package/public/assets/{formatted-body-CvrQ2D1J.js.map → formatted-body-BYQ-SPtj.js.map} +1 -1
  19. package/public/assets/{formatted-body-impl-BJ7jdbpR.js → formatted-body-impl-L1EMnNW6.js} +2 -2
  20. package/public/assets/{formatted-body-impl-BJ7jdbpR.js.map → formatted-body-impl-L1EMnNW6.js.map} +1 -1
  21. package/public/assets/{index-DnUo0Zqg.js → index-0uIGXT14.js} +6 -6
  22. package/public/assets/{index-DnUo0Zqg.js.map → index-0uIGXT14.js.map} +1 -1
  23. package/public/assets/index-DOJ7KidA.css +2 -0
  24. package/public/assets/{insights-CQGH5LhD.js → insights-DwZxq1Dn.js} +2 -2
  25. package/public/assets/{insights-CQGH5LhD.js.map → insights-DwZxq1Dn.js.map} +1 -1
  26. package/public/assets/{maintenance-7c7UvpAt.js → maintenance-COcmUDnf.js} +2 -2
  27. package/public/assets/{maintenance-7c7UvpAt.js.map → maintenance-COcmUDnf.js.map} +1 -1
  28. package/public/assets/{managed-agents-yh4HPO5a.js → managed-agents-D9-dgMeX.js} +2 -2
  29. package/public/assets/{managed-agents-yh4HPO5a.js.map → managed-agents-D9-dgMeX.js.map} +1 -1
  30. package/public/assets/{markdown-preview-impl-BuOn5-Uc.js → markdown-preview-impl-DH372bL-.js} +2 -2
  31. package/public/assets/{markdown-preview-impl-BuOn5-Uc.js.map → markdown-preview-impl-DH372bL-.js.map} +1 -1
  32. package/public/assets/{memory-Qv0JN8CE.js → memory-B9hRK8sk.js} +2 -2
  33. package/public/assets/{memory-Qv0JN8CE.js.map → memory-B9hRK8sk.js.map} +1 -1
  34. package/public/assets/{messages-CgjHJmc2.js → messages-BHkHlXzF.js} +2 -2
  35. package/public/assets/{messages-CgjHJmc2.js.map → messages-BHkHlXzF.js.map} +1 -1
  36. package/public/assets/{orchestrators-CgL4CFzb.js → orchestrators-CXy3SDSn.js} +2 -2
  37. package/public/assets/{orchestrators-CgL4CFzb.js.map → orchestrators-CXy3SDSn.js.map} +1 -1
  38. package/public/assets/{overview-Bri04eYE.js → overview-D2yRyCQq.js} +2 -2
  39. package/public/assets/{overview-Bri04eYE.js.map → overview-D2yRyCQq.js.map} +1 -1
  40. package/public/assets/{pairs-C78LwUH2.js → pairs-5tXdHylI.js} +2 -2
  41. package/public/assets/{pairs-C78LwUH2.js.map → pairs-5tXdHylI.js.map} +1 -1
  42. package/public/assets/{prompt-settings-DDEkMqPZ.js → prompt-settings-Bn5mmi9w.js} +2 -2
  43. package/public/assets/{prompt-settings-DDEkMqPZ.js.map → prompt-settings-Bn5mmi9w.js.map} +1 -1
  44. package/public/assets/registry-C3sB7qv5.js +4 -0
  45. package/public/assets/registry-C3sB7qv5.js.map +1 -0
  46. package/public/assets/{security-wvnRKFvm.js → security-CmD-YE-_.js} +2 -2
  47. package/public/assets/{security-wvnRKFvm.js.map → security-CmD-YE-_.js.map} +1 -1
  48. package/public/assets/{select-BIfbVmLf.js → select-C0UlhK2k.js} +2 -2
  49. package/public/assets/{select-BIfbVmLf.js.map → select-C0UlhK2k.js.map} +1 -1
  50. package/public/assets/{settings-B6Ntahx7.js → settings-DrqMG5nG.js} +2 -2
  51. package/public/assets/{settings-B6Ntahx7.js.map → settings-DrqMG5nG.js.map} +1 -1
  52. package/public/assets/{tasks-CUglafo5.js → tasks-ChQjtXHu.js} +2 -2
  53. package/public/assets/{tasks-CUglafo5.js.map → tasks-ChQjtXHu.js.map} +1 -1
  54. package/public/assets/{teams-EE8RHhHM.js → teams-itjNlDLp.js} +2 -2
  55. package/public/assets/{teams-EE8RHhHM.js.map → teams-itjNlDLp.js.map} +1 -1
  56. package/public/assets/{terminal-viewer-impl-DBRkdyag.js → terminal-viewer-impl-B9M6s2vB.js} +2 -2
  57. package/public/assets/{terminal-viewer-impl-DBRkdyag.js.map → terminal-viewer-impl-B9M6s2vB.js.map} +1 -1
  58. package/public/assets/{work-queue-BFQVVL4T.js → work-queue-Kw0uMkEF.js} +2 -2
  59. package/public/assets/{work-queue-BFQVVL4T.js.map → work-queue-Kw0uMkEF.js.map} +1 -1
  60. package/public/index.html +3 -3
  61. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  62. package/src/db/provisioning-registry.ts +174 -1
  63. package/src/routes/provisioning-registry.ts +3 -0
  64. package/src/routing-policy-store.ts +55 -0
  65. package/src/spawn-targets.ts +130 -28
  66. package/public/assets/index-DcmG5gLR.css +0 -2
  67. package/public/assets/registry-5KhP5FE5.js +0 -2
  68. package/public/assets/registry-5KhP5FE5.js.map +0 -1
@@ -6,8 +6,23 @@ import { renderTemplate } from "./prompt-resolver";
6
6
  import { getRoutingPolicy } from "./routing-policy-store";
7
7
  import { isPathWithinBase } from "./utils";
8
8
  import { McpNotFoundError } from "./mcp-errors";
9
- import { DEFAULT_QUOTA_PREFER_SLACK_AT, DEFAULT_QUOTA_TARGET_UTILIZATION, quotaOvershootPenalty, quotaPaceMetrics } from "agent-relay-sdk/provider-quota";
10
- import { isSpawnProvider, type ProviderQuotaRoutingConfig } from "agent-relay-sdk";
9
+ import {
10
+ DEFAULT_QUOTA_PACE_DELTA,
11
+ DEFAULT_QUOTA_PACE_THRESHOLDS,
12
+ DEFAULT_QUOTA_PREFER_SLACK_AT,
13
+ DEFAULT_QUOTA_TARGET_UTILIZATION,
14
+ formatPaceLabel,
15
+ QUOTA_PACE_WINDOW_LENGTH_MS,
16
+ quotaOvershootPenalty,
17
+ quotaPaceBandForWeight,
18
+ quotaPaceCombinedBand,
19
+ quotaPaceMetrics,
20
+ quotaPaceWindowMetrics,
21
+ type QuotaPaceBand,
22
+ type QuotaPaceRoleSignal,
23
+ type QuotaPaceThresholds,
24
+ } from "agent-relay-sdk/provider-quota";
25
+ import { isSpawnProvider, type ProviderQuotaRoutingConfig, type QuotaPacingConfig, type RoutingPolicy } from "agent-relay-sdk";
11
26
  import type { Orchestrator, ProviderCatalogSummary, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaRecord, QuotaState, SpawnProvider } from "./types";
12
27
 
13
28
  // #308 — spawn discovery + target selection. Home for the live capability matrix that
@@ -83,9 +98,17 @@ interface SpawnQuotaBurnWindow {
83
98
  projectedWallAt: number | null;
84
99
  earlyWallMs: number | null;
85
100
  // #760 — symmetric underburn signal (see ProviderQuotaBurnWindow.slack), re-derived against
86
- // the per-provider target, plus the "prefer" flag for this window.
101
+ // the per-provider target. #1098: demoted to a tiebreaker/secondary signal — still surfaced
102
+ // (and still the input to the "prefer" overlay below) but no longer the primary band driver.
87
103
  projectedUtilAtReset: number | null;
88
104
  slack: number | null;
105
+ // #1098 — the pace-error control signal: present position vs. the ideal linear pace line,
106
+ // and the legibility label ("+9% ahead of pace" beats "projected 115%"). null when timeToReset
107
+ // is unknown (no reset boundary to pace against).
108
+ paceTarget: number | null;
109
+ paceError: number | null;
110
+ paceWeight: number | null;
111
+ pace: string | null;
89
112
  prefer: boolean;
90
113
  penalty: number;
91
114
  blockedNow: boolean;
@@ -112,6 +135,32 @@ const DEFAULT_QUOTA_ADVISORY: SpawnQuotaAdvisory = {
112
135
  recommendedAction: "No quota burn projection available; spawn normally.",
113
136
  };
114
137
 
138
+ // #1098/#1105 — resolved pacing tunables: delta/thresholds/windowLengthMs are GLOBAL (one dial per
139
+ // relay instance, not per-provider — matches the design's "δ a tunable constant"), sourced from
140
+ // RoutingPolicy.quotaPacing with the provider-quota.ts constants as fallback defaults, so an
141
+ // unconfigured instance reproduces the shipped behaviour exactly.
142
+ interface ResolvedQuotaPacing {
143
+ delta: number;
144
+ thresholds: QuotaPaceThresholds;
145
+ windowLengthMs: Record<ProviderQuotaBurnRole, number>;
146
+ }
147
+
148
+ function resolveQuotaPacing(policy: RoutingPolicy): ResolvedQuotaPacing {
149
+ const cfg: QuotaPacingConfig = policy.quotaPacing ?? {};
150
+ return {
151
+ delta: cfg.delta ?? DEFAULT_QUOTA_PACE_DELTA,
152
+ thresholds: {
153
+ preferAt: cfg.thresholds?.preferAt ?? DEFAULT_QUOTA_PACE_THRESHOLDS.preferAt,
154
+ normalAt: cfg.thresholds?.normalAt ?? DEFAULT_QUOTA_PACE_THRESHOLDS.normalAt,
155
+ cautionAt: cfg.thresholds?.cautionAt ?? DEFAULT_QUOTA_PACE_THRESHOLDS.cautionAt,
156
+ },
157
+ windowLengthMs: {
158
+ "5h": cfg.windowLengthMs?.["5h"] ?? QUOTA_PACE_WINDOW_LENGTH_MS["5h"],
159
+ "7d": cfg.windowLengthMs?.["7d"] ?? QUOTA_PACE_WINDOW_LENGTH_MS["7d"],
160
+ },
161
+ };
162
+ }
163
+
115
164
  // #926 — the orchestrator's REPORTED catalog is built from the static SDK manifest only
116
165
  // (orchestrator/src/provider-probe.ts uses providerCatalogList(), which has no DB access), while
117
166
  // spawn VALIDATION resolves against effectiveProviderCatalogList() (static catalog + DB
@@ -189,21 +238,22 @@ export function providerQuotaAdvisories(now = Date.now()): Map<SpawnProvider, Sp
189
238
  const provider = record.provider;
190
239
  byProvider.set(provider, [...(byProvider.get(provider) ?? []), record]);
191
240
  }
241
+ const pacing = resolveQuotaPacing(policy);
192
242
  return new Map([...byProvider.entries()].map(([provider, records]) => [
193
243
  provider,
194
- quotaAdvisoryFor(records, now, policy.quotaBands, policy.providerQuota?.[provider]),
244
+ quotaAdvisoryFor(records, now, pacing, policy.providerQuota?.[provider]),
195
245
  ]));
196
246
  }
197
247
 
198
248
  function quotaAdvisoryFor(
199
249
  records: ProviderQuotaRecord[],
200
250
  now: number,
201
- quotaBands?: Partial<{ cautionAt: number; avoidLargeAt: number; hardAvoidAt: number }>,
251
+ pacing: ResolvedQuotaPacing,
202
252
  providerCfg?: ProviderQuotaRoutingConfig,
203
253
  ): SpawnQuotaAdvisory {
204
254
  const selected = new Map<ProviderQuotaBurnRole, SpawnQuotaBurnWindow>();
205
255
  for (const record of records) {
206
- for (const window of quotaWindowsFor(record, now, providerCfg)) {
256
+ for (const window of quotaWindowsFor(record, now, providerCfg, pacing)) {
207
257
  const existing = selected.get(window.role);
208
258
  if (!existing || compareQuotaWindows(window, existing) > 0) selected.set(window.role, window);
209
259
  }
@@ -211,15 +261,28 @@ function quotaAdvisoryFor(
211
261
  const windows = [...selected.values()].sort((a, b) => roleSort(a.role) - roleSort(b.role));
212
262
  const quotaPenalty = Math.max(0, ...windows.map((window) => window.penalty));
213
263
  const blockedNow = windows.some((window) => window.blockedNow);
214
- const penaltyBand = quotaPenaltyBandFor(quotaPenalty, quotaBands);
215
- // #760 provider-level "prefer": no over-pace penalty AND every selected window is projected
216
- // to land preferSlackAt below target. The binding constraint is the LEAST-slack window, so a
217
- // tight 5h window vetoes prefer even if 7d is wide open.
264
+ // #1098 the provider-level band: the MORE-BINDING window's pace weight drives the ladder;
265
+ // hard-avoid additionally requires the binding window's paceError beyond δ AND no other window
266
+ // able to absorb the slack (or blockedNow). Replaces the old max-penalty-then-threshold cliff.
267
+ const signals: QuotaPaceRoleSignal[] = windows
268
+ .filter((window): window is SpawnQuotaBurnWindow & { paceWeight: number; paceError: number } =>
269
+ window.paceWeight !== null && window.paceError !== null)
270
+ .map((window) => ({ role: window.role, paceError: window.paceError, weight: window.paceWeight, blockedNow: window.blockedNow }));
271
+ const { band: paceBand } = quotaPaceCombinedBand(signals, pacing.delta, pacing.thresholds);
272
+ // #760 — provider-level "prefer" tiebreaker: every selected window is projected (the #1072
273
+ // slow-EWMA trend, demoted to secondary per #1098) to land ≥ preferSlackAt below target — e.g. a
274
+ // just-reset window whose RAW utilization reads mildly "ahead" of a near-zero pace line (paceBand
275
+ // normal/caution) but whose sustained trend shows it is nowhere near the target. The binding
276
+ // constraint is the LEAST-slack window, so a tight 5h window vetoes prefer even if 7d is wide
277
+ // open. Reserved for normal/caution — avoid-large/hard-avoid are real over-pace signals that a
278
+ // secondary projection should not paper over.
218
279
  const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
219
280
  const slacks = windows.map((window) => window.slack).filter((slack): slack is number => slack !== null);
220
281
  const minSlack = slacks.length === windows.length && slacks.length > 0 ? Math.min(...slacks) : null;
221
- const prefer = !blockedNow && penaltyBand === "normal" && minSlack !== null && minSlack >= preferSlackAt;
222
- const quotaPenaltyBand: SpawnQuotaBurnBand = prefer ? "prefer" : penaltyBand;
282
+ const prefer = !blockedNow
283
+ && (paceBand === "prefer"
284
+ || ((paceBand === "normal" || paceBand === "caution") && minSlack !== null && minSlack >= preferSlackAt));
285
+ const quotaPenaltyBand: SpawnQuotaBurnBand = prefer ? "prefer" : paceBand;
223
286
  return {
224
287
  quotaBurn: { windows },
225
288
  quotaPenalty,
@@ -254,10 +317,15 @@ export function providerQuotaBands(now = Date.now()): ProviderQuotaBand[] {
254
317
  }));
255
318
  }
256
319
 
257
- function quotaWindowsFor(record: ProviderQuotaRecord, now: number, providerCfg?: ProviderQuotaRoutingConfig): SpawnQuotaBurnWindow[] {
320
+ function quotaWindowsFor(
321
+ record: ProviderQuotaRecord,
322
+ now: number,
323
+ providerCfg: ProviderQuotaRoutingConfig | undefined,
324
+ pacing: ResolvedQuotaPacing,
325
+ ): SpawnQuotaBurnWindow[] {
258
326
  const quotaByName = new Map((record.quota?.windows ?? []).map((window) => [window.name, window]));
259
327
  return (record.burn?.windows ?? [])
260
- .map((burnWindow) => spawnQuotaWindow(record, burnWindow, quotaByName.get(burnWindow.name), now, providerCfg))
328
+ .map((burnWindow) => spawnQuotaWindow(record, burnWindow, quotaByName.get(burnWindow.name), now, providerCfg, pacing))
261
329
  .filter((window): window is SpawnQuotaBurnWindow => Boolean(window));
262
330
  }
263
331
 
@@ -266,7 +334,8 @@ function spawnQuotaWindow(
266
334
  burnWindow: ProviderQuotaBurnWindow,
267
335
  quotaWindow: QuotaState["windows"][number] | undefined,
268
336
  now: number,
269
- providerCfg?: ProviderQuotaRoutingConfig,
337
+ providerCfg: ProviderQuotaRoutingConfig | undefined,
338
+ pacing: ResolvedQuotaPacing,
270
339
  ): SpawnQuotaBurnWindow | undefined {
271
340
  if (!quotaWindow) return undefined;
272
341
  const timeToResetMs = typeof quotaWindow.resetsAt === "number" ? Math.max(0, quotaWindow.resetsAt - now) : null;
@@ -288,20 +357,36 @@ function spawnQuotaWindow(
288
357
  burnRatio = quotaPaceMetrics({ utilization: quotaWindow.utilization, observedRatePerMs: burnWindow.observedRatePerMs, timeToResetMs, target }).burnRatio;
289
358
  }
290
359
 
291
- // #1072 — ONE unified overshoot penalty (replaces #760 earlyFraction, the #1071 7d ×3 weight,
292
- // and the #798 idle position penalty). The measured current overshoot counts at full weight; the
293
- // projected trend overshoot is scaled by the continuous confidence, so thin evidence cannot
294
- // hard-escalate on an extrapolated slope. Idle and active share this single formula — an idle
295
- // over-target provider is held by the current-overshoot term (confidence-independent, so the
296
- // band is flat tick-to-tick), an active climb escalates as the confidence-weighted trend crosses.
360
+ // #1072 (kept, demoted to secondary) the unified overshoot penalty/slack against the
361
+ // per-provider target. #1098 no longer uses `penalty` to drive the band, but it's still surfaced
362
+ // (informational) and `slack` still feeds the "prefer" tiebreaker below.
297
363
  const confidence = burnWindow.confidenceScore ?? 0;
298
364
  const { slack, penalty: rawPenalty } = projectedUtilAtReset !== null
299
365
  ? quotaOvershootPenalty(projectedUtilAtReset, quotaWindow.utilization, target, confidence)
300
366
  : { slack: burnWindow.slack, penalty: 0 };
301
367
  const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
302
- const prefer = !blockedNow && penalty === 0
303
- && quotaPenaltyBandFor(penalty) === "normal"
304
- && slack !== null && slack >= preferSlackAt;
368
+
369
+ // #1098 — the pace-error control signal: present position (utilization) vs. the ideal linear
370
+ // pace line (elapsed/windowLength), NOT a forecast. Drives the band; see quotaAdvisoryFor for
371
+ // the cross-window (5h+7d, more-binding-wins) combination and the hard-avoid escalation gate.
372
+ const paceMetrics = timeToResetMs !== null
373
+ ? quotaPaceWindowMetrics({
374
+ utilization: quotaWindow.utilization,
375
+ timeToResetMs,
376
+ windowLengthMs: pacing.windowLengthMs[burnWindow.role],
377
+ delta: pacing.delta,
378
+ })
379
+ : null;
380
+ const windowBand: QuotaPaceBand | null = paceMetrics ? quotaPaceBandForWeight(paceMetrics.weight, pacing.thresholds) : null;
381
+ // #1098 — per-window "prefer": either the weight ladder itself climbed past neutral into active
382
+ // promotion (use-it-or-lose-it, near reset with util below pace), or the window carries ample
383
+ // future slack per the secondary/tiebreaker projection (the #1072 target-buffer signal — e.g. a
384
+ // just-reset window reading mildly "ahead" of a near-zero pace line but nowhere near the target
385
+ // on the sustained trend). Reserved for normal/caution; avoid-large/hard-avoid are real over-pace
386
+ // signals a secondary projection should not paper over.
387
+ const prefer = !blockedNow && windowBand !== null
388
+ && (windowBand === "prefer"
389
+ || ((windowBand === "normal" || windowBand === "caution") && slack !== null && slack >= preferSlackAt));
305
390
  return {
306
391
  role: burnWindow.role,
307
392
  name: burnWindow.name,
@@ -316,6 +401,10 @@ function spawnQuotaWindow(
316
401
  earlyWallMs: burnWindow.earlyWallMs,
317
402
  projectedUtilAtReset,
318
403
  slack,
404
+ paceTarget: paceMetrics?.target ?? null,
405
+ paceError: paceMetrics?.paceError ?? null,
406
+ paceWeight: paceMetrics?.weight ?? null,
407
+ pace: paceMetrics ? formatPaceLabel(paceMetrics.paceError) : null,
319
408
  prefer,
320
409
  penalty,
321
410
  blockedNow,
@@ -357,7 +446,8 @@ export function getProviderBandInfo(provider: string, now: number): ProviderBand
357
446
  const policy = getRoutingPolicy();
358
447
  const records = listProviderQuotas({ provider, historyLimit: 0, now }).quotas;
359
448
  if (records.length === 0) return undefined;
360
- const advisory = quotaAdvisoryFor(records, now, policy.quotaBands, policy.providerQuota?.[provider as SpawnProvider]);
449
+ const pacing = resolveQuotaPacing(policy);
450
+ const advisory = quotaAdvisoryFor(records, now, pacing, policy.providerQuota?.[provider as SpawnProvider]);
361
451
  const quotaBands = {
362
452
  cautionAt: policy.quotaBands?.cautionAt ?? 0.15,
363
453
  avoidLargeAt: policy.quotaBands?.avoidLargeAt ?? 0.5,
@@ -365,8 +455,11 @@ export function getProviderBandInfo(provider: string, now: number): ProviderBand
365
455
  };
366
456
  const windows: ProviderBandWindow[] = advisory.quotaBurn.windows.map((w) => ({
367
457
  role: w.role,
368
- // #760 — per-window prefer overlay: w.prefer already encodes "no penalty + preferSlackAt slack".
369
- band: w.prefer ? "prefer" : quotaPenaltyBandFor(w.penalty, policy.quotaBands),
458
+ // #1098 — per-window prefer overlay: w.prefer already encodes the pace-ladder "prefer" state OR
459
+ // "on pace + preferSlackAt slack". Otherwise fall back to the window's own pace-weight band
460
+ // (no cross-window hard-avoid escalation at this per-window level — that needs the OTHER
461
+ // window's state and is applied only at the provider level; see quotaAdvisoryFor).
462
+ band: w.prefer ? "prefer" : quotaPaceBandForWeight(w.paceWeight ?? 0.5, pacing.thresholds),
370
463
  penalty: w.penalty,
371
464
  utilization: w.utilization,
372
465
  burnRatio: w.burnRatio,
@@ -380,7 +473,16 @@ export function getProviderBandInfo(provider: string, now: number): ProviderBand
380
473
 
381
474
  function compareQuotaWindows(a: SpawnQuotaBurnWindow, b: SpawnQuotaBurnWindow): number {
382
475
  if (a.blockedNow !== b.blockedNow) return a.blockedNow ? 1 : -1;
383
- if (a.penalty !== b.penalty) return a.penalty - b.penalty;
476
+ // #1098 — the more-BINDING (lower pace weight) window is "worse"; unknown weight (no reset
477
+ // boundary) reads as neutral (0.5), same as an on-pace window.
478
+ const aw = a.paceWeight ?? 0.5;
479
+ const bw = b.paceWeight ?? 0.5;
480
+ if (aw !== bw) return bw - aw;
481
+ // #1098 — projectedUtilAtReset demoted to a tiebreaker: when pace weight ties, the more
482
+ // concerning (higher) projection wins the "worse" slot.
483
+ const ap = a.projectedUtilAtReset ?? -Infinity;
484
+ const bp = b.projectedUtilAtReset ?? -Infinity;
485
+ if (ap !== bp) return ap - bp;
384
486
  return a.utilization - b.utilization;
385
487
  }
386
488