agent-relay-server 0.99.1 → 0.101.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +5 -2
- package/public/assets/{MessageBubble-DiorZXyB.js → MessageBubble-BEjK1-bi.js} +3 -3
- package/public/assets/{MessageBubble-DiorZXyB.js.map → MessageBubble-BEjK1-bi.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-DY1p8v4p.js → PlanGraphPanel-AqFEg7Yg.js} +2 -2
- package/public/assets/{PlanGraphPanel-DY1p8v4p.js.map → PlanGraphPanel-AqFEg7Yg.js.map} +1 -1
- package/public/assets/{activity-rIxqUtFC.js → activity-D5EZw4bj.js} +2 -2
- package/public/assets/{activity-rIxqUtFC.js.map → activity-D5EZw4bj.js.map} +1 -1
- package/public/assets/agent-profiles-CSISRm_L.js +2 -0
- package/public/assets/agent-profiles-CSISRm_L.js.map +1 -0
- package/public/assets/agents-szyYnkgD.js +2 -0
- package/public/assets/agents-szyYnkgD.js.map +1 -0
- 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-B0cyCT5l.js → automation-DVSq4A1b.js} +2 -2
- package/public/assets/{automation-B0cyCT5l.js.map → automation-DVSq4A1b.js.map} +1 -1
- package/public/assets/branch-state-badge-DgwkJ2rN.js +2 -0
- package/public/assets/branch-state-badge-DgwkJ2rN.js.map +1 -0
- 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-ZwgTH3jh.js → formatted-body-impl-Csr2DuEk.js} +2 -2
- package/public/assets/{formatted-body-impl-ZwgTH3jh.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-C3mlXZdd.js → insights-D2_Qry06.js} +2 -2
- package/public/assets/{insights-C3mlXZdd.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-DL1lnqmG.js.map → maintenance-Dm56u8F9.js.map} +1 -1
- package/public/assets/{managed-agents-7A6WH4Rn.js → managed-agents-DpX-cHsm.js} +2 -2
- package/public/assets/{managed-agents-7A6WH4Rn.js.map → managed-agents-DpX-cHsm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-THat8xv0.js → markdown-preview-impl-Df6EWwbg.js} +2 -2
- package/public/assets/{markdown-preview-impl-THat8xv0.js.map → markdown-preview-impl-Df6EWwbg.js.map} +1 -1
- package/public/assets/{memory-C349UJe9.js → memory-b2AgvV7H.js} +2 -2
- package/public/assets/{memory-C349UJe9.js.map → memory-b2AgvV7H.js.map} +1 -1
- package/public/assets/{messages-DXoLFj2P.js → messages-C5yDD_oq.js} +2 -2
- package/public/assets/{messages-DXoLFj2P.js.map → messages-C5yDD_oq.js.map} +1 -1
- package/public/assets/{orchestrators-CTXu4jAn.js → orchestrators-D3LecmEw.js} +2 -2
- package/public/assets/{orchestrators-CTXu4jAn.js.map → orchestrators-D3LecmEw.js.map} +1 -1
- package/public/assets/{overview-dWiN6TyN.js → overview-CnGkrBNe.js} +2 -2
- package/public/assets/{overview-dWiN6TyN.js.map → overview-CnGkrBNe.js.map} +1 -1
- package/public/assets/{pairs-KIXektDv.js → pairs-DXUdZOqn.js} +2 -2
- package/public/assets/{pairs-KIXektDv.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-C-Y41yGB.js → registry-BeGP37ot.js} +2 -2
- package/public/assets/{registry-C-Y41yGB.js.map → registry-BeGP37ot.js.map} +1 -1
- package/public/assets/{security-BUymkwuj.js → security-BMh_y43L.js} +2 -2
- package/public/assets/{security-BUymkwuj.js.map → security-BMh_y43L.js.map} +1 -1
- package/public/assets/{select-DV25WRP5.js → select-Cvi5OHz0.js} +2 -2
- package/public/assets/{select-DV25WRP5.js.map → select-Cvi5OHz0.js.map} +1 -1
- package/public/assets/settings-DKSEwIVG.js +6 -0
- package/public/assets/{settings-C1En58bx.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-BkpnKA63.js → tasks-BybIAX-y.js} +2 -2
- package/public/assets/{tasks-BkpnKA63.js.map → tasks-BybIAX-y.js.map} +1 -1
- package/public/assets/{teams-D-UYp7yH.js → teams-CMteOToR.js} +2 -2
- package/public/assets/{teams-D-UYp7yH.js.map → teams-CMteOToR.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-Bif-tkvD.js → terminal-viewer-impl-DanOi7lh.js} +2 -2
- package/public/assets/{terminal-viewer-impl-Bif-tkvD.js.map → terminal-viewer-impl-DanOi7lh.js.map} +1 -1
- package/public/assets/{user-avatar--czLv_AJ.js → user-avatar-DG629icI.js} +2 -2
- package/public/assets/{user-avatar--czLv_AJ.js.map → user-avatar-DG629icI.js.map} +1 -1
- package/public/assets/{work-queue-DN4oTe7w.js → work-queue-DqW80DIy.js} +2 -2
- package/public/assets/{work-queue-DN4oTe7w.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/runner/plugins/claude/hooks/kill-guard.sh +300 -0
- package/runner/plugins/claude/hooks/read-only-bash-guard.sh +38 -140
- package/runner/src/adapter.ts +2 -2
- package/src/agent-death-diagnosis.ts +12 -3
- package/src/bus.ts +6 -45
- package/src/config-store.ts +13 -9
- package/src/db/provider-quota-burn.ts +79 -6
- package/src/issue-tracker/adapter.ts +11 -1
- package/src/issue-tracker/github.ts +83 -2
- package/src/issue-tracker/index.ts +1 -0
- package/src/lifecycle-manager.ts +9 -22
- package/src/provider-state.ts +9 -18
- package/src/provider-timeline-event.ts +70 -0
- package/src/quota-advisory.ts +4 -7
- package/src/quota-band-transition.ts +101 -11
- package/src/routes/agent-sessions.ts +4 -2
- package/src/services/issue-lifecycle.ts +54 -8
- package/src/services/provider-state-audit.ts +3 -3
- package/src/services/rate-limit-resume.ts +6 -4
- package/src/services/register-agent.ts +2 -1
- package/src/settings/registry.ts +34 -0
- package/src/spawn-targets.ts +80 -10
- package/src/sse.ts +5 -4
- package/public/assets/agent-profiles-Cu-VkJm4.js +0 -2
- package/public/assets/agent-profiles-Cu-VkJm4.js.map +0 -1
- package/public/assets/agents-dRHOBDUe.js +0 -2
- package/public/assets/agents-dRHOBDUe.js.map +0 -1
- package/public/assets/branch-state-badge-CNko2_BL.js +0 -2
- package/public/assets/branch-state-badge-CNko2_BL.js.map +0 -1
- package/public/assets/chat-jit-e6S_.js +0 -2
- package/public/assets/chat-jit-e6S_.js.map +0 -1
- package/public/assets/index-GoC6YrKn.js +0 -23
- package/public/assets/index-GoC6YrKn.js.map +0 -1
- package/public/assets/maintenance-DL1lnqmG.js +0 -2
- package/public/assets/settings-C1En58bx.js +0 -6
- package/public/assets/store-pKbf_WYd.js +0 -9
- package/public/assets/store-pKbf_WYd.js.map +0 -1
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
// prevents rapid flapping from bursty burn rates. Low-confidence windows
|
|
8
8
|
// (burnRatio: null) are skipped entirely.
|
|
9
9
|
//
|
|
10
|
+
// #756 — asymmetric hysteresis deadband: recovery (improving) direction
|
|
11
|
+
// requires the penalty to drop HYSTERESIS_MARGIN below the band entry
|
|
12
|
+
// threshold before the transition is recognised, so a penalty parked on a
|
|
13
|
+
// boundary can't ping-pong. Recovery notifications are also rate-limited to
|
|
14
|
+
// at most one per RECOVERY_MIN_INTERVAL_MS — degradation is the actionable
|
|
15
|
+
// signal; fleet-wake on recovery churn is suppressed.
|
|
16
|
+
//
|
|
10
17
|
// Called from handleProviderQuotaAdvisory in quota-advisory.ts so the check
|
|
11
18
|
// runs on the same event hook as the utilization-threshold advisory — no
|
|
12
19
|
// parallel delivery channel.
|
|
@@ -14,15 +21,27 @@ import { emitRelayEvent } from "./events";
|
|
|
14
21
|
import { routeNotification } from "./notification-router";
|
|
15
22
|
import { getSpawnCapableAgentIds } from "./routing-policy-push";
|
|
16
23
|
import { getProviderBandInfo } from "./spawn-targets";
|
|
17
|
-
import type { ProviderBandWindow, QuotaBand } from "./spawn-targets";
|
|
24
|
+
import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
|
|
18
25
|
import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
|
|
19
26
|
|
|
20
27
|
const DEBOUNCE_SAMPLES = 3;
|
|
28
|
+
// Penalty must drop this far below the band entry threshold before recovery is recognised.
|
|
29
|
+
// #760: widened 0.05 → 0.10. Live evidence (Claude 7d parked at avoidLargeAt with burn×1.4)
|
|
30
|
+
// showed the 0.05 deadband was too tight for real penalty jitter — it still ping-ponged
|
|
31
|
+
// avoid-large → caution → avoid-large. Degradation stays faster-to-fire than recovery
|
|
32
|
+
// (recovery alone carries the rate-limit), so the actionable direction is unaffected.
|
|
33
|
+
const HYSTERESIS_MARGIN = 0.1;
|
|
34
|
+
// #760 — slack must fall this far below preferSlackAt before "prefer" is released, so a provider
|
|
35
|
+
// parked near the prefer boundary doesn't ping-pong normal↔prefer.
|
|
36
|
+
const PREFER_HYSTERESIS_MARGIN = 0.05;
|
|
37
|
+
// Minimum ms between recovery notifications — degradation fires immediately; recovery is rate-limited.
|
|
38
|
+
const RECOVERY_MIN_INTERVAL_MS = 5 * 60_000;
|
|
21
39
|
|
|
22
40
|
interface TransitionState {
|
|
23
41
|
lastNotifiedBand: QuotaBand;
|
|
24
42
|
pendingBand: QuotaBand | null;
|
|
25
43
|
pendingCount: number;
|
|
44
|
+
lastTransitionAt: number;
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
const bandState = new Map<string, TransitionState>();
|
|
@@ -31,36 +50,63 @@ export function _resetBandTransitionStateForTesting(): void {
|
|
|
31
50
|
bandState.clear();
|
|
32
51
|
}
|
|
33
52
|
|
|
53
|
+
// Test hook: supply a controlled band-info function to exercise hysteresis logic
|
|
54
|
+
// without a full DB setup. Set to null to restore the real implementation.
|
|
55
|
+
let _bandInfoSupplier: ((provider: string, now: number) => ProviderBandInfo | undefined) | null = null;
|
|
56
|
+
export function _setBandInfoSupplierForTesting(fn: (provider: string, now: number) => ProviderBandInfo | undefined): void {
|
|
57
|
+
_bandInfoSupplier = fn;
|
|
58
|
+
}
|
|
59
|
+
export function _clearBandInfoSupplierForTesting(): void {
|
|
60
|
+
_bandInfoSupplier = null;
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void {
|
|
35
|
-
const info = getProviderBandInfo(input.provider, input.now);
|
|
64
|
+
const info = (_bandInfoSupplier ?? getProviderBandInfo)(input.provider, input.now);
|
|
36
65
|
if (!info) return;
|
|
37
66
|
|
|
38
67
|
for (const window of info.windows) {
|
|
39
68
|
if (window.confidence === "low" || window.burnRatio === null) continue;
|
|
40
69
|
|
|
41
70
|
const key = `${input.provider}:${window.role}`;
|
|
42
|
-
const state: TransitionState = bandState.get(key) ?? {
|
|
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,
|
|
@@ -75,6 +121,43 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
|
|
|
75
121
|
}
|
|
76
122
|
}
|
|
77
123
|
|
|
124
|
+
// Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
|
|
125
|
+
// the penalty to fall HYSTERESIS_MARGIN below the entry threshold of the last notified band.
|
|
126
|
+
// #760: the "prefer" boundary uses a slack deadband instead (penalty is 0 throughout prefer).
|
|
127
|
+
function applyHysteresis(
|
|
128
|
+
rawBand: QuotaBand,
|
|
129
|
+
penalty: number,
|
|
130
|
+
slack: number | null,
|
|
131
|
+
lastNotifiedBand: QuotaBand,
|
|
132
|
+
quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
|
|
133
|
+
preferSlackAt: number,
|
|
134
|
+
): QuotaBand {
|
|
135
|
+
// #760 — hold "prefer" until slack falls PREFER_HYSTERESIS_MARGIN below the entry threshold;
|
|
136
|
+
// a real over-pace penalty (rawBand worse than normal) exits prefer immediately.
|
|
137
|
+
if (lastNotifiedBand === "prefer") {
|
|
138
|
+
if (rawBand === "prefer") return "prefer";
|
|
139
|
+
if (rawBand === "normal" && slack !== null && slack >= preferSlackAt - PREFER_HYSTERESIS_MARGIN) return "prefer";
|
|
140
|
+
return rawBand;
|
|
141
|
+
}
|
|
142
|
+
// #760 — entering prefer: the slack entry threshold is already applied upstream (window.prefer).
|
|
143
|
+
// Debounce + recovery rate-limit downstream keep prefer from fleet-spamming.
|
|
144
|
+
if (rawBand === "prefer") return "prefer";
|
|
145
|
+
if (bandRank(rawBand) >= bandRank(lastNotifiedBand)) return rawBand;
|
|
146
|
+
const exitThreshold = bandEntryThreshold(lastNotifiedBand, quotaBands) - HYSTERESIS_MARGIN;
|
|
147
|
+
if (penalty >= exitThreshold) return lastNotifiedBand;
|
|
148
|
+
return rawBand;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function bandEntryThreshold(
|
|
152
|
+
band: QuotaBand,
|
|
153
|
+
quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
|
|
154
|
+
): number {
|
|
155
|
+
if (band === "hard-avoid") return quotaBands.hardAvoidAt;
|
|
156
|
+
if (band === "avoid-large") return quotaBands.avoidLargeAt;
|
|
157
|
+
if (band === "caution") return quotaBands.cautionAt;
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
78
161
|
function emitBandTransition(input: {
|
|
79
162
|
provider: string;
|
|
80
163
|
window: ProviderBandWindow;
|
|
@@ -95,6 +178,9 @@ function emitBandTransition(input: {
|
|
|
95
178
|
utilization: window.utilization,
|
|
96
179
|
burnRatio: window.burnRatio,
|
|
97
180
|
projectedWallAt: window.projectedWallAt,
|
|
181
|
+
// #760 — carry the symmetric underburn signal so coordinators see "prefer", not just "avoid".
|
|
182
|
+
slack: window.slack,
|
|
183
|
+
prefer: window.band === "prefer",
|
|
98
184
|
recommendedAction,
|
|
99
185
|
};
|
|
100
186
|
|
|
@@ -134,11 +220,15 @@ function buildBody(input: {
|
|
|
134
220
|
const proj = window.projectedWallAt !== null
|
|
135
221
|
? ` (wall ~${new Date(window.projectedWallAt).toISOString()})`
|
|
136
222
|
: "";
|
|
137
|
-
|
|
223
|
+
// #760 — for the prefer band there is no early wall; surface the projected unused headroom instead.
|
|
224
|
+
const slackHint = window.band === "prefer" && window.slack !== null ? ` slack ${Math.round(window.slack * 100)}%` : "";
|
|
225
|
+
return `${providerLabel(provider)} ${window.role} quota band: ${previousBand} → ${window.band}. Utilization ${util}%${burn}${proj}${slackHint}. ${recommendedAction}`;
|
|
138
226
|
}
|
|
139
227
|
|
|
140
228
|
function bandRank(band: QuotaBand): number {
|
|
141
|
-
|
|
229
|
+
// #760 — "prefer" is the best state, ranked below "normal", so normal→prefer reads as a
|
|
230
|
+
// recovery (rate-limited; no fleet-spam) and prefer→normal reads as a degradation.
|
|
231
|
+
const ranks: Record<QuotaBand, number> = { "prefer": 0, "normal": 1, "caution": 2, "avoid-large": 3, "hard-avoid": 4 };
|
|
142
232
|
return ranks[band];
|
|
143
233
|
}
|
|
144
234
|
|
|
@@ -13,6 +13,7 @@ import { emitAgentStatus, emitDashboardPresence, emitNewMessage } from "../sse";
|
|
|
13
13
|
import { getAgentProfile, getSpawnPolicy } from "../config-store";
|
|
14
14
|
import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
|
|
15
15
|
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
16
|
+
import { agentProviderState } from "../provider-state";
|
|
16
17
|
import { authContextFromRequest } from "../services/auth-context";
|
|
17
18
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
|
|
18
19
|
import { type AgentCard, type SpawnApprovalMode } from "../types";
|
|
@@ -414,8 +415,9 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
|
|
|
414
415
|
const arb = arbitrateDrive(requestUserId(req), agent.id);
|
|
415
416
|
if (!arb.allowed) return driveBlockedResponse(arb);
|
|
416
417
|
if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
|
|
417
|
-
const
|
|
418
|
-
|
|
418
|
+
const providerState = agentProviderState(agent);
|
|
419
|
+
const pendingApproval = isRecord(providerState?.pendingApproval)
|
|
420
|
+
? providerState.pendingApproval
|
|
419
421
|
: null;
|
|
420
422
|
const pendingId = typeof pendingApproval?.id === "string" ? pendingApproval.id : "";
|
|
421
423
|
if (pendingId && pendingId !== approvalId) return error("approval request is no longer current", 409);
|
|
@@ -15,7 +15,7 @@ import { defaultIssueRepo } from "../config";
|
|
|
15
15
|
// existing importers and tests keep their entry point.
|
|
16
16
|
import { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests } from "./git-remote";
|
|
17
17
|
export { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests };
|
|
18
|
-
import type { IssueTrackerAdapter } from "../issue-tracker";
|
|
18
|
+
import type { IssueTrackerAdapter, IssueTrackerIssueSummary } from "../issue-tracker";
|
|
19
19
|
import { recordAndEmitIssueActivity } from "./issue-activity";
|
|
20
20
|
import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
|
|
21
21
|
|
|
@@ -208,14 +208,41 @@ function closeAuditComment(input: LandedIssueLifecycleInput): string {
|
|
|
208
208
|
return `Closed by \`${commit}\` on merge to \`main\` - reopen if release surfaces more work.`;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
function issueLabel(ref: IssueRef, parent: IssueRef): string {
|
|
211
|
+
function issueLabel(ref: Pick<IssueRef, "repo" | "externalKey">, parent: IssueRef): string {
|
|
212
212
|
return ref.repo === parent.repo ? `#${ref.externalKey}` : `${ref.repo}#${ref.externalKey}`;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
function
|
|
215
|
+
function openSubIssuesComment(input: LandedIssueLifecycleInput, parent: IssueRef, openSubIssues: IssueTrackerIssueSummary[]): string {
|
|
216
216
|
const commit = input.mergedSha ? input.mergedSha.slice(0, 12) : input.workspace.branch ?? input.workspace.id;
|
|
217
217
|
const childList = openSubIssues.map((ref) => issueLabel(ref, parent)).join(", ");
|
|
218
|
-
return `
|
|
218
|
+
return `Referenced by landed commit \`${commit}\`; left open because these sub-issues are still open: ${childList}.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function issueSummaryKey(ref: Pick<IssueTrackerIssueSummary, "provider" | "repo" | "externalKey">): string {
|
|
222
|
+
return `${ref.provider}:${ref.repo}#${ref.externalKey}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function listOpenSubIssuesForLand(issueRef: IssueRef, opts: IssueLifecycleOptions): Promise<IssueTrackerIssueSummary[]> {
|
|
226
|
+
const open = new Map<string, IssueTrackerIssueSummary>();
|
|
227
|
+
const adapter = adapterFor(issueRef, opts);
|
|
228
|
+
if (adapter.listOpenSubIssues) {
|
|
229
|
+
for (const ref of await adapter.listOpenSubIssues(issueRef)) {
|
|
230
|
+
if (ref.status === "open") open.set(issueSummaryKey(ref), ref);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const item of listIssuesForEntity("epic", issueRef.id)) {
|
|
234
|
+
const ref = item.issueRef;
|
|
235
|
+
if (ref.status !== "open") continue;
|
|
236
|
+
open.set(issueSummaryKey(ref), {
|
|
237
|
+
provider: ref.provider,
|
|
238
|
+
repo: ref.repo,
|
|
239
|
+
externalKey: ref.externalKey,
|
|
240
|
+
url: ref.url,
|
|
241
|
+
title: ref.title,
|
|
242
|
+
status: "open",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return [...open.values()];
|
|
219
246
|
}
|
|
220
247
|
|
|
221
248
|
async function reconcileClosedIssueRefBeforeSkip(issueRef: IssueRef, opts: IssueLifecycleOptions): Promise<{
|
|
@@ -250,11 +277,30 @@ async function closeIssueRefForLand(
|
|
|
250
277
|
if (reconciled.liveStatus !== "open") return { issueRef: latest, action: "skipped", reason: "already-closed", landKey: key };
|
|
251
278
|
}
|
|
252
279
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
280
|
+
let openSubIssues: IssueTrackerIssueSummary[];
|
|
281
|
+
try {
|
|
282
|
+
openSubIssues = await listOpenSubIssuesForLand(latest, opts);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const message = `sub-issue lookup failed: ${errMessage(error)}`;
|
|
285
|
+
upsertIssueLifecycleAction({
|
|
286
|
+
issueRefId: latest.id,
|
|
287
|
+
action: "close",
|
|
288
|
+
landKey: key,
|
|
289
|
+
workspaceId: input.workspace.id,
|
|
290
|
+
branch: input.workspace.branch,
|
|
291
|
+
baseRef: input.workspace.baseRef ?? "main",
|
|
292
|
+
commitSha: input.mergedSha,
|
|
293
|
+
commitSubject: input.subject,
|
|
294
|
+
agentId: input.workspace.ownerAgentId,
|
|
295
|
+
source,
|
|
296
|
+
status: "failed",
|
|
297
|
+
error: message,
|
|
298
|
+
});
|
|
299
|
+
logFailure(opts, `issue lifecycle sub-issue lookup failed for ${latest.id} on ${key}: ${errMessage(error)}`);
|
|
300
|
+
return { issueRef: latest, action: "failed", error: message, landKey: key };
|
|
301
|
+
}
|
|
256
302
|
if (openSubIssues.length > 0) {
|
|
257
|
-
const comment =
|
|
303
|
+
const comment = openSubIssuesComment(input, latest, openSubIssues);
|
|
258
304
|
try {
|
|
259
305
|
await adapterFor(latest, opts).comment(latest, comment);
|
|
260
306
|
} catch (error) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createActivityEvent } from "../db";
|
|
2
2
|
import { routeNotification } from "../notification-router";
|
|
3
|
-
import { agentProviderState } from "../provider-state";
|
|
3
|
+
import { agentProviderState, type ProviderState } from "../provider-state";
|
|
4
4
|
import type { AgentCard } from "../types";
|
|
5
5
|
|
|
6
6
|
export function auditProviderStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
|
|
@@ -46,7 +46,7 @@ export function auditProviderStateTransition(agentId: string, before: AgentCard
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function providerStateKey(state:
|
|
49
|
+
function providerStateKey(state: ProviderState | null): string | null {
|
|
50
50
|
if (!state) return null;
|
|
51
|
-
return [state.state,
|
|
51
|
+
return [state.state, state.reason ?? ""].join(":");
|
|
52
52
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
agentProviderState,
|
|
7
7
|
isRateLimitHold,
|
|
8
8
|
isTransientRateLimitHold,
|
|
9
|
+
type ProviderState,
|
|
9
10
|
rateLimitResumeAt,
|
|
10
11
|
transientRateLimitAttempt,
|
|
11
12
|
transientRateLimitExhausted,
|
|
@@ -46,7 +47,7 @@ export function resetRateLimitResumeTrackerForTests(): void {
|
|
|
46
47
|
const RESUME_BODY =
|
|
47
48
|
"Your provider limit/overload hold is ready to retry — resuming. Continue the task you were working on when the provider interrupted you. If you had already finished, no action is needed.";
|
|
48
49
|
|
|
49
|
-
function nextTransientState(state:
|
|
50
|
+
function nextTransientState(state: ProviderState, now: number, attempts: number): ProviderState {
|
|
50
51
|
const nextAttempts = attempts + 1;
|
|
51
52
|
const nextDelay = TRANSIENT_RATE_LIMIT_BACKOFF_MS[nextAttempts];
|
|
52
53
|
return {
|
|
@@ -58,7 +59,7 @@ function nextTransientState(state: Record<string, unknown>, now: number, attempt
|
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function notifyTransientExhausted(agent: { id: string; label?: string | null; spawnedBy?: string | null }, state:
|
|
62
|
+
function notifyTransientExhausted(agent: { id: string; label?: string | null; spawnedBy?: string | null }, state: ProviderState, now: number): void {
|
|
62
63
|
mergeAgentMeta(agent.id, { providerState: { ...state, exhaustedAt: now, updatedAt: now } });
|
|
63
64
|
if (!agent.spawnedBy) return;
|
|
64
65
|
routeNotification({
|
|
@@ -88,6 +89,7 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
|
|
|
88
89
|
seen.add(agent.id);
|
|
89
90
|
|
|
90
91
|
const state = agentProviderState(agent);
|
|
92
|
+
if (!state) continue;
|
|
91
93
|
const resumeAt = rateLimitResumeAt(state);
|
|
92
94
|
if (resumeAt !== null && now < resumeAt) { waiting.push(agent.id); continue; }
|
|
93
95
|
|
|
@@ -96,7 +98,7 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
|
|
|
96
98
|
const attempts = transient ? Math.max(transientRateLimitAttempt(state), tracked.attempts ?? 0) : 0;
|
|
97
99
|
if (transient && (attempts >= TRANSIENT_RATE_LIMIT_BACKOFF_MS.length || transientRateLimitExhausted(state))) {
|
|
98
100
|
if (!tracked.exhaustedNotified) {
|
|
99
|
-
notifyTransientExhausted(agent, state
|
|
101
|
+
notifyTransientExhausted(agent, state, now);
|
|
100
102
|
resumeTracker.set(agent.id, { ...tracked, attempts, exhaustedNotified: true });
|
|
101
103
|
}
|
|
102
104
|
waiting.push(agent.id);
|
|
@@ -133,7 +135,7 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
|
|
|
133
135
|
continue;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
if (transient) mergeAgentMeta(agent.id, { providerState: nextTransientState(state
|
|
138
|
+
if (transient) mergeAgentMeta(agent.id, { providerState: nextTransientState(state, now, attempts) });
|
|
137
139
|
|
|
138
140
|
// #416 — flush messages queued while the agent was rate-limited.
|
|
139
141
|
flushQueuedDirectMessages(agent.id);
|
|
@@ -13,6 +13,7 @@ import { isReservedAgentId } from "agent-relay-sdk";
|
|
|
13
13
|
import { ValidationError, createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
|
|
14
14
|
import { emitAgentStatus, emitNewMessage } from "../sse";
|
|
15
15
|
import { noteAgentTimelineEvent } from "../compaction-watch";
|
|
16
|
+
import { agentProviderTimelineEvent } from "../provider-timeline-event";
|
|
16
17
|
import { notifyAgentReady } from "../agent-lifecycle-events";
|
|
17
18
|
import { resolveSpawnLineage, resolveTeamLineage } from "../security";
|
|
18
19
|
import { flushQueuedDirectMessages, markManagedAgentRunning } from "./managed-running";
|
|
@@ -65,7 +66,7 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
|
|
|
65
66
|
// PreCompact/SessionStart hook arrives as a latched meta.timelineEvent — clears any
|
|
66
67
|
// pending stall watch; the timestamp guard inside ignores stale latched events.
|
|
67
68
|
emitAgentStatus(agent.id);
|
|
68
|
-
noteAgentTimelineEvent(agent.id, agent
|
|
69
|
+
noteAgentTimelineEvent(agent.id, agentProviderTimelineEvent(agent));
|
|
69
70
|
|
|
70
71
|
// #308/#351 — a spawned child registering already-ready (the common isolated-worktree
|
|
71
72
|
// case) wakes its block-polling parent. Fire on the first-ever ready, regardless of
|
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,7 +349,7 @@ function quotaPenaltyBandFor(
|
|
|
285
349
|
return "normal";
|
|
286
350
|
}
|
|
287
351
|
|
|
288
|
-
function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; windows: SpawnQuotaBurnWindow[] }): string {
|
|
352
|
+
function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; prefer: boolean; slack: number | null; windows: SpawnQuotaBurnWindow[] }): string {
|
|
289
353
|
if (input.blockedNow) {
|
|
290
354
|
const hasSevenDayBlock = input.windows.some((window) => window.role === "7d" && window.blockedNow);
|
|
291
355
|
return hasSevenDayBlock
|
|
@@ -295,6 +359,11 @@ function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blocke
|
|
|
295
359
|
if (input.quotaPenaltyBand === "hard-avoid") return "Hard avoid unless explicitly requested or no healthier alternate exists.";
|
|
296
360
|
if (input.quotaPenaltyBand === "avoid-large") return "Avoid large jobs if a healthier alternate exists.";
|
|
297
361
|
if (input.quotaPenaltyBand === "caution") return "Caution: prefer cheaper effort/model when practical.";
|
|
362
|
+
// #760 — surface the underburn signal so coordinators load-shift big/deferrable jobs here.
|
|
363
|
+
if (input.quotaPenaltyBand === "prefer") {
|
|
364
|
+
const headroom = input.slack !== null ? ` (~${Math.round(input.slack * 100)}% quota would otherwise go unused at reset)` : "";
|
|
365
|
+
return `Prefer for large or deferrable jobs${headroom}.`;
|
|
366
|
+
}
|
|
298
367
|
return "Spawn normally.";
|
|
299
368
|
}
|
|
300
369
|
|
|
@@ -306,6 +375,7 @@ function spawnProfilesSummary(): Array<Record<string, unknown>> {
|
|
|
306
375
|
return listAgentProfiles().map(({ key, value }) => ({
|
|
307
376
|
name: value.name ?? key,
|
|
308
377
|
description: value.description,
|
|
378
|
+
...(value.icon ? { icon: value.icon } : {}),
|
|
309
379
|
provider: value.provider,
|
|
310
380
|
approvalMode: value.permissions?.mode,
|
|
311
381
|
...(value.maxSpawnedAgents !== undefined ? { maxSpawnedAgents: value.maxSpawnedAgents } : {}),
|