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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.78.0",
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.53"
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
  }
@@ -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
- resolveClaudeQuotaIdentity,
13
- resolveCodexQuotaIdentityFromHome,
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 { OrchestratorConfig } from "./config";
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
- const configDir = this.config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
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
- const codexHome = this.config.env.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), ".codex");
116
- candidates.push({
117
- ...(await resolveCodexQuotaIdentityFromHome({ codexHome, host: this.config.hostname })),
118
- appServerUrl: this.config.env.CODEX_APP_SERVER_URL || process.env.CODEX_APP_SERVER_URL,
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
- : codexRateLimitsRead(candidate.appServerUrl!),
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.connect();
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;