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.
- package/README.md +3 -1
- package/docs/openapi.json +1 -1
- package/package.json +4 -3
- package/public/assets/{MessageBubble-NCggGKTA.js → MessageBubble-SQK8-Mzp.js} +2 -2
- package/public/assets/{MessageBubble-NCggGKTA.js.map → MessageBubble-SQK8-Mzp.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CSdszmGk.js → PlanGraphPanel-CS-aow4S.js} +2 -2
- package/public/assets/{PlanGraphPanel-CSdszmGk.js.map → PlanGraphPanel-CS-aow4S.js.map} +1 -1
- package/public/assets/{activity-Cyp2nolX.js → activity-W31XvhC2.js} +2 -2
- package/public/assets/{activity-Cyp2nolX.js.map → activity-W31XvhC2.js.map} +1 -1
- package/public/assets/{agent-profiles-D5Izg0Vn.js → agent-profiles-DXysA9To.js} +2 -2
- package/public/assets/{agent-profiles-D5Izg0Vn.js.map → agent-profiles-DXysA9To.js.map} +1 -1
- package/public/assets/{agents-DIOxuJ6M.js → agents-B5GRNRnN.js} +2 -2
- package/public/assets/{agents-DIOxuJ6M.js.map → agents-B5GRNRnN.js.map} +1 -1
- package/public/assets/{automation-Bn6mOLAR.js → automation-DYlgHYcb.js} +2 -2
- package/public/assets/{automation-Bn6mOLAR.js.map → automation-DYlgHYcb.js.map} +1 -1
- package/public/assets/{chat-eUaPz399.js → chat-Clz6qOtn.js} +2 -2
- package/public/assets/{chat-eUaPz399.js.map → chat-Clz6qOtn.js.map} +1 -1
- package/public/assets/{formatted-body-CvrQ2D1J.js → formatted-body-BYQ-SPtj.js} +3 -3
- package/public/assets/{formatted-body-CvrQ2D1J.js.map → formatted-body-BYQ-SPtj.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BJ7jdbpR.js → formatted-body-impl-L1EMnNW6.js} +2 -2
- package/public/assets/{formatted-body-impl-BJ7jdbpR.js.map → formatted-body-impl-L1EMnNW6.js.map} +1 -1
- package/public/assets/{index-DnUo0Zqg.js → index-0uIGXT14.js} +6 -6
- package/public/assets/{index-DnUo0Zqg.js.map → index-0uIGXT14.js.map} +1 -1
- package/public/assets/index-DOJ7KidA.css +2 -0
- package/public/assets/{insights-CQGH5LhD.js → insights-DwZxq1Dn.js} +2 -2
- package/public/assets/{insights-CQGH5LhD.js.map → insights-DwZxq1Dn.js.map} +1 -1
- package/public/assets/{maintenance-7c7UvpAt.js → maintenance-COcmUDnf.js} +2 -2
- package/public/assets/{maintenance-7c7UvpAt.js.map → maintenance-COcmUDnf.js.map} +1 -1
- package/public/assets/{managed-agents-yh4HPO5a.js → managed-agents-D9-dgMeX.js} +2 -2
- package/public/assets/{managed-agents-yh4HPO5a.js.map → managed-agents-D9-dgMeX.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BuOn5-Uc.js → markdown-preview-impl-DH372bL-.js} +2 -2
- package/public/assets/{markdown-preview-impl-BuOn5-Uc.js.map → markdown-preview-impl-DH372bL-.js.map} +1 -1
- package/public/assets/{memory-Qv0JN8CE.js → memory-B9hRK8sk.js} +2 -2
- package/public/assets/{memory-Qv0JN8CE.js.map → memory-B9hRK8sk.js.map} +1 -1
- package/public/assets/{messages-CgjHJmc2.js → messages-BHkHlXzF.js} +2 -2
- package/public/assets/{messages-CgjHJmc2.js.map → messages-BHkHlXzF.js.map} +1 -1
- package/public/assets/{orchestrators-CgL4CFzb.js → orchestrators-CXy3SDSn.js} +2 -2
- package/public/assets/{orchestrators-CgL4CFzb.js.map → orchestrators-CXy3SDSn.js.map} +1 -1
- package/public/assets/{overview-Bri04eYE.js → overview-D2yRyCQq.js} +2 -2
- package/public/assets/{overview-Bri04eYE.js.map → overview-D2yRyCQq.js.map} +1 -1
- package/public/assets/{pairs-C78LwUH2.js → pairs-5tXdHylI.js} +2 -2
- package/public/assets/{pairs-C78LwUH2.js.map → pairs-5tXdHylI.js.map} +1 -1
- package/public/assets/{prompt-settings-DDEkMqPZ.js → prompt-settings-Bn5mmi9w.js} +2 -2
- package/public/assets/{prompt-settings-DDEkMqPZ.js.map → prompt-settings-Bn5mmi9w.js.map} +1 -1
- package/public/assets/registry-C3sB7qv5.js +4 -0
- package/public/assets/registry-C3sB7qv5.js.map +1 -0
- package/public/assets/{security-wvnRKFvm.js → security-CmD-YE-_.js} +2 -2
- package/public/assets/{security-wvnRKFvm.js.map → security-CmD-YE-_.js.map} +1 -1
- package/public/assets/{select-BIfbVmLf.js → select-C0UlhK2k.js} +2 -2
- package/public/assets/{select-BIfbVmLf.js.map → select-C0UlhK2k.js.map} +1 -1
- package/public/assets/{settings-B6Ntahx7.js → settings-DrqMG5nG.js} +2 -2
- package/public/assets/{settings-B6Ntahx7.js.map → settings-DrqMG5nG.js.map} +1 -1
- package/public/assets/{tasks-CUglafo5.js → tasks-ChQjtXHu.js} +2 -2
- package/public/assets/{tasks-CUglafo5.js.map → tasks-ChQjtXHu.js.map} +1 -1
- package/public/assets/{teams-EE8RHhHM.js → teams-itjNlDLp.js} +2 -2
- package/public/assets/{teams-EE8RHhHM.js.map → teams-itjNlDLp.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DBRkdyag.js → terminal-viewer-impl-B9M6s2vB.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DBRkdyag.js.map → terminal-viewer-impl-B9M6s2vB.js.map} +1 -1
- package/public/assets/{work-queue-BFQVVL4T.js → work-queue-Kw0uMkEF.js} +2 -2
- package/public/assets/{work-queue-BFQVVL4T.js.map → work-queue-Kw0uMkEF.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/hooks/rg-r-guard/hooks.json +18 -0
- package/runner/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/db/provisioning-registry.ts +174 -1
- package/src/routes/provisioning-registry.ts +3 -0
- package/src/routing-policy-store.ts +55 -0
- package/src/services/spawn-chain-inspector.ts +134 -0
- package/src/spawn-targets.ts +130 -28
- package/public/assets/index-DcmG5gLR.css +0 -2
- package/public/assets/registry-5KhP5FE5.js +0 -2
- 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
|
+
}
|
package/src/spawn-targets.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
//
|
|
216
|
-
// to
|
|
217
|
-
|
|
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
|
|
222
|
-
|
|
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(
|
|
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
|
|
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 —
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
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
|
-
// #
|
|
369
|
-
|
|
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
|
-
|
|
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
|
|