agent-relay-orchestrator 0.78.0 → 0.78.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/config.ts +8 -0
- package/src/quota-poller.ts +143 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.78.
|
|
3
|
+
"version": "0.78.1",
|
|
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.54"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/config.ts
CHANGED
|
@@ -42,6 +42,14 @@ export function bunBinFromEnv(): string | undefined {
|
|
|
42
42
|
return process.env.AGENT_RELAY_BUN_BIN;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export function codexCommandFromEnv(): string | undefined {
|
|
46
|
+
return process.env.AGENT_RELAY_CODEX_COMMAND;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function providerHomeRootFromEnv(): string {
|
|
50
|
+
return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
export function disableSystemdSupervisor(): boolean {
|
|
46
54
|
return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1";
|
|
47
55
|
}
|
package/src/quota-poller.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:net";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import {
|
|
4
6
|
QUOTA_FAILURE_LOG_INTERVAL_MS,
|
|
@@ -9,17 +11,19 @@ import {
|
|
|
9
11
|
collectCodexQuotaSample,
|
|
10
12
|
providerQuotaErrorFromCollectorError,
|
|
11
13
|
quotaRetryAfterMs,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
resolveStableClaudeQuotaIdentity,
|
|
15
|
+
resolveStableCodexQuotaIdentityFromHome,
|
|
14
16
|
type ProviderQuotaIdentity,
|
|
15
17
|
type ProviderQuotaSample,
|
|
16
18
|
} from "agent-relay-sdk/provider-quota";
|
|
17
19
|
import type { ProviderQuotaLeaseAcquireInput, ProviderQuotaUpdateInput } from "agent-relay-sdk";
|
|
18
20
|
import { errMessage } from "agent-relay-sdk";
|
|
19
|
-
import type
|
|
21
|
+
import { codexCommandFromEnv, providerHomeRootFromEnv, type OrchestratorConfig } from "./config";
|
|
20
22
|
|
|
21
23
|
const QUOTA_LEASE_TTL_MS = 90_000;
|
|
22
24
|
const QUOTA_LEASE_RENEW_MS = 30_000;
|
|
25
|
+
const CODEX_APP_SERVER_CONNECT_ATTEMPTS = 40;
|
|
26
|
+
const CODEX_APP_SERVER_CONNECT_RETRY_MS = 250;
|
|
23
27
|
|
|
24
28
|
type QuotaRelay = {
|
|
25
29
|
acquireProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput): Promise<{
|
|
@@ -34,6 +38,7 @@ type QuotaRelay = {
|
|
|
34
38
|
|
|
35
39
|
type QuotaCandidate = ProviderQuotaIdentity & {
|
|
36
40
|
appServerUrl?: string;
|
|
41
|
+
codexHome?: string;
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
type QuotaPollState = {
|
|
@@ -49,6 +54,7 @@ export class OrchestratorQuotaPoller {
|
|
|
49
54
|
private active = false;
|
|
50
55
|
private inFlight = false;
|
|
51
56
|
private readonly states = new Map<string, QuotaPollState>();
|
|
57
|
+
private readonly logStates = new Map<string, { key: string; at: number }>();
|
|
52
58
|
|
|
53
59
|
constructor(
|
|
54
60
|
private readonly config: OrchestratorConfig,
|
|
@@ -78,6 +84,7 @@ export class OrchestratorQuotaPoller {
|
|
|
78
84
|
void this.relay.releaseProviderQuotaLease(this.config.id, { provider, accountKey, leaseToken: state.leaseToken }).catch(() => {});
|
|
79
85
|
}
|
|
80
86
|
this.states.clear();
|
|
87
|
+
this.logStates.clear();
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
async tick(): Promise<void> {
|
|
@@ -108,15 +115,47 @@ export class OrchestratorQuotaPoller {
|
|
|
108
115
|
private async discoverCandidates(): Promise<QuotaCandidate[]> {
|
|
109
116
|
const candidates: QuotaCandidate[] = [];
|
|
110
117
|
if (this.config.providers.includes("claude")) {
|
|
111
|
-
|
|
112
|
-
candidates.push(await resolveClaudeQuotaIdentity({ configDir, host: this.config.hostname }));
|
|
118
|
+
candidates.push(...await this.discoverClaudeCandidates());
|
|
113
119
|
}
|
|
114
120
|
if (this.config.providers.includes("codex")) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
candidates.push(...await this.discoverCodexCandidates());
|
|
122
|
+
}
|
|
123
|
+
const deduped = new Map<string, QuotaCandidate>();
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
deduped.set(candidateStateKey(candidate), candidate);
|
|
126
|
+
}
|
|
127
|
+
return [...deduped.values()];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async discoverClaudeCandidates(): Promise<QuotaCandidate[]> {
|
|
131
|
+
const paths = [
|
|
132
|
+
join(this.config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), ".credentials.json"),
|
|
133
|
+
...providerHomeCredentialPaths("claude", ".credentials.json"),
|
|
134
|
+
];
|
|
135
|
+
const candidates: QuotaCandidate[] = [];
|
|
136
|
+
for (const credentialsPath of paths) {
|
|
137
|
+
const identity = await resolveStableClaudeQuotaIdentity({ credentialsPath });
|
|
138
|
+
if (identity) candidates.push(identity);
|
|
139
|
+
}
|
|
140
|
+
if (candidates.length === 0) {
|
|
141
|
+
this.logOnce("claude:no-stable-credentials", "quota refresh skipped for claude: no materialized Claude OAuth credentials found");
|
|
142
|
+
}
|
|
143
|
+
return candidates;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async discoverCodexCandidates(): Promise<QuotaCandidate[]> {
|
|
147
|
+
const homes = [
|
|
148
|
+
this.config.env.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), ".codex"),
|
|
149
|
+
...providerHomeConfigDirs("codex", "auth.json"),
|
|
150
|
+
];
|
|
151
|
+
const appServerUrl = this.config.env.CODEX_APP_SERVER_URL || process.env.CODEX_APP_SERVER_URL;
|
|
152
|
+
const candidates: QuotaCandidate[] = [];
|
|
153
|
+
for (const codexHome of homes) {
|
|
154
|
+
const identity = await resolveStableCodexQuotaIdentityFromHome({ codexHome });
|
|
155
|
+
if (identity) candidates.push({ ...identity, codexHome, ...(appServerUrl ? { appServerUrl } : {}) });
|
|
156
|
+
}
|
|
157
|
+
if (candidates.length === 0) {
|
|
158
|
+
this.logOnce("codex:no-stable-auth", "quota refresh skipped for codex: no account id found in Codex auth.json");
|
|
120
159
|
}
|
|
121
160
|
return candidates;
|
|
122
161
|
}
|
|
@@ -198,12 +237,13 @@ export class OrchestratorQuotaPoller {
|
|
|
198
237
|
});
|
|
199
238
|
}
|
|
200
239
|
if (candidate.provider === "codex") {
|
|
201
|
-
if (!candidate.appServerUrl) throw new QuotaCollectionError("creds_not_ready", "Codex app-server URL is not configured");
|
|
202
240
|
return collectCodexQuotaSample({
|
|
203
241
|
agentId: this.sourceAgentId(),
|
|
204
242
|
rateLimitsRead: () => this.options.codexRateLimitsRead
|
|
205
|
-
? this.options.codexRateLimitsRead(candidate.appServerUrl
|
|
206
|
-
:
|
|
243
|
+
? this.options.codexRateLimitsRead(candidate.appServerUrl ?? "")
|
|
244
|
+
: candidate.appServerUrl
|
|
245
|
+
? codexRateLimitsRead(candidate.appServerUrl)
|
|
246
|
+
: codexRateLimitsReadFromHome(candidate.codexHome),
|
|
207
247
|
});
|
|
208
248
|
}
|
|
209
249
|
return {};
|
|
@@ -239,12 +279,20 @@ export class OrchestratorQuotaPoller {
|
|
|
239
279
|
const suffix = retryAfterMs !== undefined ? `; retrying in ${Math.round(retryAfterMs / 1000)}s` : "";
|
|
240
280
|
this.log(`quota refresh failed for ${candidate.provider}/${candidate.accountKey}${suffix}: ${errMessage(error)}`);
|
|
241
281
|
}
|
|
282
|
+
|
|
283
|
+
private logOnce(key: string, message: string): void {
|
|
284
|
+
const lastLog = this.logStates.get(key);
|
|
285
|
+
const now = this.now();
|
|
286
|
+
if (lastLog && now - lastLog.at < QUOTA_FAILURE_LOG_INTERVAL_MS) return;
|
|
287
|
+
this.logStates.set(key, { key, at: now });
|
|
288
|
+
this.log(message);
|
|
289
|
+
}
|
|
242
290
|
}
|
|
243
291
|
|
|
244
|
-
async function codexRateLimitsRead(appServerUrl: string): Promise<unknown> {
|
|
292
|
+
async function codexRateLimitsRead(appServerUrl: string, attempts = 1): Promise<unknown> {
|
|
245
293
|
const client = new JsonRpcWebSocket(appServerUrl);
|
|
246
294
|
try {
|
|
247
|
-
await client
|
|
295
|
+
await connectWithRetry(client, attempts);
|
|
248
296
|
await client.request("initialize", {
|
|
249
297
|
clientInfo: { name: "agent-relay-orchestrator", title: "Agent Relay Orchestrator" },
|
|
250
298
|
capabilities: { experimentalApi: true },
|
|
@@ -255,6 +303,86 @@ async function codexRateLimitsRead(appServerUrl: string): Promise<unknown> {
|
|
|
255
303
|
}
|
|
256
304
|
}
|
|
257
305
|
|
|
306
|
+
async function codexRateLimitsReadFromHome(codexHome: string | undefined): Promise<unknown> {
|
|
307
|
+
if (!codexHome) throw new QuotaCollectionError("creds_not_ready", "Codex home is not configured");
|
|
308
|
+
const appServerUrl = await freeLoopbackWsUrl();
|
|
309
|
+
const command = codexCommandFromEnv() || "codex";
|
|
310
|
+
const proc = Bun.spawn([command, "app-server", "--listen", appServerUrl], {
|
|
311
|
+
env: {
|
|
312
|
+
...process.env,
|
|
313
|
+
CODEX_HOME: codexHome,
|
|
314
|
+
CODEX_APP_SERVER_URL: appServerUrl,
|
|
315
|
+
},
|
|
316
|
+
stdin: "ignore",
|
|
317
|
+
stdout: "ignore",
|
|
318
|
+
stderr: "ignore",
|
|
319
|
+
});
|
|
320
|
+
try {
|
|
321
|
+
return await codexRateLimitsRead(appServerUrl, CODEX_APP_SERVER_CONNECT_ATTEMPTS);
|
|
322
|
+
} finally {
|
|
323
|
+
proc.kill();
|
|
324
|
+
await Promise.race([proc.exited.catch(() => undefined), Bun.sleep(2_000)]).catch(() => undefined);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function connectWithRetry(client: JsonRpcWebSocket, attempts: number): Promise<void> {
|
|
329
|
+
let lastError: unknown;
|
|
330
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
331
|
+
try {
|
|
332
|
+
await client.connect();
|
|
333
|
+
return;
|
|
334
|
+
} catch (error) {
|
|
335
|
+
lastError = error;
|
|
336
|
+
await Bun.sleep(CODEX_APP_SERVER_CONNECT_RETRY_MS);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
throw lastError ?? new Error("websocket connect failed");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function freeLoopbackWsUrl(): Promise<string> {
|
|
343
|
+
const port = await new Promise<number>((resolve, reject) => {
|
|
344
|
+
const server = createServer();
|
|
345
|
+
server.on("error", reject);
|
|
346
|
+
server.listen(0, "127.0.0.1", () => {
|
|
347
|
+
const address = server.address();
|
|
348
|
+
const port = typeof address === "object" && address ? address.port : undefined;
|
|
349
|
+
server.close(() => port ? resolve(port) : reject(new Error("failed to reserve loopback port")));
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
return `ws://127.0.0.1:${port}`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function providerHomeRoot(): string {
|
|
356
|
+
return providerHomeRootFromEnv();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function providerHomeConfigDirs(provider: "claude" | "codex", markerFile: string): string[] {
|
|
360
|
+
const root = join(providerHomeRoot(), provider);
|
|
361
|
+
const dirs: string[] = [];
|
|
362
|
+
for (const profile of safeReadDir(root)) {
|
|
363
|
+
const profileDir = join(root, profile);
|
|
364
|
+
for (const instance of safeReadDir(profileDir)) {
|
|
365
|
+
const dir = join(profileDir, instance);
|
|
366
|
+
if (existsSync(join(dir, markerFile))) dirs.push(dir);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return dirs;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function providerHomeCredentialPaths(provider: "claude", markerFile: string): string[] {
|
|
373
|
+
return providerHomeConfigDirs(provider, markerFile).map((dir) => join(dir, markerFile));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function safeReadDir(path: string): string[] {
|
|
377
|
+
try {
|
|
378
|
+
return readdirSync(path, { withFileTypes: true })
|
|
379
|
+
.filter((entry) => entry.isDirectory())
|
|
380
|
+
.map((entry) => entry.name);
|
|
381
|
+
} catch {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
258
386
|
class JsonRpcWebSocket {
|
|
259
387
|
private ws!: WebSocket;
|
|
260
388
|
private nextId = 1;
|