agent-relay-server 0.77.1 → 0.78.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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.77.1",
5
+ "version": "0.78.0",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
@@ -729,6 +729,84 @@
729
729
  ]
730
730
  }
731
731
  },
732
+ "/api/orchestrators/{id}/provider-quota-leases/acquire": {
733
+ "post": {
734
+ "operationId": "postProviderQuotaLeaseAcquireRoute",
735
+ "summary": "Acquire provider quota poll lease",
736
+ "tags": [
737
+ "Orchestrators"
738
+ ],
739
+ "description": "Orchestrator-only election endpoint. Grants or renews the single fleet-wide polling lease for one `(provider, accountKey)` when no non-expired holder exists, or returns the current holder and retry delay. The caller must renew with the returned `leaseToken`; another process with the same orchestrator id but no token cannot steal the lease.",
740
+ "parameters": [
741
+ {
742
+ "name": "id",
743
+ "in": "path",
744
+ "required": true,
745
+ "schema": {
746
+ "type": "string"
747
+ }
748
+ }
749
+ ],
750
+ "responses": {
751
+ "200": {
752
+ "description": "Success",
753
+ "content": {
754
+ "application/json": {}
755
+ }
756
+ }
757
+ },
758
+ "security": [
759
+ {
760
+ "bearerAuth": []
761
+ },
762
+ {
763
+ "tokenHeader": []
764
+ },
765
+ {
766
+ "tokenQuery": []
767
+ }
768
+ ]
769
+ }
770
+ },
771
+ "/api/orchestrators/{id}/provider-quota-leases/release": {
772
+ "post": {
773
+ "operationId": "postProviderQuotaLeaseReleaseRoute",
774
+ "summary": "Release provider quota poll lease",
775
+ "tags": [
776
+ "Orchestrators"
777
+ ],
778
+ "description": "Releases a provider quota poll lease only when `provider`, `accountKey`, orchestrator id, and `leaseToken` all match the current holder.",
779
+ "parameters": [
780
+ {
781
+ "name": "id",
782
+ "in": "path",
783
+ "required": true,
784
+ "schema": {
785
+ "type": "string"
786
+ }
787
+ }
788
+ ],
789
+ "responses": {
790
+ "200": {
791
+ "description": "Success",
792
+ "content": {
793
+ "application/json": {}
794
+ }
795
+ }
796
+ },
797
+ "security": [
798
+ {
799
+ "bearerAuth": []
800
+ },
801
+ {
802
+ "tokenHeader": []
803
+ },
804
+ {
805
+ "tokenQuery": []
806
+ }
807
+ ]
808
+ }
809
+ },
732
810
  "/api/agents/spawn/directories": {
733
811
  "get": {
734
812
  "operationId": "getHostDirectories",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.77.1",
3
+ "version": "0.78.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.52",
36
+ "agent-relay-sdk": "0.2.53",
37
37
  "ajv": "^8.20.0"
38
38
  },
39
39
  "scripts": {
@@ -1,6 +1,7 @@
1
1
  import { getDb, ValidationError } from "./db";
2
2
  import { cleanEnum, cleanString, cleanStringArray } from "./validation";
3
3
  import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
4
+ import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
4
5
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
5
6
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
6
7
  import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
@@ -499,7 +500,7 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
499
500
  // Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
500
501
  // operator can flip the master switch or individual events off via the generic
501
502
  // config route. Push messages wake recipients, so they must be suppressible.
502
- const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG };
503
+ const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG, quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG };
503
504
 
504
505
  function validateNotificationsConfig(value: unknown): NotificationsConfig {
505
506
  if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
@@ -512,6 +513,7 @@ function validateNotificationsConfig(value: unknown): NotificationsConfig {
512
513
  agentExited: bool("agentExited"),
513
514
  agentSpawnFailed: bool("agentSpawnFailed"),
514
515
  contextThreshold: validateContextThresholdConfig(value.contextThreshold),
516
+ quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold),
515
517
  };
516
518
  }
517
519
 
package/src/db/index.ts CHANGED
@@ -30,3 +30,4 @@ export * from "./projects.ts";
30
30
  export * from "./project-entries.ts";
31
31
  export * from "./provisioning-registry.ts";
32
32
  export * from "./provider-quotas.ts";
