agent-relay-orchestrator 0.78.1 → 0.78.3
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/package.json +2 -2
- package/src/quota-poller.ts +111 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.78.
|
|
3
|
+
"version": "0.78.3",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.55"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/quota-poller.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
QuotaCollectionError,
|
|
10
10
|
collectClaudeQuotaSample,
|
|
11
11
|
collectCodexQuotaSample,
|
|
12
|
+
credentialAccountKey,
|
|
12
13
|
providerQuotaErrorFromCollectorError,
|
|
13
14
|
quotaRetryAfterMs,
|
|
15
|
+
readClaudeOAuthAccessToken,
|
|
14
16
|
resolveStableClaudeQuotaIdentity,
|
|
15
17
|
resolveStableCodexQuotaIdentityFromHome,
|
|
16
18
|
type ProviderQuotaIdentity,
|
|
@@ -25,6 +27,14 @@ const QUOTA_LEASE_RENEW_MS = 30_000;
|
|
|
25
27
|
const CODEX_APP_SERVER_CONNECT_ATTEMPTS = 40;
|
|
26
28
|
const CODEX_APP_SERVER_CONNECT_RETRY_MS = 250;
|
|
27
29
|
|
|
30
|
+
// A provider that is configured but has no usable quota credential on this host
|
|
31
|
+
// is uncollectable by design. Rather than silently omitting it (so the row
|
|
32
|
+
// vanishes from the dashboard), we report a marker record carrying the reason,
|
|
33
|
+
// so the widget can show a muted "unavailable" row.
|
|
34
|
+
// A stable synthetic account key keeps it out of the host:/home: unstable-key prune.
|
|
35
|
+
const PROVIDER_QUOTA_UNAVAILABLE_ACCOUNT_KEY = "unavailable";
|
|
36
|
+
const PROVIDER_QUOTA_UNAVAILABLE_ERROR_TYPE = "unavailable";
|
|
37
|
+
|
|
28
38
|
type QuotaRelay = {
|
|
29
39
|
acquireProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput): Promise<{
|
|
30
40
|
acquired: boolean;
|
|
@@ -37,10 +47,14 @@ type QuotaRelay = {
|
|
|
37
47
|
};
|
|
38
48
|
|
|
39
49
|
type QuotaCandidate = ProviderQuotaIdentity & {
|
|
50
|
+
accessToken?: string;
|
|
40
51
|
appServerUrl?: string;
|
|
41
52
|
codexHome?: string;
|
|
42
53
|
};
|
|
43
54
|
|
|
55
|
+
type ProviderSkip = { provider: string; reason: string };
|
|
56
|
+
type QuotaDiscovery = { candidates: QuotaCandidate[]; skips: ProviderSkip[] };
|
|
57
|
+
|
|
44
58
|
type QuotaPollState = {
|
|
45
59
|
leaseToken?: string;
|
|
46
60
|
leaseExpiresAt?: number;
|
|
@@ -91,11 +105,14 @@ export class OrchestratorQuotaPoller {
|
|
|
91
105
|
if (this.inFlight || !this.active || !this.relay.connected) return;
|
|
92
106
|
this.inFlight = true;
|
|
93
107
|
try {
|
|
94
|
-
const candidates = await this.discoverCandidates();
|
|
108
|
+
const { candidates, skips } = await this.discoverCandidates();
|
|
95
109
|
await this.releaseRemovedCandidates(candidates);
|
|
96
110
|
for (const candidate of candidates) {
|
|
97
111
|
await this.processCandidate(candidate);
|
|
98
112
|
}
|
|
113
|
+
for (const skip of skips) {
|
|
114
|
+
await this.reportSkip(skip);
|
|
115
|
+
}
|
|
99
116
|
} finally {
|
|
100
117
|
this.inFlight = false;
|
|
101
118
|
this.schedule(this.options.intervalMs ?? QUOTA_LEASE_RENEW_MS);
|
|
@@ -112,38 +129,54 @@ export class OrchestratorQuotaPoller {
|
|
|
112
129
|
}, Math.max(1_000, delayMs));
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
private async discoverCandidates(): Promise<
|
|
132
|
+
private async discoverCandidates(): Promise<QuotaDiscovery> {
|
|
116
133
|
const candidates: QuotaCandidate[] = [];
|
|
134
|
+
const skips: ProviderSkip[] = [];
|
|
117
135
|
if (this.config.providers.includes("claude")) {
|
|
118
|
-
|
|
136
|
+
const found = await this.discoverClaudeCandidates();
|
|
137
|
+
candidates.push(...found.candidates);
|
|
138
|
+
if (found.skipReason) skips.push({ provider: "claude", reason: found.skipReason });
|
|
119
139
|
}
|
|
120
140
|
if (this.config.providers.includes("codex")) {
|
|
121
|
-
|
|
141
|
+
const found = await this.discoverCodexCandidates();
|
|
142
|
+
candidates.push(...found.candidates);
|
|
143
|
+
if (found.skipReason) skips.push({ provider: "codex", reason: found.skipReason });
|
|
122
144
|
}
|
|
123
145
|
const deduped = new Map<string, QuotaCandidate>();
|
|
124
146
|
for (const candidate of candidates) {
|
|
125
147
|
deduped.set(candidateStateKey(candidate), candidate);
|
|
126
148
|
}
|
|
127
|
-
return [...deduped.values()];
|
|
149
|
+
return { candidates: [...deduped.values()], skips };
|
|
128
150
|
}
|
|
129
151
|
|
|
130
|
-
private async discoverClaudeCandidates(): Promise<QuotaCandidate[]> {
|
|
131
|
-
const
|
|
152
|
+
private async discoverClaudeCandidates(): Promise<{ candidates: QuotaCandidate[]; skipReason?: string }> {
|
|
153
|
+
const credentialsPaths = [
|
|
132
154
|
join(this.config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), ".credentials.json"),
|
|
133
155
|
...providerHomeCredentialPaths("claude", ".credentials.json"),
|
|
134
156
|
];
|
|
135
157
|
const candidates: QuotaCandidate[] = [];
|
|
136
|
-
for (const
|
|
158
|
+
for (const accessToken of [
|
|
159
|
+
this.config.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
160
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
161
|
+
...claudeSetupTokenEnvPaths(this.config).map((path) => readEnvFileValue(path, "CLAUDE_CODE_OAUTH_TOKEN")),
|
|
162
|
+
]) {
|
|
163
|
+
if (accessToken) candidates.push(claudeBearerCandidate(accessToken));
|
|
164
|
+
}
|
|
165
|
+
for (const credentialsPath of credentialsPaths) {
|
|
166
|
+
const accessToken = await readClaudeOAuthAccessToken(credentialsPath);
|
|
167
|
+
if (!accessToken) continue;
|
|
137
168
|
const identity = await resolveStableClaudeQuotaIdentity({ credentialsPath });
|
|
138
|
-
|
|
169
|
+
candidates.push({ ...(identity ?? claudeBearerCandidate(accessToken)), credentialsPath, accessToken });
|
|
139
170
|
}
|
|
140
171
|
if (candidates.length === 0) {
|
|
141
|
-
|
|
172
|
+
const reason = "no usable Claude bearer token on this host";
|
|
173
|
+
this.logOnce("claude:no-usable-bearer", `quota refresh skipped for claude: ${reason}`);
|
|
174
|
+
return { candidates, skipReason: reason };
|
|
142
175
|
}
|
|
143
|
-
return candidates;
|
|
176
|
+
return { candidates };
|
|
144
177
|
}
|
|
145
178
|
|
|
146
|
-
private async discoverCodexCandidates(): Promise<QuotaCandidate[]> {
|
|
179
|
+
private async discoverCodexCandidates(): Promise<{ candidates: QuotaCandidate[]; skipReason?: string }> {
|
|
147
180
|
const homes = [
|
|
148
181
|
this.config.env.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), ".codex"),
|
|
149
182
|
...providerHomeConfigDirs("codex", "auth.json"),
|
|
@@ -155,9 +188,25 @@ export class OrchestratorQuotaPoller {
|
|
|
155
188
|
if (identity) candidates.push({ ...identity, codexHome, ...(appServerUrl ? { appServerUrl } : {}) });
|
|
156
189
|
}
|
|
157
190
|
if (candidates.length === 0) {
|
|
158
|
-
|
|
191
|
+
const reason = "no Codex account id found in auth.json";
|
|
192
|
+
this.logOnce("codex:no-stable-auth", `quota refresh skipped for codex: ${reason}`);
|
|
193
|
+
return { candidates, skipReason: reason };
|
|
159
194
|
}
|
|
160
|
-
return candidates;
|
|
195
|
+
return { candidates };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Publish a marker record for a provider that is configured but uncollectable on
|
|
199
|
+
// this host, so the dashboard shows a muted "unavailable" row with the reason
|
|
200
|
+
// instead of dropping the provider entirely. Re-sent each tick to keep the row
|
|
201
|
+
// fresh (the day-old prune is keyed on updated_at).
|
|
202
|
+
private async reportSkip(skip: ProviderSkip): Promise<void> {
|
|
203
|
+
await this.relay.reportProviderQuota({
|
|
204
|
+
provider: skip.provider,
|
|
205
|
+
accountKey: PROVIDER_QUOTA_UNAVAILABLE_ACCOUNT_KEY,
|
|
206
|
+
lastAttemptAt: this.now(),
|
|
207
|
+
lastError: { type: PROVIDER_QUOTA_UNAVAILABLE_ERROR_TYPE, message: skip.reason },
|
|
208
|
+
sourceAgentId: this.sourceAgentId(),
|
|
209
|
+
}).catch((publishError) => this.log(`quota skip publish failed for ${skip.provider}: ${errMessage(publishError)}`));
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
private async releaseRemovedCandidates(candidates: QuotaCandidate[]): Promise<void> {
|
|
@@ -232,6 +281,7 @@ export class OrchestratorQuotaPoller {
|
|
|
232
281
|
if (candidate.provider === "claude") {
|
|
233
282
|
return collectClaudeQuotaSample({
|
|
234
283
|
agentId: this.sourceAgentId(),
|
|
284
|
+
accessToken: candidate.accessToken,
|
|
235
285
|
credentialsPath: candidate.credentialsPath,
|
|
236
286
|
fetchImpl: this.options.fetchImpl,
|
|
237
287
|
});
|
|
@@ -294,9 +344,10 @@ async function codexRateLimitsRead(appServerUrl: string, attempts = 1): Promise<
|
|
|
294
344
|
try {
|
|
295
345
|
await connectWithRetry(client, attempts);
|
|
296
346
|
await client.request("initialize", {
|
|
297
|
-
clientInfo: { name: "agent-relay-orchestrator", title: "Agent Relay Orchestrator" },
|
|
347
|
+
clientInfo: { name: "agent-relay-orchestrator", title: "Agent Relay Orchestrator", version: "0.1.0" },
|
|
298
348
|
capabilities: { experimentalApi: true },
|
|
299
|
-
})
|
|
349
|
+
});
|
|
350
|
+
client.notify("initialized");
|
|
300
351
|
return await client.request("account/rateLimits/read");
|
|
301
352
|
} finally {
|
|
302
353
|
client.close();
|
|
@@ -373,6 +424,45 @@ function providerHomeCredentialPaths(provider: "claude", markerFile: string): st
|
|
|
373
424
|
return providerHomeConfigDirs(provider, markerFile).map((dir) => join(dir, markerFile));
|
|
374
425
|
}
|
|
375
426
|
|
|
427
|
+
function claudeSetupTokenEnvPaths(config: OrchestratorConfig): string[] {
|
|
428
|
+
const configDir = config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
429
|
+
return [
|
|
430
|
+
join(configDir, "setup-token.env"),
|
|
431
|
+
...providerHomeConfigDirs("claude", "setup-token.env").map((dir) => join(dir, "setup-token.env")),
|
|
432
|
+
];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function claudeBearerCandidate(accessToken: string): QuotaCandidate {
|
|
436
|
+
return {
|
|
437
|
+
provider: "claude",
|
|
438
|
+
accountKey: credentialAccountKey(accessToken),
|
|
439
|
+
accessToken,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function readEnvFileValue(path: string, key: string): string | undefined {
|
|
444
|
+
try {
|
|
445
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
446
|
+
const match = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)\\s*$`).exec(line);
|
|
447
|
+
if (!match) continue;
|
|
448
|
+
return cleanEnvValue(match[1] ?? "");
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function cleanEnvValue(value: string): string | undefined {
|
|
457
|
+
let cleaned = value.trim();
|
|
458
|
+
if (!cleaned) return undefined;
|
|
459
|
+
const quote = cleaned[0];
|
|
460
|
+
if ((quote === "'" || quote === "\"") && cleaned.endsWith(quote)) {
|
|
461
|
+
cleaned = cleaned.slice(1, -1);
|
|
462
|
+
}
|
|
463
|
+
return cleaned.trim() || undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
376
466
|
function safeReadDir(path: string): string[] {
|
|
377
467
|
try {
|
|
378
468
|
return readdirSync(path, { withFileTypes: true })
|
|
@@ -414,6 +504,10 @@ class JsonRpcWebSocket {
|
|
|
414
504
|
return promise;
|
|
415
505
|
}
|
|
416
506
|
|
|
507
|
+
notify(method: string, params?: unknown): void {
|
|
508
|
+
this.ws.send(JSON.stringify(params === undefined ? { method } : { method, params }));
|
|
509
|
+
}
|
|
510
|
+
|
|
417
511
|
close(): void {
|
|
418
512
|
this.ws?.close();
|
|
419
513
|
}
|