agent-relay-server 0.123.1 → 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 (72) hide show
  1. package/README.md +3 -1
  2. package/docs/openapi.json +1 -1
  3. package/package.json +4 -3
  4. package/public/assets/{MessageBubble-NCggGKTA.js → MessageBubble-SQK8-Mzp.js} +2 -2
  5. package/public/assets/{MessageBubble-NCggGKTA.js.map → MessageBubble-SQK8-Mzp.js.map} +1 -1
  6. package/public/assets/{PlanGraphPanel-CSdszmGk.js → PlanGraphPanel-CS-aow4S.js} +2 -2
  7. package/public/assets/{PlanGraphPanel-CSdszmGk.js.map → PlanGraphPanel-CS-aow4S.js.map} +1 -1
  8. package/public/assets/{activity-Cyp2nolX.js → activity-W31XvhC2.js} +2 -2
  9. package/public/assets/{activity-Cyp2nolX.js.map → activity-W31XvhC2.js.map} +1 -1
  10. package/public/assets/{agent-profiles-D5Izg0Vn.js → agent-profiles-DXysA9To.js} +2 -2
  11. package/public/assets/{agent-profiles-D5Izg0Vn.js.map → agent-profiles-DXysA9To.js.map} +1 -1
  12. package/public/assets/{agents-DIOxuJ6M.js → agents-B5GRNRnN.js} +2 -2
  13. package/public/assets/{agents-DIOxuJ6M.js.map → agents-B5GRNRnN.js.map} +1 -1
  14. package/public/assets/{automation-Bn6mOLAR.js → automation-DYlgHYcb.js} +2 -2
  15. package/public/assets/{automation-Bn6mOLAR.js.map → automation-DYlgHYcb.js.map} +1 -1
  16. package/public/assets/{chat-eUaPz399.js → chat-Clz6qOtn.js} +2 -2
  17. package/public/assets/{chat-eUaPz399.js.map → chat-Clz6qOtn.js.map} +1 -1
  18. package/public/assets/{formatted-body-CvrQ2D1J.js → formatted-body-BYQ-SPtj.js} +3 -3
  19. package/public/assets/{formatted-body-CvrQ2D1J.js.map → formatted-body-BYQ-SPtj.js.map} +1 -1
  20. package/public/assets/{formatted-body-impl-BJ7jdbpR.js → formatted-body-impl-L1EMnNW6.js} +2 -2
  21. package/public/assets/{formatted-body-impl-BJ7jdbpR.js.map → formatted-body-impl-L1EMnNW6.js.map} +1 -1
  22. package/public/assets/{index-DnUo0Zqg.js → index-0uIGXT14.js} +6 -6
  23. package/public/assets/{index-DnUo0Zqg.js.map → index-0uIGXT14.js.map} +1 -1
  24. package/public/assets/index-DOJ7KidA.css +2 -0
  25. package/public/assets/{insights-CQGH5LhD.js → insights-DwZxq1Dn.js} +2 -2
  26. package/public/assets/{insights-CQGH5LhD.js.map → insights-DwZxq1Dn.js.map} +1 -1
  27. package/public/assets/{maintenance-7c7UvpAt.js → maintenance-COcmUDnf.js} +2 -2
  28. package/public/assets/{maintenance-7c7UvpAt.js.map → maintenance-COcmUDnf.js.map} +1 -1
  29. package/public/assets/{managed-agents-yh4HPO5a.js → managed-agents-D9-dgMeX.js} +2 -2
  30. package/public/assets/{managed-agents-yh4HPO5a.js.map → managed-agents-D9-dgMeX.js.map} +1 -1
  31. package/public/assets/{markdown-preview-impl-BuOn5-Uc.js → markdown-preview-impl-DH372bL-.js} +2 -2
  32. package/public/assets/{markdown-preview-impl-BuOn5-Uc.js.map → markdown-preview-impl-DH372bL-.js.map} +1 -1
  33. package/public/assets/{memory-Qv0JN8CE.js → memory-B9hRK8sk.js} +2 -2
  34. package/public/assets/{memory-Qv0JN8CE.js.map → memory-B9hRK8sk.js.map} +1 -1
  35. package/public/assets/{messages-CgjHJmc2.js → messages-BHkHlXzF.js} +2 -2
  36. package/public/assets/{messages-CgjHJmc2.js.map → messages-BHkHlXzF.js.map} +1 -1
  37. package/public/assets/{orchestrators-CgL4CFzb.js → orchestrators-CXy3SDSn.js} +2 -2
  38. package/public/assets/{orchestrators-CgL4CFzb.js.map → orchestrators-CXy3SDSn.js.map} +1 -1
  39. package/public/assets/{overview-Bri04eYE.js → overview-D2yRyCQq.js} +2 -2
  40. package/public/assets/{overview-Bri04eYE.js.map → overview-D2yRyCQq.js.map} +1 -1
  41. package/public/assets/{pairs-C78LwUH2.js → pairs-5tXdHylI.js} +2 -2
  42. package/public/assets/{pairs-C78LwUH2.js.map → pairs-5tXdHylI.js.map} +1 -1
  43. package/public/assets/{prompt-settings-DDEkMqPZ.js → prompt-settings-Bn5mmi9w.js} +2 -2
  44. package/public/assets/{prompt-settings-DDEkMqPZ.js.map → prompt-settings-Bn5mmi9w.js.map} +1 -1
  45. package/public/assets/registry-C3sB7qv5.js +4 -0
  46. package/public/assets/registry-C3sB7qv5.js.map +1 -0
  47. package/public/assets/{security-wvnRKFvm.js → security-CmD-YE-_.js} +2 -2
  48. package/public/assets/{security-wvnRKFvm.js.map → security-CmD-YE-_.js.map} +1 -1
  49. package/public/assets/{select-BIfbVmLf.js → select-C0UlhK2k.js} +2 -2
  50. package/public/assets/{select-BIfbVmLf.js.map → select-C0UlhK2k.js.map} +1 -1
  51. package/public/assets/{settings-B6Ntahx7.js → settings-DrqMG5nG.js} +2 -2
  52. package/public/assets/{settings-B6Ntahx7.js.map → settings-DrqMG5nG.js.map} +1 -1
  53. package/public/assets/{tasks-CUglafo5.js → tasks-ChQjtXHu.js} +2 -2
  54. package/public/assets/{tasks-CUglafo5.js.map → tasks-ChQjtXHu.js.map} +1 -1
  55. package/public/assets/{teams-EE8RHhHM.js → teams-itjNlDLp.js} +2 -2
  56. package/public/assets/{teams-EE8RHhHM.js.map → teams-itjNlDLp.js.map} +1 -1
  57. package/public/assets/{terminal-viewer-impl-DBRkdyag.js → terminal-viewer-impl-B9M6s2vB.js} +2 -2
  58. package/public/assets/{terminal-viewer-impl-DBRkdyag.js.map → terminal-viewer-impl-B9M6s2vB.js.map} +1 -1
  59. package/public/assets/{work-queue-BFQVVL4T.js → work-queue-Kw0uMkEF.js} +2 -2
  60. package/public/assets/{work-queue-BFQVVL4T.js.map → work-queue-Kw0uMkEF.js.map} +1 -1
  61. package/public/index.html +3 -3
  62. package/runner/hooks/rg-r-guard/hooks.json +18 -0
  63. package/runner/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  64. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  65. package/src/db/provisioning-registry.ts +174 -1
  66. package/src/routes/provisioning-registry.ts +3 -0
  67. package/src/routing-policy-store.ts +55 -0
  68. package/src/services/spawn-chain-inspector.ts +134 -0
  69. package/src/spawn-targets.ts +130 -28
  70. package/public/assets/index-DcmG5gLR.css +0 -2
  71. package/public/assets/registry-5KhP5FE5.js +0 -2
  72. package/public/assets/registry-5KhP5FE5.js.map +0 -1
