agent-relay-runner 0.75.0 → 0.77.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/package.json +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/claude.ts +3 -4
- package/src/quota.ts +322 -19
- package/src/runner-core.ts +1 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.52"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
package/src/adapters/claude.ts
CHANGED
|
@@ -47,7 +47,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
47
47
|
stderr: "inherit",
|
|
48
48
|
});
|
|
49
49
|
void proc.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
|
|
50
|
-
return { pid: proc.pid, process: proc, meta: { monitor: config.monitor } };
|
|
50
|
+
return { pid: proc.pid, process: proc, meta: { monitor: config.monitor, env: args.env } };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
|
|
@@ -335,9 +335,8 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
return {
|
|
338
|
-
pid: undefined,
|
|
339
|
-
|
|
340
|
-
meta: {
|
|
338
|
+
pid: undefined, process: undefined, meta: {
|
|
339
|
+
env: spawnArgs.env,
|
|
341
340
|
monitor: config.monitor,
|
|
342
341
|
// When the profile disables the relay plugin, the bundled monitor never
|
|
343
342
|
// loads, so no monitor will ever connect — deliver() must use tmux injection.
|
package/src/quota.ts
CHANGED
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import type { ContextProbeMetrics, ProviderQuotaError, ProviderQuotaUpdateInput, QuotaState, QuotaWindowName } from "agent-relay-sdk";
|
|
5
7
|
import { errMessage, isRecord, quotaStateFromProbeMetrics, stringValue } from "agent-relay-sdk";
|
|
8
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
9
|
import type { ManagedProcess } from "./adapter";
|
|
10
|
+
import { agentRelayHome, runnerOutboxDirWithInfoFallback } from "./config";
|
|
7
11
|
|
|
8
12
|
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
9
13
|
const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
|
10
|
-
const QUOTA_POLL_INTERVAL_MS =
|
|
14
|
+
const QUOTA_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
15
|
+
const QUOTA_FAST_RETRY_MS = 15 * 1000;
|
|
11
16
|
const QUOTA_FAILURE_LOG_INTERVAL_MS = 5 * 60 * 1000;
|
|
17
|
+
const QUOTA_LOCK_STALE_MS = 60 * 1000;
|
|
12
18
|
|
|
13
19
|
type CodexQuotaClient = {
|
|
14
20
|
isConnected?: () => boolean;
|
|
15
21
|
accountRateLimitsRead?: () => Promise<unknown>;
|
|
16
22
|
};
|
|
17
23
|
|
|
24
|
+
type QuotaIdentity = {
|
|
25
|
+
provider: string;
|
|
26
|
+
accountKey: string;
|
|
27
|
+
credentialsPath?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type RunnerQuotaSample = {
|
|
31
|
+
quota?: QuotaState;
|
|
32
|
+
accountKey?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type SharedQuotaEntry = {
|
|
36
|
+
provider: string;
|
|
37
|
+
accountKey: string;
|
|
38
|
+
lastAttemptAt?: number;
|
|
39
|
+
nextAttemptAt?: number;
|
|
40
|
+
quota?: QuotaState;
|
|
41
|
+
lastError?: ProviderQuotaError | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
18
44
|
export class RunnerQuotaPoller {
|
|
19
45
|
private quotaState?: QuotaState;
|
|
20
46
|
private timer?: Timer;
|
|
@@ -28,6 +54,9 @@ export class RunnerQuotaPoller {
|
|
|
28
54
|
getProcess: () => ManagedProcess | undefined;
|
|
29
55
|
onUpdate: () => void;
|
|
30
56
|
log: (message: string) => void;
|
|
57
|
+
reportProviderQuota?: (input: ProviderQuotaUpdateInput) => Promise<unknown>;
|
|
58
|
+
quotaStateDir?: string;
|
|
59
|
+
fetchImpl?: typeof fetch;
|
|
31
60
|
}) {}
|
|
32
61
|
|
|
33
62
|
get current(): QuotaState | undefined {
|
|
@@ -64,26 +93,91 @@ export class RunnerQuotaPoller {
|
|
|
64
93
|
private async refresh(): Promise<void> {
|
|
65
94
|
if (this.inFlight) return;
|
|
66
95
|
this.inFlight = true;
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
let identity: QuotaIdentity | undefined;
|
|
98
|
+
let lockDir: string | undefined;
|
|
67
99
|
try {
|
|
68
|
-
const
|
|
100
|
+
const process = this.options.getProcess();
|
|
101
|
+
identity = await resolveRunnerQuotaIdentity({
|
|
102
|
+
provider: this.options.provider,
|
|
103
|
+
process,
|
|
104
|
+
});
|
|
105
|
+
const attempt = beginSharedQuotaAttempt(identity, this.options.quotaStateDir, now);
|
|
106
|
+
if (!attempt.poll) {
|
|
107
|
+
if (attempt.entry?.quota && this.active) {
|
|
108
|
+
this.quotaState = attempt.entry.quota;
|
|
109
|
+
this.options.onUpdate();
|
|
110
|
+
}
|
|
111
|
+
this.schedule(attempt.retryInMs);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
lockDir = attempt.lockDir;
|
|
115
|
+
const sample = await collectRunnerQuotaSample({
|
|
69
116
|
provider: this.options.provider,
|
|
70
117
|
agentId: this.options.agentId,
|
|
71
|
-
process
|
|
118
|
+
process,
|
|
119
|
+
credentialsPath: identity.credentialsPath,
|
|
120
|
+
fetchImpl: this.options.fetchImpl,
|
|
72
121
|
});
|
|
73
|
-
|
|
122
|
+
const quota = sample.quota;
|
|
123
|
+
if (!quota) throw new QuotaCollectionError("creds_not_ready", `${this.options.provider} quota source is not ready`);
|
|
124
|
+
if (this.active) {
|
|
125
|
+
const update: ProviderQuotaUpdateInput = {
|
|
126
|
+
provider: identity.provider,
|
|
127
|
+
accountKey: sample.accountKey ?? identity.accountKey,
|
|
128
|
+
quota,
|
|
129
|
+
lastAttemptAt: quota.updatedAt,
|
|
130
|
+
sourceAgentId: this.options.agentId,
|
|
131
|
+
};
|
|
132
|
+
await this.reportProviderQuota(update);
|
|
133
|
+
finishSharedQuotaAttempt(identity, {
|
|
134
|
+
provider: update.provider,
|
|
135
|
+
accountKey: update.accountKey,
|
|
136
|
+
quota,
|
|
137
|
+
lastAttemptAt: update.lastAttemptAt,
|
|
138
|
+
nextAttemptAt: now + QUOTA_POLL_INTERVAL_MS,
|
|
139
|
+
lastError: null,
|
|
140
|
+
}, this.options.quotaStateDir);
|
|
74
141
|
this.quotaState = quota;
|
|
75
142
|
this.options.onUpdate();
|
|
76
143
|
}
|
|
77
144
|
this.schedule();
|
|
78
145
|
} catch (error) {
|
|
79
146
|
const retryAfterMs = quotaRetryAfterMs(error);
|
|
147
|
+
const lastError = providerQuotaErrorFromCollectorError(error, retryAfterMs);
|
|
148
|
+
const lastAttemptAt = Date.now();
|
|
149
|
+
const retryDelayMs = retryAfterMs ?? (identity && !readSharedQuotaEntry(identity, this.options.quotaStateDir)?.lastAttemptAt ? QUOTA_FAST_RETRY_MS : QUOTA_POLL_INTERVAL_MS);
|
|
150
|
+
if (identity) {
|
|
151
|
+
await this.reportProviderQuota({
|
|
152
|
+
provider: identity.provider,
|
|
153
|
+
accountKey: identity.accountKey,
|
|
154
|
+
lastAttemptAt,
|
|
155
|
+
lastError,
|
|
156
|
+
sourceAgentId: this.options.agentId,
|
|
157
|
+
}).catch((publishError) => {
|
|
158
|
+
this.options.log(`quota status publish failed: ${errMessage(publishError)}`);
|
|
159
|
+
});
|
|
160
|
+
finishSharedQuotaAttempt(identity, {
|
|
161
|
+
provider: identity.provider,
|
|
162
|
+
accountKey: identity.accountKey,
|
|
163
|
+
lastAttemptAt,
|
|
164
|
+
nextAttemptAt: lastAttemptAt + retryDelayMs,
|
|
165
|
+
lastError,
|
|
166
|
+
}, this.options.quotaStateDir);
|
|
167
|
+
}
|
|
80
168
|
if (this.active) this.logFailure(error, retryAfterMs);
|
|
81
|
-
this.schedule(
|
|
169
|
+
this.schedule(retryDelayMs);
|
|
82
170
|
} finally {
|
|
171
|
+
if (lockDir) releaseQuotaLock(lockDir);
|
|
83
172
|
this.inFlight = false;
|
|
84
173
|
}
|
|
85
174
|
}
|
|
86
175
|
|
|
176
|
+
private async reportProviderQuota(input: ProviderQuotaUpdateInput): Promise<void> {
|
|
177
|
+
if (!this.options.reportProviderQuota) return;
|
|
178
|
+
await this.options.reportProviderQuota(input);
|
|
179
|
+
}
|
|
180
|
+
|
|
87
181
|
private logFailure(error: unknown, retryAfterMs: number | undefined): void {
|
|
88
182
|
const key = retryAfterMs !== undefined ? `retry-after:${retryAfterMs}` : errMessage(error);
|
|
89
183
|
const now = Date.now();
|
|
@@ -101,31 +195,91 @@ export class QuotaRetryAfterError extends Error {
|
|
|
101
195
|
}
|
|
102
196
|
}
|
|
103
197
|
|
|
104
|
-
|
|
198
|
+
class QuotaCollectionError extends Error {
|
|
199
|
+
constructor(readonly type: ProviderQuotaError["type"], message: string) {
|
|
200
|
+
super(message);
|
|
201
|
+
this.name = "QuotaCollectionError";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function quotaRetryAfterMs(error: unknown): number | undefined {
|
|
105
206
|
return error instanceof QuotaRetryAfterError ? error.retryAfterMs : undefined;
|
|
106
207
|
}
|
|
107
208
|
|
|
108
|
-
|
|
209
|
+
function providerQuotaErrorFromCollectorError(error: unknown, retryAfterMs: number | undefined): ProviderQuotaError {
|
|
210
|
+
if (retryAfterMs !== undefined) {
|
|
211
|
+
return { type: "rate_limited", message: errMessage(error), retryAfterMs };
|
|
212
|
+
}
|
|
213
|
+
if (error instanceof QuotaCollectionError) {
|
|
214
|
+
return { type: error.type, message: error.message };
|
|
215
|
+
}
|
|
216
|
+
return { type: "other", message: errMessage(error) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function resolveRunnerQuotaIdentity(input: {
|
|
220
|
+
provider: string;
|
|
221
|
+
process?: ManagedProcess;
|
|
222
|
+
}): Promise<QuotaIdentity> {
|
|
223
|
+
const provider = input.provider.trim().toLowerCase();
|
|
224
|
+
if (provider === "claude") {
|
|
225
|
+
const credentialsPath = claudeCredentialsPathFromProcess(input.process);
|
|
226
|
+
const token = await readClaudeOAuthAccessToken(credentialsPath);
|
|
227
|
+
return {
|
|
228
|
+
provider,
|
|
229
|
+
accountKey: token ? `credential:${shortHash(token)}` : hostProviderAccountKey(provider),
|
|
230
|
+
credentialsPath,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
provider,
|
|
235
|
+
accountKey: codexAccountKeyFromProcess(input.process) ?? hostProviderAccountKey(provider),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function collectRunnerQuotaSample(input: {
|
|
109
240
|
provider: string;
|
|
110
241
|
agentId: string;
|
|
111
242
|
process?: ManagedProcess;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
243
|
+
credentialsPath?: string;
|
|
244
|
+
fetchImpl?: typeof fetch;
|
|
245
|
+
}): Promise<RunnerQuotaSample> {
|
|
246
|
+
if (input.provider === "claude") return collectClaudeQuotaSample({ agentId: input.agentId, credentialsPath: input.credentialsPath, fetchImpl: input.fetchImpl });
|
|
247
|
+
if (input.provider !== "codex") return {};
|
|
115
248
|
const client = input.process?.meta?.client as CodexQuotaClient | undefined;
|
|
116
|
-
if (!client?.isConnected?.() || typeof client.accountRateLimitsRead !== "function") return
|
|
117
|
-
return
|
|
249
|
+
if (!client?.isConnected?.() || typeof client.accountRateLimitsRead !== "function") return {};
|
|
250
|
+
return collectCodexQuotaSample({
|
|
118
251
|
agentId: codexProcessAgentId(input.process, input.agentId),
|
|
119
252
|
rateLimitsRead: () => client.accountRateLimitsRead!(),
|
|
120
253
|
});
|
|
121
254
|
}
|
|
122
255
|
|
|
256
|
+
async function collectClaudeQuotaSample(options: {
|
|
257
|
+
agentId: string;
|
|
258
|
+
credentialsPath?: string;
|
|
259
|
+
fetchImpl?: typeof fetch;
|
|
260
|
+
now?: number;
|
|
261
|
+
}): Promise<RunnerQuotaSample> {
|
|
262
|
+
const payload = await fetchClaudeUsagePayload(options);
|
|
263
|
+
if (payload === undefined) return {};
|
|
264
|
+
return {
|
|
265
|
+
quota: claudeUsageQuotaState(payload, options.agentId, options.now ?? Date.now()),
|
|
266
|
+
accountKey: providerPayloadAccountKey(payload),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
123
270
|
export async function collectClaudeQuotaState(options: {
|
|
124
271
|
agentId: string;
|
|
125
272
|
credentialsPath?: string;
|
|
126
273
|
fetchImpl?: typeof fetch;
|
|
127
274
|
now?: number;
|
|
128
275
|
}): Promise<QuotaState | undefined> {
|
|
276
|
+
return (await collectClaudeQuotaSample(options)).quota;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function fetchClaudeUsagePayload(options: {
|
|
280
|
+
credentialsPath?: string;
|
|
281
|
+
fetchImpl?: typeof fetch;
|
|
282
|
+
}): Promise<unknown | undefined> {
|
|
129
283
|
const accessToken = await readClaudeOAuthAccessToken(options.credentialsPath ?? defaultClaudeCredentialsPath());
|
|
130
284
|
if (!accessToken) return undefined;
|
|
131
285
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -140,8 +294,9 @@ export async function collectClaudeQuotaState(options: {
|
|
|
140
294
|
if (response.status === 429) {
|
|
141
295
|
throw new QuotaRetryAfterError(retryAfterHeaderMs(response.headers.get("retry-after")) ?? 60_000, "Claude quota source returned 429");
|
|
142
296
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
297
|
+
if (response.status === 401 || response.status === 403) throw new QuotaCollectionError("auth", `Claude quota source returned ${response.status}`);
|
|
298
|
+
if (!response.ok) throw new QuotaCollectionError("other", `Claude quota source returned ${response.status}`);
|
|
299
|
+
return response.json();
|
|
145
300
|
}
|
|
146
301
|
|
|
147
302
|
export function claudeUsageQuotaState(payload: unknown, agentId: string, now = Date.now()): QuotaState | undefined {
|
|
@@ -163,8 +318,20 @@ export async function collectCodexQuotaState(input: {
|
|
|
163
318
|
rateLimitsRead: () => Promise<unknown>;
|
|
164
319
|
now?: number;
|
|
165
320
|
}): Promise<QuotaState | undefined> {
|
|
321
|
+
return (await collectCodexQuotaSample(input)).quota;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function collectCodexQuotaSample(input: {
|
|
325
|
+
agentId: string;
|
|
326
|
+
rateLimitsRead: () => Promise<unknown>;
|
|
327
|
+
now?: number;
|
|
328
|
+
}): Promise<RunnerQuotaSample> {
|
|
166
329
|
try {
|
|
167
|
-
|
|
330
|
+
const payload = await input.rateLimitsRead();
|
|
331
|
+
return {
|
|
332
|
+
quota: codexRateLimitsQuotaState(payload, input.agentId, input.now ?? Date.now()),
|
|
333
|
+
accountKey: providerPayloadAccountKey(payload),
|
|
334
|
+
};
|
|
168
335
|
} catch (error) {
|
|
169
336
|
const retryAfter = codexRetryAfterMs(error);
|
|
170
337
|
if (retryAfter !== undefined) throw new QuotaRetryAfterError(retryAfter, "Codex quota source returned 429");
|
|
@@ -190,7 +357,143 @@ export function codexRateLimitsQuotaState(payload: unknown, agentId: string, now
|
|
|
190
357
|
}
|
|
191
358
|
|
|
192
359
|
function defaultClaudeCredentialsPath(): string {
|
|
193
|
-
return join(
|
|
360
|
+
return join(defaultClaudeConfigDir(), ".credentials.json");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function defaultClaudeConfigDir(): string {
|
|
364
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function claudeCredentialsPathFromProcess(proc: ManagedProcess | undefined): string {
|
|
368
|
+
const env = processEnvFromMeta(proc);
|
|
369
|
+
const configDir = stringValue(env?.CLAUDE_CONFIG_DIR) ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
370
|
+
return join(configDir, ".credentials.json");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function codexAccountKeyFromProcess(proc: ManagedProcess | undefined): string | undefined {
|
|
374
|
+
const env = processEnvFromMeta(proc);
|
|
375
|
+
const codexHome = stringValue(env?.CODEX_HOME) ?? process.env.CODEX_HOME;
|
|
376
|
+
return codexHome ? `home:${shortHash(codexHome)}` : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function processEnvFromMeta(proc: ManagedProcess | undefined): Record<string, unknown> | undefined {
|
|
380
|
+
const direct = isRecord(proc?.meta?.env) ? proc.meta.env : undefined;
|
|
381
|
+
if (direct) return direct;
|
|
382
|
+
const config = isRecord(proc?.meta?.config) ? proc.meta.config : undefined;
|
|
383
|
+
return isRecord(config?.env) ? config.env : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function hostProviderAccountKey(provider: string): string {
|
|
387
|
+
return `host:${hostname()}:${provider}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function beginSharedQuotaAttempt(identity: QuotaIdentity, stateDir?: string, now = Date.now()): {
|
|
391
|
+
poll: boolean;
|
|
392
|
+
retryInMs: number;
|
|
393
|
+
entry?: SharedQuotaEntry;
|
|
394
|
+
lockDir?: string;
|
|
395
|
+
} {
|
|
396
|
+
const entry = readSharedQuotaEntry(identity, stateDir);
|
|
397
|
+
const nextAttemptAt = entry?.nextAttemptAt;
|
|
398
|
+
if (nextAttemptAt !== undefined && nextAttemptAt > now) {
|
|
399
|
+
return { poll: false, retryInMs: nextAttemptAt - now, entry };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const lockDir = sharedQuotaLockDir(identity, stateDir);
|
|
403
|
+
if (!acquireQuotaLock(lockDir, now)) {
|
|
404
|
+
return { poll: false, retryInMs: QUOTA_FAST_RETRY_MS, entry };
|
|
405
|
+
}
|
|
406
|
+
return { poll: true, retryInMs: 0, entry, lockDir };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function finishSharedQuotaAttempt(identity: QuotaIdentity, entry: SharedQuotaEntry, stateDir?: string): void {
|
|
410
|
+
const path = sharedQuotaStatePath(identity, stateDir);
|
|
411
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
412
|
+
writeFileSync(path, JSON.stringify(entry, null, 2));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function readSharedQuotaEntry(identity: QuotaIdentity, stateDir?: string): SharedQuotaEntry | undefined {
|
|
416
|
+
try {
|
|
417
|
+
const parsed = JSON.parse(readFileSync(sharedQuotaStatePath(identity, stateDir), "utf8")) as unknown;
|
|
418
|
+
if (!isRecord(parsed)) return undefined;
|
|
419
|
+
const provider = stringValue(parsed.provider);
|
|
420
|
+
const accountKey = stringValue(parsed.accountKey);
|
|
421
|
+
if (!provider || !accountKey) return undefined;
|
|
422
|
+
return {
|
|
423
|
+
provider,
|
|
424
|
+
accountKey,
|
|
425
|
+
...(typeof parsed.lastAttemptAt === "number" && Number.isFinite(parsed.lastAttemptAt) ? { lastAttemptAt: parsed.lastAttemptAt } : {}),
|
|
426
|
+
...(typeof parsed.nextAttemptAt === "number" && Number.isFinite(parsed.nextAttemptAt) ? { nextAttemptAt: parsed.nextAttemptAt } : {}),
|
|
427
|
+
...(isQuotaState(parsed.quota) ? { quota: parsed.quota } : {}),
|
|
428
|
+
...(parsed.lastError === null || isRecord(parsed.lastError) ? { lastError: parsed.lastError as ProviderQuotaError | null } : {}),
|
|
429
|
+
};
|
|
430
|
+
} catch {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function sharedQuotaStatePath(identity: QuotaIdentity, stateDir?: string): string {
|
|
436
|
+
return join(sharedQuotaStateDir(stateDir), `${sharedQuotaKey(identity)}.json`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function sharedQuotaLockDir(identity: QuotaIdentity, stateDir?: string): string {
|
|
440
|
+
return join(sharedQuotaStateDir(stateDir), `${sharedQuotaKey(identity)}.lock`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function sharedQuotaStateDir(stateDir?: string): string {
|
|
444
|
+
return stateDir ?? join(runnerOutboxDirWithInfoFallback() ?? agentRelayHome(), "quota");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function sharedQuotaKey(identity: QuotaIdentity): string {
|
|
448
|
+
return sanitizeFsName(`${identity.provider}-${identity.accountKey}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function acquireQuotaLock(lockDir: string, now = Date.now()): boolean {
|
|
452
|
+
try {
|
|
453
|
+
mkdirSync(lockDir, { recursive: false });
|
|
454
|
+
writeFileSync(join(lockDir, "owner"), String(now));
|
|
455
|
+
return true;
|
|
456
|
+
} catch {
|
|
457
|
+
const ownerPath = join(lockDir, "owner");
|
|
458
|
+
try {
|
|
459
|
+
const lockedAt = Number(readFileSync(ownerPath, "utf8"));
|
|
460
|
+
if (Number.isFinite(lockedAt) && now - lockedAt <= QUOTA_LOCK_STALE_MS) return false;
|
|
461
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
462
|
+
mkdirSync(lockDir, { recursive: false });
|
|
463
|
+
writeFileSync(ownerPath, String(now));
|
|
464
|
+
return true;
|
|
465
|
+
} catch {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function releaseQuotaLock(lockDir: string): void {
|
|
472
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isQuotaState(value: unknown): value is QuotaState {
|
|
476
|
+
return isRecord(value) && Array.isArray(value.windows) && typeof value.updatedAt === "number";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function providerPayloadAccountKey(payload: unknown): string | undefined {
|
|
480
|
+
if (!isRecord(payload)) return undefined;
|
|
481
|
+
const direct = stringValue(payload.accountKey) ??
|
|
482
|
+
stringValue(payload.account_key) ??
|
|
483
|
+
stringValue(payload.accountId) ??
|
|
484
|
+
stringValue(payload.account_id) ??
|
|
485
|
+
stringValue(payload.orgId) ??
|
|
486
|
+
stringValue(payload.org_id) ??
|
|
487
|
+
stringValue(payload.organizationId) ??
|
|
488
|
+
stringValue(payload.organization_id);
|
|
489
|
+
if (direct) return direct;
|
|
490
|
+
const account = isRecord(payload.account) ? payload.account : undefined;
|
|
491
|
+
const org = isRecord(payload.org) ? payload.org : isRecord(payload.organization) ? payload.organization : undefined;
|
|
492
|
+
return stringValue(account?.id) ?? stringValue(org?.id);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function shortHash(value: string): string {
|
|
496
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
194
497
|
}
|
|
195
498
|
|
|
196
499
|
function quotaProviderSupported(provider: string): boolean {
|
package/src/runner-core.ts
CHANGED
|
@@ -353,7 +353,7 @@ export class AgentRunner {
|
|
|
353
353
|
clearProviderTurn: (reason) => this.forceClearProviderTurn(reason),
|
|
354
354
|
sessionDebug: (message) => this.sessionDebug(message),
|
|
355
355
|
});
|
|
356
|
-
this.quotaPoller = new RunnerQuotaPoller({ provider: options.provider, agentId: this.agentId, getProcess: () => this.process, onUpdate: () => this.publishStatus(), log: (message) => this.logRunnerDiagnostic(message) });
|
|
356
|
+
this.quotaPoller = new RunnerQuotaPoller({ provider: options.provider, agentId: this.agentId, getProcess: () => this.process, onUpdate: () => this.publishStatus(), log: (message) => this.logRunnerDiagnostic(message), reportProviderQuota: (input) => this.http.upsertProviderQuota(input).then(() => {}) });
|
|
357
357
|
this.bus = new RelayBusClient({
|
|
358
358
|
url: relayBusUrl(options.relayUrl),
|
|
359
359
|
role: "provider",
|
|
@@ -2126,7 +2126,6 @@ export class AgentRunner {
|
|
|
2126
2126
|
...(terminalSocket ? { tmuxSocket: terminalSocket } : {}),
|
|
2127
2127
|
};
|
|
2128
2128
|
if (context) meta.context = context;
|
|
2129
|
-
if (quota) meta.quota = quota;
|
|
2130
2129
|
return meta;
|
|
2131
2130
|
}
|
|
2132
2131
|
|