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
package/src/config-store.ts
CHANGED
|
@@ -72,15 +72,15 @@ const BUILT_IN_AGENT_PROFILE_NAMES = new Set([DEFAULT_SPAWN_PROFILE_NAME, "minim
|
|
|
72
72
|
const WORKER_TOOL_GROUPS = ["core", "workspace", "memory", "artifacts", "blackboard", "subscriptions"];
|
|
73
73
|
|
|
74
74
|
const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
|
|
75
|
-
agentProfileDefaults({ name: DEFAULT_SPAWN_PROFILE_NAME, description: "Relay-aware spawned agent with bundled Relay context, skills, plugins, and status integration.", base: "host", mcp: { mode: "none" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
|
|
76
|
-
agentProfileDefaults({ name: "minimal", description: "Repo-focused spawned agent with Relay affordances disabled and host/global extras avoided where supported.", base: "minimal" }),
|
|
77
|
-
agentProfileDefaults({ name: "isolated-research", description: "Strict profile for autonomous research runs: repo context only, no Relay prompt surface, no global extras where supported.", base: "isolated" }),
|
|
78
|
-
agentProfileDefaults({ name: "vanilla", description: "Zero host bleed (#552): isolated provider home with host plugins, skills, hooks, MCP, and the CLAUDE.md/AGENTS.md cascade (repo + global) all suppressed at launch. Relay provisions any surface explicitly per profile.", base: "vanilla" }),
|
|
79
|
-
agentProfileDefaults({ name: "lean-worker", description: "Lean worker profile (#570): vanilla host-dependency drop with only Relay context/MCP restored and Project knowledge injected from the spawn cwd.", base: "vanilla", relay: { context: true, skills: false, plugins: false, statusLine: false, mcp: true, tools: { groups: WORKER_TOOL_GROUPS } }, projectInstructions: { policy: "all" } }),
|
|
80
|
-
agentProfileDefaults({ name: "coordinator", description: "Relay-aware coordination lead for spawning worker teams, maintaining shared state, and driving review and release.", base: "host", instructions: { append: [COORDINATION_CHARTER], repoInstructions: "allow", globalInstructions: "allow" }, maxSpawnedAgents: 8 }),
|
|
81
|
-
agentProfileDefaults({ name: "ops-assistant", description: "Relay-aware ops lieutenant for a coordinator: runs tests, releases, smoke probes, gates, and host checks, then reports crisp outcomes back up.", provider: "codex", base: "host", instructions: { append: [OPS_ASSISTANT_BRIEF], repoInstructions: "allow", globalInstructions: "allow" }, permissions: { mode: "open", filesystem: "host" } }),
|
|
82
|
-
agentProfileDefaults({ name: "worker", description: "Relay-aware focused implementer with open approval posture and no delegated spawn capability.", base: "host", mcp: { mode: "none" }, permissions: { mode: "open", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
|
|
83
|
-
agentProfileDefaults({ name: "reviewer", description: "Relay-aware read-only reviewer for audit and review passes without file writes.", base: "host", mcp: { mode: "none" }, permissions: { mode: "read-only", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
|
|
75
|
+
agentProfileDefaults({ name: DEFAULT_SPAWN_PROFILE_NAME, icon: "🤖", description: "Relay-aware spawned agent with bundled Relay context, skills, plugins, and status integration.", base: "host", mcp: { mode: "none" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
|
|
76
|
+
agentProfileDefaults({ name: "minimal", icon: "▫️", description: "Repo-focused spawned agent with Relay affordances disabled and host/global extras avoided where supported.", base: "minimal" }),
|
|
77
|
+
agentProfileDefaults({ name: "isolated-research", icon: "🔬", description: "Strict profile for autonomous research runs: repo context only, no Relay prompt surface, no global extras where supported.", base: "isolated" }),
|
|
78
|
+
agentProfileDefaults({ name: "vanilla", icon: "🫥", description: "Zero host bleed (#552): isolated provider home with host plugins, skills, hooks, MCP, and the CLAUDE.md/AGENTS.md cascade (repo + global) all suppressed at launch. Relay provisions any surface explicitly per profile.", base: "vanilla" }),
|
|
79
|
+
agentProfileDefaults({ name: "lean-worker", icon: "🪶", description: "Lean worker profile (#570): vanilla host-dependency drop with only Relay context/MCP restored and Project knowledge injected from the spawn cwd.", base: "vanilla", relay: { context: true, skills: false, plugins: false, statusLine: false, mcp: true, tools: { groups: WORKER_TOOL_GROUPS } }, projectInstructions: { policy: "all" } }),
|
|
80
|
+
agentProfileDefaults({ name: "coordinator", icon: "🧭", description: "Relay-aware coordination lead for spawning worker teams, maintaining shared state, and driving review and release.", base: "host", instructions: { append: [COORDINATION_CHARTER], repoInstructions: "allow", globalInstructions: "allow" }, maxSpawnedAgents: 8 }),
|
|
81
|
+
agentProfileDefaults({ name: "ops-assistant", icon: "🛠️", description: "Relay-aware ops lieutenant for a coordinator: runs tests, releases, smoke probes, gates, and host checks, then reports crisp outcomes back up.", provider: "codex", base: "host", instructions: { append: [OPS_ASSISTANT_BRIEF], repoInstructions: "allow", globalInstructions: "allow" }, permissions: { mode: "open", filesystem: "host" } }),
|
|
82
|
+
agentProfileDefaults({ name: "worker", icon: "🔨", description: "Relay-aware focused implementer with open approval posture and no delegated spawn capability.", base: "host", mcp: { mode: "none" }, permissions: { mode: "open", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
|
|
83
|
+
agentProfileDefaults({ name: "reviewer", icon: "🔍", description: "Relay-aware read-only reviewer for audit and review passes without file writes.", base: "host", mcp: { mode: "none" }, permissions: { mode: "read-only", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
|
|
84
84
|
].map((profile) => ({ ...profile, builtIn: true }));
|
|
85
85
|
|
|
86
86
|
interface ConfigRow {
|
|
@@ -286,6 +286,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Omit<
|
|
|
286
286
|
return {
|
|
287
287
|
name: input.name,
|
|
288
288
|
description: input.description,
|
|
289
|
+
...(input.icon === undefined ? {} : { icon: input.icon }),
|
|
289
290
|
provider: input.provider ?? "any",
|
|
290
291
|
base: input.base,
|
|
291
292
|
builtIn: input.builtIn,
|
|
@@ -361,6 +362,9 @@ function validateAgentProfile(key: string, value: unknown, opts: { allowBuiltIn?
|
|
|
361
362
|
return agentProfileDefaults({
|
|
362
363
|
...defaults,
|
|
363
364
|
description: cleanString(value.description, "description", { max: 1000 }),
|
|
365
|
+
// #751: per-profile emoji icon. Short free string (single emoji incl. ZWJ
|
|
366
|
+
// sequences); cap length so it can't smuggle arbitrary text. Empty → undefined.
|
|
367
|
+
icon: cleanString(value.icon, "icon", { max: 8 }) || undefined,
|
|
364
368
|
provider: value.provider === undefined || value.provider === null
|
|
365
369
|
? defaults.provider
|
|
366
370
|
: cleanEnum(value.provider, "provider", VALID_PROFILE_PROVIDERS),
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
|
|
1
2
|
import type { ProviderQuotaBurn, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaSnapshot, QuotaState } from "../types";
|
|
2
3
|
|
|
3
4
|
const QUOTA_BURN_MIN_SAMPLE_SPAN_MS = 10 * 60_000;
|
|
4
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
|
+
|
|
5
18
|
type QuotaWindow = QuotaState["windows"][number];
|
|
6
19
|
|
|
7
20
|
// Provider-agnostic: window role is expressed by name only. Each adapter uses the
|
|
@@ -35,9 +48,38 @@ function lowConfidenceBurnWindow(role: ProviderQuotaBurnRole, window: QuotaWindo
|
|
|
35
48
|
timeToWallMs: null,
|
|
36
49
|
projectedWallAt: null,
|
|
37
50
|
earlyWallMs: null,
|
|
51
|
+
observedRatePerMs: null,
|
|
52
|
+
projectedUtilAtReset: null,
|
|
53
|
+
slack: null,
|
|
54
|
+
target: null,
|
|
38
55
|
};
|
|
39
56
|
}
|
|
40
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
|
+
|
|
41
83
|
function deriveProviderQuotaBurnWindow(input: {
|
|
42
84
|
provider: string;
|
|
43
85
|
currentWindow: QuotaWindow;
|
|
@@ -78,16 +120,42 @@ function deriveProviderQuotaBurnWindow(input: {
|
|
|
78
120
|
return low;
|
|
79
121
|
}
|
|
80
122
|
|
|
81
|
-
const remainingQuota =
|
|
123
|
+
const remainingQuota = quotaWindowMetrics(input.currentWindow).remaining;
|
|
82
124
|
const timeToResetMs = resetAt - input.now;
|
|
83
125
|
if (remainingQuota <= 0 || timeToResetMs <= 0) return low;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
87
151
|
return {
|
|
88
152
|
...low,
|
|
89
153
|
confidence: "high",
|
|
90
154
|
burnRatio: 0,
|
|
155
|
+
observedRatePerMs: 0,
|
|
156
|
+
projectedUtilAtReset: pace.projectedUtilAtReset,
|
|
157
|
+
slack: pace.slack,
|
|
158
|
+
target: pace.target,
|
|
91
159
|
};
|
|
92
160
|
}
|
|
93
161
|
const timeToWallMs = remainingQuota / observedRatePerMs;
|
|
@@ -95,10 +163,15 @@ function deriveProviderQuotaBurnWindow(input: {
|
|
|
95
163
|
return {
|
|
96
164
|
...low,
|
|
97
165
|
confidence: "high",
|
|
98
|
-
burnRatio:
|
|
166
|
+
burnRatio: pace.burnRatio,
|
|
167
|
+
observedRatePerMs,
|
|
99
168
|
timeToWallMs,
|
|
100
169
|
projectedWallAt,
|
|
101
|
-
|
|
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,
|
|
102
175
|
};
|
|
103
176
|
}
|
|
104
177
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IssueRef, IssueRefStatus } from "../types";
|
|
1
|
+
import type { IssueProvider, IssueRef, IssueRefStatus } from "../types";
|
|
2
2
|
|
|
3
3
|
export interface IssueTrackerStatus {
|
|
4
4
|
status: IssueRefStatus;
|
|
@@ -14,9 +14,19 @@ export interface IssueTrackerReopenOptions {
|
|
|
14
14
|
comment?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface IssueTrackerIssueSummary {
|
|
18
|
+
provider: IssueProvider;
|
|
19
|
+
repo: string;
|
|
20
|
+
externalKey: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
status: IssueRefStatus;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export interface IssueTrackerAdapter {
|
|
18
27
|
resolveLink(ref: IssueRef): string;
|
|
19
28
|
getStatus(ref: IssueRef): Promise<IssueTrackerStatus>;
|
|
29
|
+
listOpenSubIssues?(ref: IssueRef): Promise<IssueTrackerIssueSummary[]>;
|
|
20
30
|
close(ref: IssueRef, options?: IssueTrackerCloseOptions): Promise<void>;
|
|
21
31
|
reopen(ref: IssueRef, options?: IssueTrackerReopenOptions): Promise<void>;
|
|
22
32
|
comment(ref: IssueRef, body: string): Promise<void>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IssueRef, IssueRefStatus } from "../types";
|
|
2
|
-
import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerReopenOptions, IssueTrackerStatus } from "./adapter";
|
|
2
|
+
import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerIssueSummary, IssueTrackerReopenOptions, IssueTrackerStatus } from "./adapter";
|
|
3
3
|
|
|
4
4
|
export const GITHUB_FIXED_LABEL = "fixed";
|
|
5
5
|
const GITHUB_CREDENTIAL_REQUIREMENT = "GitHub issue tracker adapter requires gh authenticated with a token that has issues:write scope";
|
|
@@ -20,6 +20,50 @@ interface GitHubIssuePayload {
|
|
|
20
20
|
html_url?: unknown;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
interface GitHubSubIssueNode {
|
|
24
|
+
number?: unknown;
|
|
25
|
+
title?: unknown;
|
|
26
|
+
state?: unknown;
|
|
27
|
+
url?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface GitHubSubIssuesPayload {
|
|
31
|
+
data?: {
|
|
32
|
+
repository?: {
|
|
33
|
+
issue?: {
|
|
34
|
+
subIssues?: {
|
|
35
|
+
nodes?: GitHubSubIssueNode[];
|
|
36
|
+
pageInfo?: {
|
|
37
|
+
hasNextPage?: unknown;
|
|
38
|
+
endCursor?: unknown;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SUB_ISSUES_QUERY = `
|
|
47
|
+
query($owner: String!, $repo: String!, $number: Int!, $after: String) {
|
|
48
|
+
repository(owner: $owner, name: $repo) {
|
|
49
|
+
issue(number: $number) {
|
|
50
|
+
subIssues(first: 100, after: $after) {
|
|
51
|
+
nodes {
|
|
52
|
+
number
|
|
53
|
+
title
|
|
54
|
+
state
|
|
55
|
+
url
|
|
56
|
+
}
|
|
57
|
+
pageInfo {
|
|
58
|
+
hasNextPage
|
|
59
|
+
endCursor
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
|
|
23
67
|
function textFromBytes(value: unknown): string {
|
|
24
68
|
if (!value) return "";
|
|
25
69
|
if (typeof value === "string") return value;
|
|
@@ -78,7 +122,10 @@ function assertGitHubIssueRef(ref: IssueRef): void {
|
|
|
78
122
|
}
|
|
79
123
|
|
|
80
124
|
function statusFromState(state: unknown): IssueRefStatus {
|
|
81
|
-
if (
|
|
125
|
+
if (typeof state === "string") {
|
|
126
|
+
const normalized = state.toLowerCase();
|
|
127
|
+
if (normalized === "open" || normalized === "closed") return normalized;
|
|
128
|
+
}
|
|
82
129
|
return "unknown";
|
|
83
130
|
}
|
|
84
131
|
|
|
@@ -105,6 +152,40 @@ class GitHubIssueTrackerAdapter implements IssueTrackerAdapter {
|
|
|
105
152
|
return { status: statusFromState(issue.state), title: titleFromIssue(issue) };
|
|
106
153
|
}
|
|
107
154
|
|
|
155
|
+
async listOpenSubIssues(ref: IssueRef): Promise<IssueTrackerIssueSummary[]> {
|
|
156
|
+
assertGitHubIssueRef(ref);
|
|
157
|
+
const [owner, repo] = ref.repo.split("/") as [string, string];
|
|
158
|
+
const open: IssueTrackerIssueSummary[] = [];
|
|
159
|
+
let after: string | undefined;
|
|
160
|
+
|
|
161
|
+
do {
|
|
162
|
+
const response = await this.api({
|
|
163
|
+
method: "POST",
|
|
164
|
+
path: "graphql",
|
|
165
|
+
body: {
|
|
166
|
+
query: SUB_ISSUES_QUERY,
|
|
167
|
+
owner,
|
|
168
|
+
repo,
|
|
169
|
+
number: Number(ref.externalKey),
|
|
170
|
+
after,
|
|
171
|
+
},
|
|
172
|
+
}) as GitHubSubIssuesPayload;
|
|
173
|
+
const subIssues = response.data?.repository?.issue?.subIssues;
|
|
174
|
+
for (const node of subIssues?.nodes ?? []) {
|
|
175
|
+
if (statusFromState(node.state) !== "open") continue;
|
|
176
|
+
const number = typeof node.number === "number" || typeof node.number === "string" ? String(node.number) : "";
|
|
177
|
+
if (!number) continue;
|
|
178
|
+
const title = typeof node.title === "string" ? node.title : undefined;
|
|
179
|
+
const url = typeof node.url === "string" ? node.url : `https://github.com/${ref.repo}/issues/${number}`;
|
|
180
|
+
open.push({ provider: "github", repo: ref.repo, externalKey: number, url, title, status: "open" });
|
|
181
|
+
}
|
|
182
|
+
const pageInfo = subIssues?.pageInfo;
|
|
183
|
+
after = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : undefined;
|
|
184
|
+
} while (after);
|
|
185
|
+
|
|
186
|
+
return open;
|
|
187
|
+
}
|
|
188
|
+
|
|
108
189
|
async close(ref: IssueRef, options: IssueTrackerCloseOptions = {}): Promise<void> {
|
|
109
190
|
await this.api({ method: "PATCH", path: githubIssuePath(ref), body: { state: "closed" } });
|
|
110
191
|
await this.applyLabels(ref, [GITHUB_FIXED_LABEL, ...(options.labels ?? [])]);
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCommand } from "./commands-db";
|
|
2
|
-
import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
|
|
3
2
|
import { isPathWithinBase } from "./utils";
|
|
3
|
+
import { agentProviderState } from "./provider-state";
|
|
4
4
|
import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces } from "./db";
|
|
5
5
|
import {
|
|
6
6
|
getManagedAgentState,
|
|
@@ -413,7 +413,10 @@ export class LifecycleManager {
|
|
|
413
413
|
|
|
414
414
|
private markPolicyFailure(policy: SpawnPolicy, state: ManagedAgentState, error: string, exited?: ManagedSessionExitDiagnostics): void {
|
|
415
415
|
if (state.status === "stopping") return;
|
|
416
|
-
|
|
416
|
+
// Read the terminal reason from the adapter-surfaced typed provider state rather
|
|
417
|
+
// than re-parsing raw provider log text (#745 / #725 §4.4 leak #13).
|
|
418
|
+
const agentId = state.agentId || exited?.agentId;
|
|
419
|
+
const modelUnavailable = agentId ? agentTerminalFailureMessage(getAgent(agentId)) : null;
|
|
417
420
|
if (modelUnavailable) {
|
|
418
421
|
this.markFailed(policy, state, modelUnavailable);
|
|
419
422
|
return;
|
|
@@ -622,34 +625,18 @@ export function __resetLifecycleManager(): void {
|
|
|
622
625
|
singleton = null;
|
|
623
626
|
}
|
|
624
627
|
|
|
625
|
-
function managedModelUnavailableMessage(_policy: SpawnPolicy, error: string, exited?: ManagedSessionExitDiagnostics): string | null {
|
|
626
|
-
// extractClaudeModelUnavailableMessage returns null for text that doesn't match
|
|
627
|
-
// the Claude-specific error pattern, so this is safe to call unconditionally —
|
|
628
|
-
// no provider=== branch needed here; the parser is the gate (#743).
|
|
629
|
-
return extractClaudeModelUnavailableMessage([
|
|
630
|
-
error,
|
|
631
|
-
exited?.lastError,
|
|
632
|
-
...(exited?.logTail ?? []),
|
|
633
|
-
...(exited?.unavailable ?? []),
|
|
634
|
-
].join("\n"));
|
|
635
|
-
}
|
|
636
|
-
|
|
637
628
|
function isFastFailStorm(failures: number, exited?: ManagedSessionExitDiagnostics): boolean {
|
|
638
629
|
return failures >= MAX_FAST_FAILURES && exited !== undefined && exited.runtimeMs < FAST_FAIL_RUNTIME_MS;
|
|
639
630
|
}
|
|
640
631
|
|
|
632
|
+
// Returns the model-unavailable message when the agent's adapter-surfaced typed provider
|
|
633
|
+
// state indicates a terminal model-unavailable failure (#745 / #725 §4.4 leak #13).
|
|
641
634
|
function agentTerminalFailureMessage(agent: ReturnType<typeof getAgent> | null): string | null {
|
|
642
635
|
if (!agent?.meta) return null;
|
|
643
636
|
const reason = typeof agent.meta.terminalFailureReason === "string" ? agent.meta.terminalFailureReason : undefined;
|
|
644
637
|
const message = typeof agent.meta.terminalFailureMessage === "string" ? agent.meta.terminalFailureMessage : undefined;
|
|
645
638
|
if (reason === "model-unavailable" && message) return message;
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
const stateMessage = typeof providerState?.message === "string" ? providerState.message : undefined;
|
|
649
|
-
if (stateReason === "model-unavailable" && stateMessage) return stateMessage;
|
|
639
|
+
const ps = agentProviderState(agent);
|
|
640
|
+
if (ps?.reason === "model-unavailable" && ps.message) return ps.message;
|
|
650
641
|
return null;
|
|
651
642
|
}
|
|
652
|
-
|
|
653
|
-
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
654
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
655
|
-
}
|
package/src/provider-state.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import type { ProviderState } from "agent-relay-sdk";
|
|
2
3
|
import { rateLimitFallbackMs } from "./config";
|
|
3
4
|
|
|
4
5
|
// One server-side home for reading an agent's provider runtime state (the
|
|
@@ -7,28 +8,18 @@ import { rateLimitFallbackMs } from "./config";
|
|
|
7
8
|
// the transient-agent reaper (don't reap a held agent), and the rate-limit
|
|
8
9
|
// resume sweep. The dashboard has its own client-side copy in display.ts.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
reason?: string;
|
|
13
|
-
label?: string;
|
|
14
|
-
/** Unix ms the provider's limit window resets (rate-limit holds). */
|
|
15
|
-
resetAt?: number;
|
|
16
|
-
/** Unix ms the hold was entered (used for the resume fallback when resetAt is absent). */
|
|
17
|
-
enteredAt?: number;
|
|
18
|
-
/** Raw provider error_type, kept for empirical observation of which type a real limit fires. */
|
|
19
|
-
errorType?: string;
|
|
20
|
-
[key: string]: unknown;
|
|
21
|
-
}
|
|
11
|
+
// Re-export so callers don't need to import from two places.
|
|
12
|
+
export type { ProviderState };
|
|
22
13
|
|
|
23
14
|
interface HasMeta {
|
|
24
15
|
meta?: Record<string, unknown> | null;
|
|
25
16
|
}
|
|
26
17
|
|
|
27
18
|
/** The agent's provider runtime state, or null when none is set / malformed. */
|
|
28
|
-
export function agentProviderState(agent: HasMeta | null | undefined):
|
|
19
|
+
export function agentProviderState(agent: HasMeta | null | undefined): ProviderState | null {
|
|
29
20
|
const value = agent?.meta?.providerState;
|
|
30
21
|
if (!isRecord(value) || typeof value.state !== "string") return null;
|
|
31
|
-
return value as
|
|
22
|
+
return value as ProviderState;
|
|
32
23
|
}
|
|
33
24
|
|
|
34
25
|
/** True when the agent is in any blocked provider state (rate-limit, approval, model-unavailable). */
|
|
@@ -47,16 +38,16 @@ export function isRateLimitHold(agent: HasMeta | null | undefined): boolean {
|
|
|
47
38
|
return state?.state === "blocked" && state.reason === RATE_LIMIT_BLOCK_REASON;
|
|
48
39
|
}
|
|
49
40
|
|
|
50
|
-
export function isTransientRateLimitHold(state:
|
|
41
|
+
export function isTransientRateLimitHold(state: ProviderState | null): boolean {
|
|
51
42
|
return state?.kind === TRANSIENT_RATE_LIMIT_KIND;
|
|
52
43
|
}
|
|
53
44
|
|
|
54
|
-
export function transientRateLimitAttempt(state:
|
|
45
|
+
export function transientRateLimitAttempt(state: ProviderState | null): number {
|
|
55
46
|
const raw = state?.resumeAttempt;
|
|
56
47
|
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
|
|
57
48
|
}
|
|
58
49
|
|
|
59
|
-
export function transientRateLimitExhausted(state:
|
|
50
|
+
export function transientRateLimitExhausted(state: ProviderState | null): boolean {
|
|
60
51
|
return isTransientRateLimitHold(state) && transientRateLimitAttempt(state) >= TRANSIENT_RATE_LIMIT_BACKOFF_MS.length;
|
|
61
52
|
}
|
|
62
53
|
|
|
@@ -69,7 +60,7 @@ export function transientRateLimitExhausted(state: ProviderStateView | null): bo
|
|
|
69
60
|
* authoritative; otherwise fall back to `enteredAt + fallback window`. Null only
|
|
70
61
|
* when neither is known (shouldn't happen — the runner always stamps enteredAt).
|
|
71
62
|
*/
|
|
72
|
-
export function rateLimitResumeAt(state:
|
|
63
|
+
export function rateLimitResumeAt(state: ProviderState | null): number | null {
|
|
73
64
|
if (!state) return null;
|
|
74
65
|
if (isTransientRateLimitHold(state)) {
|
|
75
66
|
if (typeof state.nextResumeAt === "number" && Number.isFinite(state.nextResumeAt)) return state.nextResumeAt;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
2
|
+
import { createActivityEvent } from "./db";
|
|
3
|
+
import { emitRelayEvent } from "./events";
|
|
4
|
+
|
|
5
|
+
interface HasMeta {
|
|
6
|
+
meta?: Record<string, unknown> | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ProviderTimelineEvent extends Record<string, unknown> {
|
|
10
|
+
status?: string;
|
|
11
|
+
timestamp?: number;
|
|
12
|
+
metadata?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The provider timeline event latched on the agent card, or null when absent/malformed. */
|
|
16
|
+
export function agentProviderTimelineEvent(agent: HasMeta | null | undefined): ProviderTimelineEvent | null {
|
|
17
|
+
const value = agent?.meta?.timelineEvent;
|
|
18
|
+
if (!isRecord(value)) return null;
|
|
19
|
+
return {
|
|
20
|
+
...value,
|
|
21
|
+
...(typeof value.status === "string" ? { status: value.status } : {}),
|
|
22
|
+
...(typeof value.timestamp === "number" && Number.isFinite(value.timestamp) ? { timestamp: value.timestamp } : {}),
|
|
23
|
+
...(isRecord(value.metadata) ? { metadata: value.metadata } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function numberValue(value: unknown): number | undefined {
|
|
28
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function auditRunnerTimelineEvent(agentId: string, timelineEvent: ProviderTimelineEvent | null): void {
|
|
32
|
+
if (!timelineEvent) return;
|
|
33
|
+
const metadata = isRecord(timelineEvent.metadata) ? timelineEvent.metadata : {};
|
|
34
|
+
if (metadata.source !== "runner") return;
|
|
35
|
+
const status = stringValue(timelineEvent.status);
|
|
36
|
+
if (!status) return;
|
|
37
|
+
const eventType = stringValue(metadata.eventType) ?? status;
|
|
38
|
+
const timestamp = numberValue(timelineEvent.timestamp) ?? Date.now();
|
|
39
|
+
const id = stringValue(timelineEvent.id) ?? `${eventType}-${timestamp}`;
|
|
40
|
+
const title = stringValue(timelineEvent.title) ?? status.replace(/[._-]+/g, " ");
|
|
41
|
+
const body = stringValue(timelineEvent.body);
|
|
42
|
+
const icon = stringValue(timelineEvent.icon) ?? "ti-activity";
|
|
43
|
+
try {
|
|
44
|
+
const event = createActivityEvent({
|
|
45
|
+
clientId: `runner-timeline-${agentId}-${id}`,
|
|
46
|
+
kind: "state",
|
|
47
|
+
title,
|
|
48
|
+
body,
|
|
49
|
+
meta: agentId,
|
|
50
|
+
icon,
|
|
51
|
+
view: "agents",
|
|
52
|
+
agentId,
|
|
53
|
+
metadata: {
|
|
54
|
+
...metadata,
|
|
55
|
+
eventType,
|
|
56
|
+
timelineStatus: status,
|
|
57
|
+
timelineId: id,
|
|
58
|
+
timelineTimestamp: timestamp,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
emitRelayEvent({
|
|
62
|
+
type: "activity.created",
|
|
63
|
+
source: "server",
|
|
64
|
+
subject: String(event.id),
|
|
65
|
+
data: event as unknown as Record<string, unknown>,
|
|
66
|
+
});
|
|
67
|
+
} catch {
|
|
68
|
+
// Timeline writes must never block status updates.
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/quota-advisory.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
|
|
2
3
|
import { getDb } from "./db/connection.ts";
|
|
3
4
|
import { emitRelayEvent } from "./events";
|
|
4
5
|
import { routeNotification } from "./notification-router";
|
|
@@ -46,7 +47,7 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
46
47
|
|
|
47
48
|
for (const window of input.quota.windows) {
|
|
48
49
|
const previousWindow = input.previousQuota?.windows.find((candidate) => candidate.name === window.name);
|
|
49
|
-
const remaining =
|
|
50
|
+
const remaining = quotaWindowMetrics(window).remaining;
|
|
50
51
|
const remainingPercent = percent(remaining);
|
|
51
52
|
let previous = nextState[window.name];
|
|
52
53
|
const reset = hasLastQuotaAdvisory(previous) && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
|
|
@@ -279,7 +280,7 @@ function crossProviderHeadroomHint(provider: string): string {
|
|
|
279
280
|
return quota.windows.map((window) => ({
|
|
280
281
|
provider: row.provider,
|
|
281
282
|
window: window.name,
|
|
282
|
-
remainingPercent: percent(
|
|
283
|
+
remainingPercent: percent(quotaWindowMetrics(window).remaining),
|
|
283
284
|
}));
|
|
284
285
|
}).sort((a, b) => b.remainingPercent - a.remainingPercent);
|
|
285
286
|
const best = options[0];
|
|
@@ -295,17 +296,13 @@ function isQuotaReset(
|
|
|
295
296
|
): boolean {
|
|
296
297
|
if (remaining >= resetRemaining) return true;
|
|
297
298
|
if (!previousWindow?.resetsAt || !window.resetsAt || window.resetsAt <= previousWindow.resetsAt) return false;
|
|
298
|
-
return remaining -
|
|
299
|
+
return remaining - quotaWindowMetrics(previousWindow).remaining >= 0.5 && remaining >= 0.8;
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
function quotaRemainingBand(remaining: number, bandSize: number): number {
|
|
302
303
|
return Math.min(100, Math.max(0, percent(Math.ceil((remaining / bandSize) - 1e-9) * bandSize)));
|
|
303
304
|
}
|
|
304
305
|
|
|
305
|
-
function quotaRemaining(utilization: number): number {
|
|
306
|
-
return Math.max(0, Math.min(1, 1 - utilization));
|
|
307
|
-
}
|
|
308
|
-
|
|
309
306
|
function percent(value: number): number {
|
|
310
307
|
return Math.round(value * 100);
|
|
311
308
|
}
|