@@ -0,0 +1,134 @@
1
+ // Real-chain spawn inspector (#1103 L1 seed) — the regression harness whose absence let two
2
+ // dead #1096 fixes ship. It drives the ACTUAL spawn chain for a named profile, end to end, with
3
+ // NO hand-fed config objects:
4
+ //
5
+ // getAgentProfile(name) — the real built-in/stored profile
6
+ // → withResolvedProvisioning — relay-side registry resolution (needs a seeded DB)
7
+ // → buildSpawnCommand — the command params the orchestrator dispatches
8
+ // → JSON round-trip — the command-bus / AGENT_RELAY_AGENT_PROFILE_JSON env seam
9
+ // → spawnOptionsFromControl — orchestrator re-parse of the command
10
+ // → ClaudeAdapter.buildSpawnArgs — runner assembly + hook/skill/plugin MATERIALIZATION to disk
11
+ //
12
+ // then reads back the EFFECTIVE on-disk artifacts: the written managed `settings.json`, the
13
+ // materialized `relay-provisioned-hooks/` tree, and the assembled argv. Two prior fixes each
14
+ // tested one half in isolation (relay resolution OR runner materialization) with a hand-fed
15
+ // `provisioning.hooks`, so neither caught that the bundled hook asset is never SHIPPED (the
16
+ // `runner/hooks/**` packaging gap) and so never seeds → resolves empty on a real deployed spawn.
17
+ // This helper runs the whole chain against the real DB + real package layout, so that class of
18
+ // break surfaces as a red on-disk artifact instead of a green half-test.
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import type { SpawnProvider } from "agent-relay-sdk";
23
+ import { withResolvedProvisioning } from "../db";
24
+ import { getAgentProfile } from "../config-store";
25
+ import { buildSpawnCommand } from "../spawn-command";
26
+ import { spawnOptionsFromControl } from "../../orchestrator/src/control";
27
+ import { ClaudeAdapter } from "../../runner/src/adapters/claude";
28
+ import { defaultProviderConfig } from "../../runner/src/config";
29
+ import type { RunnerSpawnConfig } from "../../runner/src/adapter";
30
+
31
+ /** The bundled baseline safety hook whose silent absence is the #1096 bug. */
32
+ export const BASELINE_HOOK_REF = "agent-relay-rg-r-guard";
33
+ export const BASELINE_HOOK_SCRIPT = "rg-r-guard.cjs";
34
+
35
+ export interface SpawnChainInspection {
36
+ profile: string;
37
+ provider: SpawnProvider;
38
+ /** The isolated managed CLAUDE_CONFIG_DIR the runner assembled + materialized into. */
39
+ configHome: string | undefined;
40
+ /** Absolute path of the managed settings.json (whether or not it exists). */
41
+ settingsPath: string | undefined;
42
+ /** Parsed on-disk settings.json (null when relay wrote none). */
43
+ settings: Record<string, unknown> | null;
44
+ /** The effective launch argv the adapter would exec. */
45
+ argv: string[];
46
+ /** The launch command (claude / claude-rig). */
47
+ command: string;
48
+ /** relay-provisioned-hooks/<ref> dir for the baseline guard. */
49
+ baselineHookAssetDir: string | undefined;
50
+ baselineHookAssetExists: boolean;
51
+ baselineHookScriptExists: boolean;
52
+ /** True iff the WRITTEN settings.json (the documented, transit-safe hook source) carries the
53
+ * baseline PreToolUse guard — the #1096 proof-of-life bar. */
54
+ settingsHasBaselineHook: boolean;
55
+ /** True iff SOME launch channel (written settings.json OR inline --settings) carries it. */
56
+ launchHasBaselineHook: boolean;
57
+ }
58
+
59
+ function settingsCarriesBaselineHook(settings: Record<string, unknown> | null): boolean {
60
+ const pre = (settings?.hooks as { PreToolUse?: unknown } | undefined)?.PreToolUse;
61
+ if (!Array.isArray(pre)) return false;
62
+ return JSON.stringify(pre).includes(BASELINE_HOOK_SCRIPT);
63
+ }
64
+
65
+ function argvCarriesBaselineHook(argv: string[]): boolean {
66
+ const idx = argv.findIndex((a) => a === "--settings");
67
+ if (idx < 0 || idx + 1 >= argv.length) return false;
68
+ return argv[idx + 1]!.includes(BASELINE_HOOK_SCRIPT);
69
+ }
70
+
71
+ /**
72
+ * Run the real spawn chain for a profile and read back the on-disk artifacts. Two preconditions,
73
+ * both owned by the caller so this stays a pure harness (no global/env mutation of its own):
74
+ * • the DB is seeded (initDb) so registry resolution can find the bundled hook, and
75
+ * • AGENT_RELAY_PROVIDER_HOME_ROOT points at a scratch dir (the runner's config module reads it
76
+ * to place isolated provider homes) — set it like launch-assembly.test.ts does.
77
+ */
78
+ export function inspectSpawnChain(opts: {
79
+ profile: string;
80
+ provider: SpawnProvider;
81
+ cwd: string;
82
+ approvalMode?: string;
83
+ }): SpawnChainInspection {
84
+ const { profile, provider, cwd } = opts;
85
+
86
+ // 1. RELAY: resolve the named profile's provisioning against the registry.
87
+ const resolved = withResolvedProvisioning(getAgentProfile(profile)?.value, provider);
88
+ if (!resolved) throw new Error(`profile not found: ${profile}`);
89
+
90
+ // 2. RELAY: build the spawn command params the orchestrator dispatches.
91
+ const cmdParams = buildSpawnCommand({
92
+ provider, cwd, agentProfile: resolved, env: {}, requestedBy: "spawn-chain-inspector",
93
+ } as Parameters<typeof buildSpawnCommand>[0]);
94
+
95
+ // 3. SEAM: JSON round-trip through the command bus / AGENT_RELAY_AGENT_PROFILE_JSON env var.
96
+ const roundTripped = JSON.parse(JSON.stringify(cmdParams)) as Record<string, unknown>;
97
+
98
+ // 4. ORCHESTRATOR: re-parse the command into spawn options.
99
+ const spawnOpts = spawnOptionsFromControl(roundTripped, { providers: [provider], baseDir: cwd } as never);
100
+
101
+ // 5. RUNNER: assemble the RunnerSpawnConfig the adapter consumes.
102
+ const providerConfig = defaultProviderConfig(provider);
103
+ const config = {
104
+ provider, runnerId: "inspector", instanceId: `insp-${profile}`, agentId: "insp",
105
+ relayUrl: "http://localhost:4850", cwd, headless: true,
106
+ approvalMode: opts.approvalMode ?? "open", providerArgs: [], providerConfig,
107
+ env: {}, controlPort: 4900,
108
+ agentProfile: spawnOpts.agentProfile,
109
+ } as unknown as RunnerSpawnConfig;
110
+
111
+ // 6. RUNNER: the REAL adapter path — prepares the home, materializes provisioned assets to
112
+ // disk, and returns the launch argv (exactly what the runner execs).
113
+ const spawnArgs = new ClaudeAdapter().buildSpawnArgs(config, providerConfig);
114
+
115
+ // 7. Read back the effective on-disk artifacts.
116
+ const configHome = spawnArgs.env.CLAUDE_CONFIG_DIR;
117
+ const settingsPath = configHome ? join(configHome, "settings.json") : undefined;
118
+ const settings = settingsPath && existsSync(settingsPath)
119
+ ? (JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>)
120
+ : null;
121
+ const baselineHookAssetDir = configHome
122
+ ? join(configHome, "relay-provisioned-hooks", BASELINE_HOOK_REF)
123
+ : undefined;
124
+
125
+ return {
126
+ profile, provider, configHome, settingsPath, settings,
127
+ argv: spawnArgs.args, command: spawnArgs.command,
128
+ baselineHookAssetDir,
129
+ baselineHookAssetExists: Boolean(baselineHookAssetDir && existsSync(baselineHookAssetDir)),
130
+ baselineHookScriptExists: Boolean(baselineHookAssetDir && existsSync(join(baselineHookAssetDir, BASELINE_HOOK_SCRIPT))),
131
+ settingsHasBaselineHook: settingsCarriesBaselineHook(settings),
132
+ launchHasBaselineHook: settingsCarriesBaselineHook(settings) || argvCarriesBaselineHook(spawnArgs.args),
133
+ };
134
+ }
@@ -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