agent-relay-server 0.100.0 → 0.101.1
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/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DZ3cUoHO.js → MessageBubble-BEjK1-bi.js} +3 -3
- package/public/assets/{MessageBubble-DZ3cUoHO.js.map → MessageBubble-BEjK1-bi.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-B6G_RSZ3.js → PlanGraphPanel-AqFEg7Yg.js} +2 -2
- package/public/assets/{PlanGraphPanel-B6G_RSZ3.js.map → PlanGraphPanel-AqFEg7Yg.js.map} +1 -1
- package/public/assets/{activity-BefDMYiE.js → activity-D5EZw4bj.js} +2 -2
- package/public/assets/{activity-BefDMYiE.js.map → activity-D5EZw4bj.js.map} +1 -1
- package/public/assets/{agent-profiles-D5VW943m.js → agent-profiles-CSISRm_L.js} +2 -2
- package/public/assets/{agent-profiles-D5VW943m.js.map → agent-profiles-CSISRm_L.js.map} +1 -1
- package/public/assets/{agents-CtsNY05B.js → agents-szyYnkgD.js} +2 -2
- package/public/assets/{agents-CtsNY05B.js.map → agents-szyYnkgD.js.map} +1 -1
- package/public/assets/{analytics-CUJbpoA7.js → analytics-KzLQfGMO.js} +2 -2
- package/public/assets/{analytics-CUJbpoA7.js.map → analytics-KzLQfGMO.js.map} +1 -1
- package/public/assets/{automation-B5TFhCGs.js → automation-DVSq4A1b.js} +2 -2
- package/public/assets/{automation-B5TFhCGs.js.map → automation-DVSq4A1b.js.map} +1 -1
- package/public/assets/{branch-state-badge-BLF9LPtu.js → branch-state-badge-DgwkJ2rN.js} +2 -2
- package/public/assets/{branch-state-badge-BLF9LPtu.js.map → branch-state-badge-DgwkJ2rN.js.map} +1 -1
- package/public/assets/{channels-Tg8l4n5J.js → channels-Dh_r7SnM.js} +2 -2
- package/public/assets/{channels-Tg8l4n5J.js.map → channels-Dh_r7SnM.js.map} +1 -1
- package/public/assets/chat-patMKjsi.js +2 -0
- package/public/assets/chat-patMKjsi.js.map +1 -0
- package/public/assets/{connectors-B5wbjyUd.js → connectors-DVKBHNRV.js} +2 -2
- package/public/assets/{connectors-B5wbjyUd.js.map → connectors-DVKBHNRV.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Zk5WjWS7.js → formatted-body-impl-Csr2DuEk.js} +2 -2
- package/public/assets/{formatted-body-impl-Zk5WjWS7.js.map → formatted-body-impl-Csr2DuEk.js.map} +1 -1
- package/public/assets/index-D3woTUwp.js +23 -0
- package/public/assets/index-D3woTUwp.js.map +1 -0
- package/public/assets/{insights-C8iC-IB1.js → insights-D2_Qry06.js} +2 -2
- package/public/assets/{insights-C8iC-IB1.js.map → insights-D2_Qry06.js.map} +1 -1
- package/public/assets/{integrations-sYKtupOq.js → integrations-AW8b4h7T.js} +2 -2
- package/public/assets/{integrations-sYKtupOq.js.map → integrations-AW8b4h7T.js.map} +1 -1
- package/public/assets/maintenance-Dm56u8F9.js +2 -0
- package/public/assets/{maintenance-pl6U2a_y.js.map → maintenance-Dm56u8F9.js.map} +1 -1
- package/public/assets/{managed-agents-1CynkXEy.js → managed-agents-DpX-cHsm.js} +2 -2
- package/public/assets/{managed-agents-1CynkXEy.js.map → managed-agents-DpX-cHsm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-Dj6MExTR.js → markdown-preview-impl-Df6EWwbg.js} +2 -2
- package/public/assets/{markdown-preview-impl-Dj6MExTR.js.map → markdown-preview-impl-Df6EWwbg.js.map} +1 -1
- package/public/assets/{memory-DyWT5mwx.js → memory-b2AgvV7H.js} +2 -2
- package/public/assets/{memory-DyWT5mwx.js.map → memory-b2AgvV7H.js.map} +1 -1
- package/public/assets/{messages-LqXFX2yc.js → messages-C5yDD_oq.js} +2 -2
- package/public/assets/{messages-LqXFX2yc.js.map → messages-C5yDD_oq.js.map} +1 -1
- package/public/assets/{orchestrators-xdpBJOt1.js → orchestrators-D3LecmEw.js} +2 -2
- package/public/assets/{orchestrators-xdpBJOt1.js.map → orchestrators-D3LecmEw.js.map} +1 -1
- package/public/assets/{overview-BDetOVf7.js → overview-CnGkrBNe.js} +2 -2
- package/public/assets/{overview-BDetOVf7.js.map → overview-CnGkrBNe.js.map} +1 -1
- package/public/assets/{pairs-BxVd-YVM.js → pairs-DXUdZOqn.js} +2 -2
- package/public/assets/{pairs-BxVd-YVM.js.map → pairs-DXUdZOqn.js.map} +1 -1
- package/public/assets/{projects-BPI8z55X.js → projects-1h41atqJ.js} +2 -2
- package/public/assets/{projects-BPI8z55X.js.map → projects-1h41atqJ.js.map} +1 -1
- package/public/assets/{registry-BeoPmyhz.js → registry-BeGP37ot.js} +2 -2
- package/public/assets/{registry-BeoPmyhz.js.map → registry-BeGP37ot.js.map} +1 -1
- package/public/assets/{security-DKDgxf96.js → security-BMh_y43L.js} +2 -2
- package/public/assets/{security-DKDgxf96.js.map → security-BMh_y43L.js.map} +1 -1
- package/public/assets/{select-DpRN9C_1.js → select-Cvi5OHz0.js} +2 -2
- package/public/assets/{select-DpRN9C_1.js.map → select-Cvi5OHz0.js.map} +1 -1
- package/public/assets/settings-DKSEwIVG.js +6 -0
- package/public/assets/{settings-D_mLzA5O.js.map → settings-DKSEwIVG.js.map} +1 -1
- package/public/assets/store-Ch-QzxPq.js +9 -0
- package/public/assets/store-Ch-QzxPq.js.map +1 -0
- package/public/assets/{tasks-D5OVfd-K.js → tasks-BybIAX-y.js} +2 -2
- package/public/assets/{tasks-D5OVfd-K.js.map → tasks-BybIAX-y.js.map} +1 -1
- package/public/assets/{teams-CFJM0XlG.js → teams-CMteOToR.js} +2 -2
- package/public/assets/{teams-CFJM0XlG.js.map → teams-CMteOToR.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-B-2tqoEB.js → terminal-viewer-impl-DanOi7lh.js} +2 -2
- package/public/assets/{terminal-viewer-impl-B-2tqoEB.js.map → terminal-viewer-impl-DanOi7lh.js.map} +1 -1
- package/public/assets/{user-avatar-BGXTBaME.js → user-avatar-DG629icI.js} +2 -2
- package/public/assets/{user-avatar-BGXTBaME.js.map → user-avatar-DG629icI.js.map} +1 -1
- package/public/assets/{work-queue-DMwtDbBg.js → work-queue-DqW80DIy.js} +2 -2
- package/public/assets/{work-queue-DMwtDbBg.js.map → work-queue-DqW80DIy.js.map} +1 -1
- package/public/assets/{workspaces-BolccWZy.js → workspaces-CccVI6lA.js} +2 -2
- package/public/assets/{workspaces-BolccWZy.js.map → workspaces-CccVI6lA.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/db/provider-quota-burn.ts +78 -6
- package/src/quota-band-transition.ts +106 -13
- package/src/routes/_shared.ts +15 -0
- package/src/routes/routing-policy.ts +3 -2
- package/src/routing-policy-store.ts +8 -0
- package/src/settings/registry.ts +34 -0
- package/src/spawn-targets.ts +90 -13
- package/public/assets/chat-CoZcF6dA.js +0 -2
- package/public/assets/chat-CoZcF6dA.js.map +0 -1
- package/public/assets/index-B4cTW4A9.js +0 -23
- package/public/assets/index-B4cTW4A9.js.map +0 -1
- package/public/assets/maintenance-pl6U2a_y.js +0 -2
- package/public/assets/settings-D_mLzA5O.js +0 -6
- package/public/assets/store-pKbf_WYd.js +0 -9
- 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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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:
|
|
166
|
+
burnRatio: pace.burnRatio,
|
|
167
|
+
observedRatePerMs,
|
|
100
168
|
timeToWallMs,
|
|
101
169
|
projectedWallAt,
|
|
102
|
-
|
|
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,22 +7,41 @@
|
|
|
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.
|
|
13
20
|
import { emitRelayEvent } from "./events";
|
|
14
21
|
import { routeNotification } from "./notification-router";
|
|
15
22
|
import { getSpawnCapableAgentIds } from "./routing-policy-push";
|
|
16
|
-
import { getProviderBandInfo } from "./spawn-targets";
|
|
17
|
-
import type { ProviderBandWindow, QuotaBand } from "./spawn-targets";
|
|
23
|
+
import { getProviderBandInfo, windowRecommendedAction } 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,42 +50,72 @@ 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) ?? {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
|
90
|
+
if (effectiveBand === state.pendingBand) {
|
|
53
91
|
state.pendingCount += 1;
|
|
54
92
|
} else {
|
|
55
|
-
state.pendingBand =
|
|
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 =
|
|
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,
|
|
67
113
|
window,
|
|
68
114
|
previousBand,
|
|
69
|
-
|
|
115
|
+
// #761 — use the window's own band for the action text, not the provider-level band.
|
|
116
|
+
// e.g. a 5h→prefer transition on a provider whose 7d is at avoid-large must say
|
|
117
|
+
// "Prefer…", not "Avoid large jobs…".
|
|
118
|
+
recommendedAction: windowRecommendedAction(effectiveBand, window.slack),
|
|
70
119
|
now: input.now,
|
|
71
120
|
});
|
|
72
121
|
} else {
|
|
@@ -75,6 +124,43 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
75
124
|
}
|
|
76
125
|
}
|
|
77
126
|
|
|
127
|
+
// Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
|
|
128
|
+
// the penalty to fall HYSTERESIS_MARGIN below the entry threshold of the last notified band.
|
|
129
|
+
// #760: the "prefer" boundary uses a slack deadband instead (penalty is 0 throughout prefer).
|
|
130
|
+
function applyHysteresis(
|
|
131
|
+
rawBand: QuotaBand,
|
|
132
|
+
penalty: number,
|
|
133
|
+
slack: number | null,
|
|
134
|
+
lastNotifiedBand: QuotaBand,
|
|
135
|
+
quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
|
|
136
|
+
preferSlackAt: number,
|
|
137
|
+
): QuotaBand {
|
|
138
|
+
// #760 — hold "prefer" until slack falls PREFER_HYSTERESIS_MARGIN below the entry threshold;
|
|
139
|
+
// a real over-pace penalty (rawBand worse than normal) exits prefer immediately.
|
|
140
|
+
if (lastNotifiedBand === "prefer") {
|
|
141
|
+
if (rawBand === "prefer") return "prefer";
|
|
142
|
+
if (rawBand === "normal" && slack !== null && slack >= preferSlackAt - PREFER_HYSTERESIS_MARGIN) return "prefer";
|
|
143
|
+
return rawBand;
|
|
144
|
+
}
|
|
145
|
+
// #760 — entering prefer: the slack entry threshold is already applied upstream (window.prefer).
|
|
146
|
+
// Debounce + recovery rate-limit downstream keep prefer from fleet-spamming.
|
|
147
|
+
if (rawBand === "prefer") return "prefer";
|
|
148
|
+
if (bandRank(rawBand) >= bandRank(lastNotifiedBand)) return rawBand;
|
|
149
|
+
const exitThreshold = bandEntryThreshold(lastNotifiedBand, quotaBands) - HYSTERESIS_MARGIN;
|
|
150
|
+
if (penalty >= exitThreshold) return lastNotifiedBand;
|
|
151
|
+
return rawBand;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function bandEntryThreshold(
|
|
155
|
+
band: QuotaBand,
|
|
156
|
+
quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
|
|
157
|
+
): number {
|
|
158
|
+
if (band === "hard-avoid") return quotaBands.hardAvoidAt;
|
|
159
|
+
if (band === "avoid-large") return quotaBands.avoidLargeAt;
|
|
160
|
+
if (band === "caution") return quotaBands.cautionAt;
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
78
164
|
function emitBandTransition(input: {
|
|
79
165
|
provider: string;
|
|
80
166
|
window: ProviderBandWindow;
|
|
@@ -95,6 +181,9 @@ function emitBandTransition(input: {
|
|
|
95
181
|
utilization: window.utilization,
|
|
96
182
|
burnRatio: window.burnRatio,
|
|
97
183
|
projectedWallAt: window.projectedWallAt,
|
|
184
|
+
// #760 — carry the symmetric underburn signal so coordinators see "prefer", not just "avoid".
|
|
185
|
+
slack: window.slack,
|
|
186
|
+
prefer: window.band === "prefer",
|
|
98
187
|
recommendedAction,
|
|
99
188
|
};
|
|
100
189
|
|
|
@@ -134,11 +223,15 @@ function buildBody(input: {
|
|
|
134
223
|
const proj = window.projectedWallAt !== null
|
|
135
224
|
? ` (wall ~${new Date(window.projectedWallAt).toISOString()})`
|
|
136
225
|
: "";
|
|
137
|
-
|
|
226
|
+
// #760 — for the prefer band there is no early wall; surface the projected unused headroom instead.
|
|
227
|
+
const slackHint = window.band === "prefer" && window.slack !== null ? ` slack ${Math.round(window.slack * 100)}%` : "";
|
|
228
|
+
return `${providerLabel(provider)} ${window.role} quota band: ${previousBand} → ${window.band}. Utilization ${util}%${burn}${proj}${slackHint}. ${recommendedAction}`;
|
|
138
229
|
}
|
|
139
230
|
|
|
140
231
|
function bandRank(band: QuotaBand): number {
|
|
141
|
-
|
|
232
|
+
// #760 — "prefer" is the best state, ranked below "normal", so normal→prefer reads as a
|
|
233
|
+
// recovery (rate-limited; no fleet-spam) and prefer→normal reads as a degradation.
|
|
234
|
+
const ranks: Record<QuotaBand, number> = { "prefer": 0, "normal": 1, "caution": 2, "avoid-large": 3, "hard-avoid": 4 };
|
|
142
235
|
return ranks[band];
|
|
143
236
|
}
|
|
144
237
|
|
package/src/routes/_shared.ts
CHANGED
|
@@ -362,6 +362,21 @@ export function auditEvent(input: ActivityEventInput): void {
|
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Derive a human-readable actor label from the request's auth context, for use as
|
|
367
|
+
* `updatedBy` in config writes. Prefixes distinguish the auth type so notifications
|
|
368
|
+
* show "by dashboard:alice" rather than an opaque id.
|
|
369
|
+
*/
|
|
370
|
+
export function resolveActorLabel(req: Request): string | undefined {
|
|
371
|
+
const acting = resolveActingUser(req);
|
|
372
|
+
if (acting) return `dashboard:${acting.userId}`;
|
|
373
|
+
const component = getComponentAuth(req);
|
|
374
|
+
if (component) return `agent:${component.sub}`;
|
|
375
|
+
const integration = getIntegrationAuth(req);
|
|
376
|
+
if (integration) return `integration:${integration.name}`;
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
365
380
|
export function memoryContext(req: Request): MemoryBrokerContext {
|
|
366
381
|
const component = getComponentAuth(req);
|
|
367
382
|
if (component) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GET: agent:read (coordinators read the policy to apply rules).
|
|
3
3
|
// PUT: settings:write:routing (admin writes).
|
|
4
4
|
import { ValidationError } from "../db";
|
|
5
|
-
import { error, json, parseBody, type Handler } from "./_shared";
|
|
5
|
+
import { error, json, parseBody, resolveActorLabel, type Handler } from "./_shared";
|
|
6
6
|
import { getRoutingPolicy, setRoutingPolicy } from "../routing-policy-store";
|
|
7
7
|
import { isRecord } from "agent-relay-sdk";
|
|
8
8
|
import { cleanString } from "../validation";
|
|
@@ -15,7 +15,8 @@ export const putRoutingPolicyRoute: Handler = async (req) => {
|
|
|
15
15
|
const parsed = await parseBody<unknown>(req);
|
|
16
16
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
17
17
|
try {
|
|
18
|
-
const
|
|
18
|
+
const bodyUpdatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
|
|
19
|
+
const updatedBy = bodyUpdatedBy ?? resolveActorLabel(req);
|
|
19
20
|
const policy = setRoutingPolicy(parsed.body, { updatedBy });
|
|
20
21
|
return json(policy);
|
|
21
22
|
} catch (e) {
|
|
@@ -31,9 +31,17 @@ export function getRoutingPolicy(): RoutingPolicy {
|
|
|
31
31
|
* Persist the routing policy. The incoming value is normalized so partial bodies
|
|
32
32
|
* still produce a valid document. Emits `config.changed` via `setConfig` so the
|
|
33
33
|
* dashboard + any live listeners pick it up immediately.
|
|
34
|
+
*
|
|
35
|
+
* No-op detection: if the normalized incoming policy is deep-equal to the current
|
|
36
|
+
* persisted one, the write and the fleet notification are both skipped so identical
|
|
37
|
+
* saves are fully silent.
|
|
34
38
|
*/
|
|
35
39
|
export function setRoutingPolicy(value: unknown, opts: { updatedBy?: string } = {}): RoutingPolicy {
|
|
36
40
|
const normalized = normalizeRoutingPolicy(value);
|
|
41
|
+
const current = getRoutingPolicy();
|
|
42
|
+
if (JSON.stringify(normalized) === JSON.stringify(current)) {
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
37
45
|
setConfig(ROUTING_POLICY_NAMESPACE, ROUTING_POLICY_KEY, normalized, opts.updatedBy);
|
|
38
46
|
notifyRoutingPolicyChanged(normalized, opts);
|
|
39
47
|
return normalized;
|
package/src/settings/registry.ts
CHANGED
|
@@ -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",
|
package/src/spawn-targets.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
273
|
+
blockedNow,
|
|
226
274
|
};
|
|
227
275
|
}
|
|
228
276
|
|
|
229
277
|
// === Public band-info API for quota-band-transition (#738) ===
|
|
230
278
|
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,16 +349,29 @@ 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
|
|
292
356
|
? "Quota is blocked now; switch provider or escalate unless this provider was explicitly requested."
|
|
293
357
|
: "5h quota is blocked now; wait for reset or use another provider unless the job is urgent.";
|
|
294
358
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
359
|
+
return windowRecommendedAction(input.quotaPenaltyBand, input.slack);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Per-window recommended action text for a given band — used by band-transition notifications
|
|
364
|
+
* so each window's notification reflects ITS own band, not the provider-level band.
|
|
365
|
+
*/
|
|
366
|
+
export function windowRecommendedAction(band: QuotaBand, slack: number | null): string {
|
|
367
|
+
if (band === "hard-avoid") return "Hard avoid unless explicitly requested or no healthier alternate exists.";
|
|
368
|
+
if (band === "avoid-large") return "Avoid large jobs if a healthier alternate exists.";
|
|
369
|
+
if (band === "caution") return "Caution: prefer cheaper effort/model when practical.";
|
|
370
|
+
// #760 — surface the underburn signal so coordinators load-shift big/deferrable jobs here.
|
|
371
|
+
if (band === "prefer") {
|
|
372
|
+
const headroom = slack !== null ? ` (~${Math.round(slack * 100)}% quota would otherwise go unused at reset)` : "";
|
|
373
|
+
return `Prefer for large or deferrable jobs${headroom}.`;
|
|
374
|
+
}
|
|
298
375
|
return "Spawn normally.";
|
|
299
376
|
}
|
|
300
377
|
|