33
+ export * from "./provider-quota-leases.ts";
@@ -658,6 +658,7 @@ export function applyMigrations(): void {
658
658
  account_key TEXT NOT NULL,
659
659
  quota_state TEXT,
660
660
  source_agent_ids TEXT NOT NULL DEFAULT '[]',
661
+ quota_advisory_state TEXT NOT NULL DEFAULT '{}',
661
662
  last_updated_at INTEGER,
662
663
  last_attempt_at INTEGER NOT NULL,
663
664
  last_error TEXT,
@@ -666,6 +667,10 @@ export function applyMigrations(): void {
666
667
  PRIMARY KEY (provider, account_key)
667
668
  )
668
669
  `);
670
+ const providerQuotaCols = (getDb().query("PRAGMA table_info(provider_quotas)").all() as Array<{ name: string }>).map((col) => col.name);
671
+ if (!providerQuotaCols.includes("quota_advisory_state")) {
672
+ getDb().run("ALTER TABLE provider_quotas ADD COLUMN quota_advisory_state TEXT NOT NULL DEFAULT '{}'");
673
+ }
669
674
  getDb().run(`
670
675
  INSERT INTO provider_quotas (
671
676
  provider, account_key, quota_state, source_agent_ids, last_updated_at,
@@ -703,6 +708,20 @@ export function applyMigrations(): void {
703
708
  getDb().run("CREATE INDEX IF NOT EXISTS idx_quota_snapshots_agent_time ON quota_snapshots(agent_id, captured_at DESC)");
704
709
  normalizeProviderQuotaAccountKeys();
705
710
 
711
+ getDb().run(`
712
+ CREATE TABLE IF NOT EXISTS provider_quota_leases (
713
+ provider TEXT NOT NULL,
714
+ account_key TEXT NOT NULL,
715
+ orchestrator_id TEXT NOT NULL,
716
+ lease_token TEXT NOT NULL,
717
+ expires_at INTEGER NOT NULL,
718
+ updated_at INTEGER NOT NULL,
719
+ created_at INTEGER NOT NULL,
720
+ PRIMARY KEY (provider, account_key)
721
+ )
722
+ `);
723
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_provider_quota_leases_expires ON provider_quota_leases(expires_at)");
724
+
706
725
  getDb().run(`
707
726
  CREATE TABLE IF NOT EXISTS memories (
708
727
  id TEXT PRIMARY KEY,
@@ -0,0 +1,134 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "./connection.ts";
3
+
4
+ export interface ProviderQuotaLease {
5
+ provider: string;
6
+ accountKey: string;
7
+ orchestratorId: string;
8
+ leaseToken: string;
9
+ expiresAt: number;
10
+ updatedAt: number;
11
+ createdAt: number;
12
+ }
13
+
14
+ export interface ProviderQuotaLeaseAcquireResult {
15
+ acquired: boolean;
16
+ lease?: ProviderQuotaLease;
17
+ holder?: {
18
+ orchestratorId: string;
19
+ expiresAt: number;
20
+ };
21
+ retryAfterMs?: number;
22
+ }
23
+
24
+ type ProviderQuotaLeaseRow = {
25
+ provider: string;
26
+ account_key: string;
27
+ orchestrator_id: string;
28
+ lease_token: string;
29
+ expires_at: number;
30
+ updated_at: number;
31
+ created_at: number;
32
+ };
33
+
34
+ function normalizeProvider(value: string): string {
35
+ return value.trim().toLowerCase();
36
+ }
37
+
38
+ function cleanAccountKey(value: string): string {
39
+ return value.trim();
40
+ }
41
+
42
+ function rowToLease(row: ProviderQuotaLeaseRow): ProviderQuotaLease {
43
+ return {
44
+ provider: row.provider,
45
+ accountKey: row.account_key,
46
+ orchestratorId: row.orchestrator_id,
47
+ leaseToken: row.lease_token,
48
+ expiresAt: row.expires_at,
49
+ updatedAt: row.updated_at,
50
+ createdAt: row.created_at,
51
+ };
52
+ }
53
+
54
+ export function acquireProviderQuotaLease(input: {
55
+ provider: string;
56
+ accountKey: string;
57
+ orchestratorId: string;
58
+ leaseToken?: string;
59
+ ttlMs: number;
60
+ now?: number;
61
+ }): ProviderQuotaLeaseAcquireResult {
62
+ const provider = normalizeProvider(input.provider);
63
+ const accountKey = cleanAccountKey(input.accountKey);
64
+ const orchestratorId = input.orchestratorId.trim();
65
+ const ttlMs = Math.max(5_000, input.ttlMs);
66
+ const now = input.now ?? Date.now();
67
+ if (!provider) throw new Error("provider required");
68
+ if (!accountKey) throw new Error("accountKey required");
69
+ if (!orchestratorId) throw new Error("orchestratorId required");
70
+
71
+ const acquire = getDb().transaction(() => {
72
+ const existing = getDb().query("SELECT * FROM provider_quota_leases WHERE provider = ? AND account_key = ?")
73
+ .get(provider, accountKey) as ProviderQuotaLeaseRow | undefined;
74
+ const expired = !existing || existing.expires_at <= now;
75
+ const sameHolder = existing?.orchestrator_id === orchestratorId && existing.lease_token === input.leaseToken;
76
+ if (existing && !expired && !sameHolder) {
77
+ return {
78
+ acquired: false,
79
+ holder: { orchestratorId: existing.orchestrator_id, expiresAt: existing.expires_at },
80
+ retryAfterMs: Math.max(1_000, existing.expires_at - now),
81
+ } satisfies ProviderQuotaLeaseAcquireResult;
82
+ }
83
+
84
+ const leaseToken = sameHolder ? existing!.lease_token : randomUUID();
85
+ const createdAt = sameHolder ? existing!.created_at : now;
86
+ const expiresAt = now + ttlMs;
87
+ getDb().query(`
88
+ INSERT INTO provider_quota_leases (
89
+ provider, account_key, orchestrator_id, lease_token, expires_at, updated_at, created_at
90
+ )
91
+ VALUES (?, ?, ?, ?, ?, ?, ?)
92
+ ON CONFLICT(provider, account_key) DO UPDATE SET
93
+ orchestrator_id = excluded.orchestrator_id,
94
+ lease_token = excluded.lease_token,
95
+ expires_at = excluded.expires_at,
96
+ updated_at = excluded.updated_at
97
+ `).run(provider, accountKey, orchestratorId, leaseToken, expiresAt, now, createdAt);
98
+ return {
99
+ acquired: true,
100
+ lease: {
101
+ provider,
102
+ accountKey,
103
+ orchestratorId,
104
+ leaseToken,
105
+ expiresAt,
106
+ updatedAt: now,
107
+ createdAt,
108
+ },
109
+ } satisfies ProviderQuotaLeaseAcquireResult;
110
+ });
111
+
112
+ return acquire();
113
+ }
114
+
115
+ export function releaseProviderQuotaLease(input: {
116
+ provider: string;
117
+ accountKey: string;
118
+ orchestratorId: string;
119
+ leaseToken: string;
120
+ }): boolean {
121
+ const provider = normalizeProvider(input.provider);
122
+ const accountKey = cleanAccountKey(input.accountKey);
123
+ const result = getDb().query(`
124
+ DELETE FROM provider_quota_leases
125
+ WHERE provider = ? AND account_key = ? AND orchestrator_id = ? AND lease_token = ?
126
+ `).run(provider, accountKey, input.orchestratorId.trim(), input.leaseToken);
127
+ return result.changes > 0;
128
+ }
129
+
130
+ export function getProviderQuotaLease(provider: string, accountKey: string): ProviderQuotaLease | null {
131
+ const row = getDb().query("SELECT * FROM provider_quota_leases WHERE provider = ? AND account_key = ?")
132
+ .get(normalizeProvider(provider), cleanAccountKey(accountKey)) as ProviderQuotaLeaseRow | undefined;
133
+ return row ? rowToLease(row) : null;
134
+ }
@@ -6,11 +6,28 @@ import type { AgentCard, ProviderQuotaError, ProviderQuotaMatrix, ProviderQuotaR
6
6
 
7
7
  const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
8
8
 
9
+ export interface ProviderQuotaAdvisoryInput {
10
+ provider: string;
11
+ accountKey: string;
12
+ previousQuota?: QuotaState;
13
+ quota: QuotaState;
14
+ sourceAgentIds: string[];
15
+ previousAdvisoryState?: string | null;
16
+ now: number;
17
+ }
18
+
19
+ let providerQuotaAdvisoryHandler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined;
20
+
21
+ export function setProviderQuotaAdvisoryHandler(handler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined): void {
22
+ providerQuotaAdvisoryHandler = handler;
23
+ }
24
+
9
25
  type ProviderQuotaRow = {
10
26
  provider: string;
11
27
  account_key: string;
12
28
  quota_state?: string | null;
13
29
  source_agent_ids?: string | null;
30
+ quota_advisory_state?: string | null;
14
31
  last_updated_at?: number | null;
15
32
  last_attempt_at: number;
16
33
  last_error?: string | null;
@@ -96,6 +113,13 @@ function newestLastError(rows: ProviderQuotaRow[]): string | null {
96
113
  return row?.last_error ?? null;
97
114
  }
98
115
 
116
+ function newestQuotaAdvisoryState(rows: ProviderQuotaRow[]): string {
117
+ const row = rows
118
+ .filter((candidate) => candidate.quota_advisory_state)
119
+ .sort((a, b) => b.updated_at - a.updated_at)[0];
120
+ return row?.quota_advisory_state ?? "{}";
121
+ }
122
+
99
123
  function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccountKeys: string[], now: number): number {
100
124
  const keys = [...new Set(oldAccountKeys.filter((key) => key && key !== accountKey))];
101
125
  if (keys.length === 0) return 0;
@@ -117,15 +141,16 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
117
141
  getDb().query(`
118
142
  INSERT INTO provider_quotas (
119
143
  provider, account_key, quota_state, source_agent_ids, last_updated_at,
120
- last_attempt_at, last_error, updated_at, created_at
144
+ last_attempt_at, last_error, quota_advisory_state, updated_at, created_at
121
145
  )
122
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
146
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
123
147
  ON CONFLICT(provider, account_key) DO UPDATE SET
124
148
  quota_state = excluded.quota_state,
125
149
  source_agent_ids = excluded.source_agent_ids,
126
150
  last_updated_at = excluded.last_updated_at,
127
151
  last_attempt_at = excluded.last_attempt_at,
128
152
  last_error = excluded.last_error,
153
+ quota_advisory_state = excluded.quota_advisory_state,
129
154
  updated_at = excluded.updated_at
130
155
  `).run(
131
156
  provider,
@@ -135,6 +160,7 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
135
160
  lastUpdatedAt ?? null,
136
161
  lastAttemptAt || now,
137
162
  newestLastError(rows),
163
+ newestQuotaAdvisoryState(rows),
138
164
  updatedAt,
139
165
  createdAt,
140
166
  );
@@ -251,6 +277,20 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
251
277
  if (input.quota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(input.quota)) {
252
278
  recordProviderQuotaSnapshot({ provider, accountKey, quota: input.quota, sourceAgentId: input.sourceAgentId }, now);
253
279
  }
280
+ if (input.quota) {
281
+ const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
282
+ provider,
283
+ accountKey,
284
+ previousQuota: existing?.quota_state ? parseJson<QuotaState>(existing.quota_state, { windows: [], source: "probe", updatedAt: 0 }) : undefined,
285
+ quota: input.quota,
286
+ sourceAgentIds,
287
+ previousAdvisoryState: existing?.quota_advisory_state,
288
+ now,
289
+ });
290
+ if (quotaAdvisoryState !== undefined) {
291
+ getDb().query("UPDATE provider_quotas SET quota_advisory_state = ? WHERE provider = ? AND account_key = ?").run(quotaAdvisoryState, provider, accountKey);
292
+ }
293
+ }
254
294
  pruneProviderQuotaRecords(provider, undefined, now - DAY_MS);
255
295
 
256
296
  return listProviderQuotas({ provider, accountKey, historyLimit: 24, now }).quotas[0]!;
package/src/db/schema.ts CHANGED
@@ -307,6 +307,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
307
307
  account_key TEXT NOT NULL,
308
308
  quota_state TEXT,
309
309
  source_agent_ids TEXT NOT NULL DEFAULT '[]',
310
+ quota_advisory_state TEXT NOT NULL DEFAULT '{}',
310
311
  last_updated_at INTEGER,
311
312
  last_attempt_at INTEGER NOT NULL,
312
313
  last_error TEXT,
@@ -315,6 +316,18 @@ export function initDb(path: string = "agent-relay.db"): Database {
315
316
  PRIMARY KEY (provider, account_key)
316
317
  );
317
318
 
319
+ CREATE TABLE IF NOT EXISTS provider_quota_leases (
320
+ provider TEXT NOT NULL,
321
+ account_key TEXT NOT NULL,
322
+ orchestrator_id TEXT NOT NULL,
323
+ lease_token TEXT NOT NULL,
324
+ expires_at INTEGER NOT NULL,
325
+ updated_at INTEGER NOT NULL,
326
+ created_at INTEGER NOT NULL,
327
+ PRIMARY KEY (provider, account_key)
328
+ );
329
+ CREATE INDEX IF NOT EXISTS idx_provider_quota_leases_expires ON provider_quota_leases(expires_at);
330
+
318
331
  CREATE TABLE IF NOT EXISTS agent_continuations (
319
332
  agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
320
333
  objective TEXT NOT NULL,
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "./compaction-watch";
11
11
  import { getConfig, setConfig } from "./config-store";
12
12
  import { startConnectorStatusPoller } from "./connectors";
13
13
  import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
14
+ import { installQuotaAdvisoryHandler } from "./quota-advisory";
14
15
  import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
16
  import { resolve, sep } from "path";
16
17
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
@@ -76,6 +77,7 @@ function startServer(): void {
76
77
 
77
78
  assertSafeNetworkConfig(HOST);
78
79
  initDb(DB_PATH);
80
+ installQuotaAdvisoryHandler();
79
81
  installNotificationSubscriptionRule();
80
82
  startPlanLiveBindings();
81
83
  getLifecycleManager().start();
@@ -5,6 +5,7 @@ export type NotificationType =
5
5
  | "agent.exited"
6
6
  | "agent.spawn_failed"
7
7
  | "agent.context_threshold"
8
+ | "agent.quota_threshold"
8
9
  | "agent.resume_budget_warning"
9
10
  | "agent.resume_budget_exhausted"
10
11
  | "agent.transient_land_hold"
@@ -54,6 +55,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
54
55
  "agent.exited": null,
55
56
  "agent.spawn_failed": null,
56
57
  "agent.context_threshold": 15 * MINUTES,
58
+ "agent.quota_threshold": 15 * MINUTES,
57
59
  "agent.resume_budget_warning": null,
58
60
  "agent.resume_budget_exhausted": null,
59
61
  "agent.transient_land_hold": null,
@@ -0,0 +1,300 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { getDb } from "./db/connection.ts";
3
+ import { emitRelayEvent } from "./events";
4
+ import { routeNotification } from "./notification-router";
5
+ import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
6
+ import { parseJson } from "./utils";
7
+ import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput } from "./db/provider-quotas.ts";
8
+ import type { QuotaState } from "./types";
9
+
10
+ interface QuotaAdvisoryWindowState {
11
+ lastQuotaAdvisoryBand?: number;
12
+ lastQuotaAdvisoryAt?: number;
13
+ lastRemainingPercent?: number;
14
+ lastResetsAt?: number;
15
+ }
16
+
17
+ type QuotaAdvisoryState = Record<string, QuotaAdvisoryWindowState>;
18
+ type QuotaAdvisoryStage = "warn" | "critical" | "reset";
19
+
20
+ type AgentRecipientRow = {
21
+ id: string;
22
+ name: string;
23
+ kind?: string | null;
24
+ machine?: string | null;
25
+ rig?: string | null;
26
+ ready?: number | null;
27
+ status: string;
28
+ meta?: string | null;
29
+ };
30
+
31
+ const RESET_ROUNDING_PERCENT = 99;
32
+
33
+ export function installQuotaAdvisoryHandler(): void {
34
+ setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
35
+ }
36
+
37
+ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string {
38
+ const config = effectiveQuotaNotificationConfig().quotaThreshold;
39
+ const state = parseJson<QuotaAdvisoryState>(input.previousAdvisoryState ?? "{}", {});
40
+ const nextState: QuotaAdvisoryState = { ...state };
41
+
42
+ for (const window of input.quota.windows) {
43
+ const previousWindow = input.previousQuota?.windows.find((candidate) => candidate.name === window.name);
44
+ const remaining = quotaRemaining(window.utilization);
45
+ const remainingPercent = percent(remaining);
46
+ const previous = nextState[window.name];
47
+ const reset = previous?.lastQuotaAdvisoryBand !== undefined && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
48
+ if (reset) {
49
+ notifyQuotaAdvisory({
50
+ provider: input.provider,
51
+ accountKey: input.accountKey,
52
+ windowName: window.name,
53
+ stage: "reset",
54
+ band: 100,
55
+ remainingPercent,
56
+ resetsAt: window.resetsAt,
57
+ recipients: quotaAdvisoryRecipients(input.provider, input.accountKey, input.sourceAgentIds, input.now),
58
+ headroomHint: crossProviderHeadroomHint(input.provider),
59
+ now: input.now,
60
+ });
61
+ delete nextState[window.name];
62
+ continue;
63
+ }
64
+
65
+ if (remaining > config.warnRemaining) {
66
+ if (previous) delete nextState[window.name];
67
+ continue;
68
+ }
69
+
70
+ const band = quotaRemainingBand(remaining, config.bandSize);
71
+ if (previous?.lastQuotaAdvisoryBand === band) continue;
72
+ const stage: QuotaAdvisoryStage = remaining <= config.criticalRemaining ? "critical" : "warn";
73
+ notifyQuotaAdvisory({
74
+ provider: input.provider,
75
+ accountKey: input.accountKey,
76
+ windowName: window.name,
77
+ stage,
78
+ band,
79
+ remainingPercent,
80
+ resetsAt: window.resetsAt,
81
+ recipients: quotaAdvisoryRecipients(input.provider, input.accountKey, input.sourceAgentIds, input.now),
82
+ headroomHint: crossProviderHeadroomHint(input.provider),
83
+ now: input.now,
84
+ });
85
+ nextState[window.name] = {
86
+ lastQuotaAdvisoryBand: band,
87
+ lastQuotaAdvisoryAt: input.now,
88
+ lastRemainingPercent: remainingPercent,
89
+ ...(window.resetsAt !== undefined ? { lastResetsAt: window.resetsAt } : {}),
90
+ };
91
+ }
92
+
93
+ return JSON.stringify(nextState);
94
+ }
95
+
96
+ function notifyQuotaAdvisory(input: {
97
+ provider: string;
98
+ accountKey: string;
99
+ windowName: string;
100
+ stage: QuotaAdvisoryStage;
101
+ band: number;
102
+ remainingPercent: number;
103
+ resetsAt?: number;
104
+ recipients: string[];
105
+ headroomHint: string;
106
+ now: number;
107
+ }): void {
108
+ const payload = {
109
+ kind: "agent.quota_threshold",
110
+ provider: input.provider,
111
+ accountKey: input.accountKey,
112
+ window: input.windowName,
113
+ stage: input.stage,
114
+ band: input.band,
115
+ remainingPercent: input.remainingPercent,
116
+ ...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}),
117
+ recipients: input.recipients,
118
+ };
119
+
120
+ emitRelayEvent({
121
+ type: "agent.quota_threshold",
122
+ source: "server",
123
+ subject: `${input.provider}:${input.accountKey}:${input.windowName}`,
124
+ data: payload,
125
+ });
126
+
127
+ const notifications = effectiveQuotaNotificationConfig();
128
+ if (!notifications.enabled || !notifications.quotaThreshold.enabled) return;
129
+
130
+ routeNotification({
131
+ type: "agent.quota_threshold",
132
+ scope: {},
133
+ recipients: input.recipients,
134
+ message: {
135
+ subject: input.stage === "reset"
136
+ ? `${windowLabel(input.windowName)} ${providerLabel(input.provider)} quota reset`
137
+ : `${windowLabel(input.windowName)} ${providerLabel(input.provider)} quota ${input.stage === "critical" ? "critical" : "warning"}`,
138
+ body: quotaAdvisoryBody(input),
139
+ payload,
140
+ replyExpected: false,
141
+ },
142
+ emittedAt: input.now,
143
+ });
144
+ }
145
+
146
+ function effectiveQuotaNotificationConfig(): { enabled: boolean; quotaThreshold: ReturnType<typeof validateQuotaThresholdConfig> } {
147
+ const row = getDb().query("SELECT value FROM config WHERE namespace = 'notifications' AND key = 'default'").get() as { value?: string | null } | undefined;
148
+ const value = parseJson<Record<string, unknown>>(row?.value ?? "{}", {});
149
+ return {
150
+ enabled: value.enabled === undefined ? true : value.enabled === true,
151
+ quotaThreshold: validateQuotaThresholdConfig({ ...DEFAULT_QUOTA_THRESHOLD_CONFIG, ...(isRecord(value.quotaThreshold) ? value.quotaThreshold : {}) }),
152
+ };
153
+ }
154
+
155
+ function quotaAdvisoryBody(input: {
156
+ provider: string;
157
+ windowName: string;
158
+ stage: QuotaAdvisoryStage;
159
+ band: number;
160
+ remainingPercent: number;
161
+ resetsAt?: number;
162
+ headroomHint: string;
163
+ now: number;
164
+ }): string {
165
+ const provider = providerLabel(input.provider);
166
+ const window = windowLabel(input.windowName);
167
+ if (input.stage === "reset" || input.remainingPercent >= RESET_ROUNDING_PERCENT) {
168
+ return `${window} ${provider} quota reset to 100%. ${input.headroomHint}`;
169
+ }
170
+ const reset = input.resetsAt === undefined ? "reset time unknown" : `resets in ${formatDuration(input.resetsAt - input.now)}`;
171
+ return `${window} ${provider} quota below ${input.band}% remaining - ${input.remainingPercent}% remaining, ${reset}. ${input.headroomHint}`;
172
+ }
173
+
174
+ function quotaAdvisoryRecipients(provider: string, accountKey: string, sourceAgentIds: string[], now: number): string[] {
175
+ const sourceHosts = agentHosts(sourceAgentIds);
176
+ const accountHost = hostFromAccountKey(accountKey);
177
+ const candidates = getDb().query(`
178
+ SELECT id, name, kind, machine, rig, ready, status, meta
179
+ FROM agents
180
+ WHERE kind = 'provider'
181
+ AND ready = 1
182
+ AND status NOT IN ('offline', 'stale')
183
+ `).all() as AgentRecipientRow[];
184
+ return candidates
185
+ .filter((agent) => isSpawnCapableAgent(agent, now))
186
+ .filter((agent) => isBoundToQuotaAccount(agent, provider, accountKey, accountHost, sourceHosts, sourceAgentIds))
187
+ .map((agent) => agent.id);
188
+ }
189
+
190
+ function isSpawnCapableAgent(agent: AgentRecipientRow, now: number): boolean {
191
+ const meta = parseJson<Record<string, unknown>>(agent.meta ?? "{}", {});
192
+ const auth = isRecord(meta.auth) ? meta.auth : undefined;
193
+ const jti = typeof auth?.jti === "string" ? auth.jti : undefined;
194
+ if (!jti) return false;
195
+ const row = getDb().query("SELECT scope, revoked_at, expires_at FROM tokens WHERE jti = ?").get(jti) as { scope: string; revoked_at?: number | null; expires_at?: number | null } | undefined;
196
+ if (!row || row.revoked_at) return false;
197
+ const nowSeconds = Math.floor(now / 1000);
198
+ if (row.expires_at !== null && row.expires_at !== undefined && row.expires_at <= nowSeconds) return false;
199
+ const scopes = parseJson<string[]>(row.scope, []);
200
+ return scopes.includes("command:spawn") || scopes.includes("command:*") || scopes.includes("admin:*") || scopes.includes("*");
201
+ }
202
+
203
+ function isBoundToQuotaAccount(agent: AgentRecipientRow, provider: string, accountKey: string, accountHost: string | undefined, sourceHosts: Set<string>, sourceAgentIds: string[]): boolean {
204
+ if (sourceAgentIds.includes(agent.id)) return true;
205
+ const meta = parseJson<Record<string, unknown>>(agent.meta ?? "{}", {});
206
+ if (stringMeta(meta.provider)?.toLowerCase() === provider && accountKeyMatches(meta, accountKey)) return true;
207
+ if (accountKeyMatches(meta, accountKey)) return true;
208
+ const host = stringMeta(agent.machine) ?? stringMeta(agent.rig);
209
+ if (!host) return false;
210
+ return host === accountHost || sourceHosts.has(host);
211
+ }
212
+
213
+ function accountKeyMatches(meta: Record<string, unknown>, accountKey: string): boolean {
214
+ return [stringMeta(meta.accountKey), stringMeta(meta.accountId), stringMeta(meta.orgId)].includes(accountKey);
215
+ }
216
+
217
+ function agentHosts(agentIds: string[]): Set<string> {
218
+ const hosts = new Set<string>();
219
+ if (agentIds.length === 0) return hosts;
220
+ const rows = getDb().query(`
221
+ SELECT machine, rig FROM agents
222
+ WHERE id IN (${agentIds.map(() => "?").join(",")})
223
+ `).all(...agentIds) as Array<{ machine?: string | null; rig?: string | null }>;
224
+ for (const row of rows) {
225
+ const host = stringMeta(row.machine) ?? stringMeta(row.rig);
226
+ if (host) hosts.add(host);
227
+ }
228
+ return hosts;
229
+ }
230
+
231
+ function crossProviderHeadroomHint(provider: string): string {
232
+ const rows = getDb().query(`
233
+ SELECT provider, quota_state
234
+ FROM provider_quotas
235
+ WHERE provider <> ? AND quota_state IS NOT NULL
236
+ `).all(provider) as Array<{ provider: string; quota_state: string }>;
237
+ const options = rows.flatMap((row) => {
238
+ const quota = parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 });
239
+ return quota.windows.map((window) => ({
240
+ provider: row.provider,
241
+ window: window.name,
242
+ remainingPercent: percent(quotaRemaining(window.utilization)),
243
+ }));
244
+ }).sort((a, b) => b.remainingPercent - a.remainingPercent);
245
+ const best = options[0];
246
+ if (!best) return "No separate provider headroom is currently recorded.";
247
+ return `${providerLabel(best.provider)} pool separate, ${windowLabel(best.window)} at ${best.remainingPercent}% remaining - route new work there.`;
248
+ }
249
+
250
+ function isQuotaReset(
251
+ remaining: number,
252
+ previousWindow: QuotaState["windows"][number] | undefined,
253
+ window: QuotaState["windows"][number],
254
+ resetRemaining: number,
255
+ ): boolean {
256
+ if (remaining >= resetRemaining) return true;
257
+ if (!previousWindow?.resetsAt || !window.resetsAt || window.resetsAt <= previousWindow.resetsAt) return false;
258
+ return remaining - quotaRemaining(previousWindow.utilization) >= 0.5 && remaining >= 0.8;
259
+ }
260
+
261
+ function quotaRemainingBand(remaining: number, bandSize: number): number {
262
+ return Math.min(100, Math.max(0, percent(Math.ceil(remaining / bandSize) * bandSize)));
263
+ }
264
+
265
+ function quotaRemaining(utilization: number): number {
266
+ return Math.max(0, Math.min(1, 1 - utilization));
267
+ }
268
+
269
+ function percent(value: number): number {
270
+ return Math.round(value * 100);
271
+ }
272
+
273
+ function hostFromAccountKey(accountKey: string): string | undefined {
274
+ if (!accountKey.startsWith("host:")) return undefined;
275
+ const host = accountKey.slice("host:".length);
276
+ return host && !host.includes(":") ? host : undefined;
277
+ }
278
+
279
+ function stringMeta(value: unknown): string | undefined {
280
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
281
+ }
282
+
283
+ function providerLabel(provider: string): string {
284
+ return provider.slice(0, 1).toUpperCase() + provider.slice(1);
285
+ }
286
+
287
+ function windowLabel(value: string): string {
288
+ if (value === "five_hour") return "5h";
289
+ if (value === "seven_day") return "7d";
290
+ return value.replace(/_/g, " ");
291
+ }
292
+
293
+ function formatDuration(ms: number): string {
294
+ if (!Number.isFinite(ms) || ms <= 0) return "now";
295
+ const minutes = Math.max(1, Math.round(ms / 60_000));
296
+ if (minutes < 60) return `${minutes}m`;
297
+ const hours = Math.round(minutes / 60);
298
+ if (hours < 48) return `${hours}h`;
299
+ return `${Math.round(hours / 24)}d`;
300
+ }
@@ -0,0 +1,46 @@
1
+ import { isRecord } from "agent-relay-sdk";
2
+ import { ValidationError } from "./db/connection.ts";
3
+ import type { NotificationsConfig } from "./types";
4
+
5
+ export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
6
+ enabled: true,
7
+ warnRemaining: 0.25,
8
+ criticalRemaining: 0.1,
9
+ resetRemaining: 0.99,
10
+ bandSize: 0.05,
11
+ };
12
+
13
+ export function validateQuotaThresholdConfig(value: unknown): NotificationsConfig["quotaThreshold"] {
14
+ const input = isRecord(value) ? value : {};
15
+ const warnRemaining = cleanFraction(input.warnRemaining, "quotaThreshold.warnRemaining", DEFAULT_QUOTA_THRESHOLD_CONFIG.warnRemaining);
16
+ const criticalRemaining = cleanFraction(input.criticalRemaining, "quotaThreshold.criticalRemaining", DEFAULT_QUOTA_THRESHOLD_CONFIG.criticalRemaining);
17
+ const resetRemaining = cleanFraction(input.resetRemaining, "quotaThreshold.resetRemaining", DEFAULT_QUOTA_THRESHOLD_CONFIG.resetRemaining);
18
+ const bandSize = cleanFraction(input.bandSize, "quotaThreshold.bandSize", DEFAULT_QUOTA_THRESHOLD_CONFIG.bandSize);
19
+ if (bandSize <= 0) throw new ValidationError("quotaThreshold.bandSize must be greater than 0");
20
+ if (criticalRemaining >= warnRemaining) {
21
+ throw new ValidationError("quotaThreshold.criticalRemaining must be less than quotaThreshold.warnRemaining");
22
+ }
23
+ if (resetRemaining <= warnRemaining) {
24
+ throw new ValidationError("quotaThreshold.resetRemaining must be greater than quotaThreshold.warnRemaining");
25
+ }
26
+ return {
27
+ enabled: input.enabled === undefined ? DEFAULT_QUOTA_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "quotaThreshold.enabled"),
28
+ warnRemaining,
29
+ criticalRemaining,
30
+ resetRemaining,
31
+ bandSize,
32
+ };
33
+ }
34
+
35
+ function cleanBoolean(value: unknown, field: string): boolean {
36
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
37
+ return value;
38
+ }
39
+
40
+ function cleanFraction(value: unknown, field: string, fallback: number): number {
41
+ if (value === undefined || value === null) return fallback;
42
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
43
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
44
+ }
45
+ return value;
46
+ }
@@ -5,7 +5,7 @@ import { deleteAgentTerminalSession, postAgentAction, postAgentPermissionDecisio
5
5
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
6
6
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
7
7
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
8
- import { getProviderQuotasRoute, postProviderQuotaRoute } from "./provider-quotas";
8
+ import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
9
9
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
10
10
  import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
11
11
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
@@ -90,6 +90,8 @@ const routes: Route[] = [
90
90
  route("POST", "/api/route", postRouteAdvice),
91
91
  route("GET", "/api/provider-quotas", getProviderQuotasRoute),
92
92
  route("POST", "/api/provider-quotas", postProviderQuotaRoute),
93
+ route("POST", "/api/orchestrators/:id/provider-quota-leases/acquire", postProviderQuotaLeaseAcquireRoute),
94
+ route("POST", "/api/orchestrators/:id/provider-quota-leases/release", postProviderQuotaLeaseReleaseRoute),
93
95
  route("GET", "/api/agents/spawn/directories", getHostDirectories),
94
96
  route("GET", "/api/agents/:id/chat", getAgentChatHistory),
95
97
  route("GET", "/api/agents/:id/context-snapshots", getAgentContextSnapshots),
@@ -1,8 +1,8 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
- import { listProviderQuotas, upsertProviderQuota } from "../db";
2
+ import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
3
3
  import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
4
4
  import { cleanString } from "../validation";
5
- import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
5
+ import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
6
6
  import type { ProviderQuotaUpdateInput } from "../types";
7
7
 
8
8
  function cleanProviderQuotaUpdate(body: unknown): ProviderQuotaUpdateInput | string {
@@ -46,3 +46,57 @@ export const postProviderQuotaRoute: Handler = async (req) => {
46
46
  if (typeof input === "string") return error(input);
47
47
  return json(upsertProviderQuota(input), 201);
48
48
  };
49
+
50
+ function cleanLeaseBody(body: unknown): {
51
+ provider: string;
52
+ accountKey: string;
53
+ leaseToken?: string;
54
+ ttlMs: number;
55
+ } | string {
56
+ if (!isRecord(body)) return "JSON object body required";
57
+ const provider = cleanString(body.provider, "provider", { required: true, max: 80 })!;
58
+ const accountKey = cleanString(body.accountKey, "accountKey", { required: true, max: 240 })!;
59
+ const leaseToken = cleanString(body.leaseToken, "leaseToken", { max: 120 });
60
+ const ttlMs = typeof body.ttlMs === "number" && Number.isFinite(body.ttlMs)
61
+ ? Math.max(5_000, Math.min(10 * 60_000, Math.round(body.ttlMs)))
62
+ : 90_000;
63
+ return { provider, accountKey, ...(leaseToken ? { leaseToken } : {}), ttlMs };
64
+ }
65
+
66
+ export const postProviderQuotaLeaseAcquireRoute: Handler = async (req, params) => {
67
+ const orch = getOrchestrator(params.id!);
68
+ if (!orch) return error("orchestrator not found", 404);
69
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
70
+ if (denied) return denied;
71
+ const parsed = await parseBody<unknown>(req);
72
+ if (!parsed.ok) return error(parsed.error, parsed.status);
73
+ const input = cleanLeaseBody(parsed.body);
74
+ if (typeof input === "string") return error(input);
75
+ return json(acquireProviderQuotaLease({
76
+ provider: input.provider,
77
+ accountKey: input.accountKey,
78
+ orchestratorId: orch.id,
79
+ leaseToken: input.leaseToken,
80
+ ttlMs: input.ttlMs,
81
+ }));
82
+ };
83
+
84
+ export const postProviderQuotaLeaseReleaseRoute: Handler = async (req, params) => {
85
+ const orch = getOrchestrator(params.id!);
86
+ if (!orch) return error("orchestrator not found", 404);
87
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
88
+ if (denied) return denied;
89
+ const parsed = await parseBody<unknown>(req);
90
+ if (!parsed.ok) return error(parsed.error, parsed.status);
91
+ const input = cleanLeaseBody(parsed.body);
92
+ if (typeof input === "string") return error(input);
93
+ if (!input.leaseToken) return error("leaseToken required");
94
+ return json({
95
+ released: releaseProviderQuotaLease({
96
+ provider: input.provider,
97
+ accountKey: input.accountKey,
98
+ orchestratorId: orch.id,
99
+ leaseToken: input.leaseToken,
100
+ }),
101
+ });
102
+ };
package/src/token-db.ts CHANGED
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
67
67
  name: "Orchestrator Host",
68
68
  description: "Host supervisor access for spawn, command, log, and terminal operations.",
69
69
  role: "orchestrator",
70
- scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach"],
70
+ scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "quota:write"],
71
71
  constraints: { logsRead: true, terminalAttach: true },
72
72
  ttlSeconds: 30 * 24 * 60 * 60,
73
73
  builtIn: true,