agent-relay-runner 0.76.0 → 0.77.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/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/claude.ts +3 -4
- package/src/quota.ts +342 -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.1",
|
|
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,25 +1,52 @@
|
|
|
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, statSync, 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;
|
|
21
47
|
private inFlight = false;
|
|
22
48
|
private active = false;
|
|
49
|
+
private activeLockDir?: string;
|
|
23
50
|
private lastLog?: { key: string; at: number };
|
|
24
51
|
|
|
25
52
|
constructor(private readonly options: {
|
|
@@ -28,6 +55,9 @@ export class RunnerQuotaPoller {
|
|
|
28
55
|
getProcess: () => ManagedProcess | undefined;
|
|
29
56
|
onUpdate: () => void;
|
|
30
57
|
log: (message: string) => void;
|
|
58
|
+
reportProviderQuota?: (input: ProviderQuotaUpdateInput) => Promise<unknown>;
|
|
59
|
+
quotaStateDir?: string;
|
|
60
|
+
fetchImpl?: typeof fetch;
|
|
31
61
|
}) {}
|
|
32
62
|
|
|
33
63
|
get current(): QuotaState | undefined {
|
|
@@ -45,6 +75,9 @@ export class RunnerQuotaPoller {
|
|
|
45
75
|
this.active = false;
|
|
46
76
|
if (this.timer) clearTimeout(this.timer);
|
|
47
77
|
this.timer = undefined;
|
|
78
|
+
const lockDir = this.activeLockDir;
|
|
79
|
+
this.activeLockDir = undefined;
|
|
80
|
+
if (lockDir) releaseQuotaLock(lockDir);
|
|
48
81
|
}
|
|
49
82
|
|
|
50
83
|
clear(): void {
|
|
@@ -64,26 +97,95 @@ export class RunnerQuotaPoller {
|
|
|
64
97
|
private async refresh(): Promise<void> {
|
|
65
98
|
if (this.inFlight) return;
|
|
66
99
|
this.inFlight = true;
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
let identity: QuotaIdentity | undefined;
|
|
102
|
+
let lockDir: string | undefined;
|
|
67
103
|
try {
|
|
68
|
-
const
|
|
104
|
+
const process = this.options.getProcess();
|
|
105
|
+
identity = await resolveRunnerQuotaIdentity({
|
|
106
|
+
provider: this.options.provider,
|
|
107
|
+
process,
|
|
108
|
+
});
|
|
109
|
+
const attempt = beginSharedQuotaAttempt(identity, this.options.quotaStateDir, now);
|
|
110
|
+
if (!attempt.poll) {
|
|
111
|
+
if (attempt.entry?.quota && this.active) {
|
|
112
|
+
this.quotaState = attempt.entry.quota;
|
|
113
|
+
this.options.onUpdate();
|
|
114
|
+
}
|
|
115
|
+
this.schedule(attempt.retryInMs);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
lockDir = attempt.lockDir;
|
|
119
|
+
this.activeLockDir = lockDir;
|
|
120
|
+
const sample = await collectRunnerQuotaSample({
|
|
69
121
|
provider: this.options.provider,
|
|
70
122
|
agentId: this.options.agentId,
|
|
71
|
-
process
|
|
123
|
+
process,
|
|
124
|
+
credentialsPath: identity.credentialsPath,
|
|
125
|
+
fetchImpl: this.options.fetchImpl,
|
|
72
126
|
});
|
|
73
|
-
|
|
127
|
+
const quota = sample.quota;
|
|
128
|
+
if (!quota) throw new QuotaCollectionError("creds_not_ready", `${this.options.provider} quota source is not ready`);
|
|
129
|
+
if (this.active) {
|
|
130
|
+
const update: ProviderQuotaUpdateInput = {
|
|
131
|
+
provider: identity.provider,
|
|
132
|
+
accountKey: sample.accountKey ?? identity.accountKey,
|
|
133
|
+
quota,
|
|
134
|
+
lastAttemptAt: quota.updatedAt,
|
|
135
|
+
sourceAgentId: this.options.agentId,
|
|
136
|
+
};
|
|
137
|
+
await this.reportProviderQuota(update);
|
|
138
|
+
finishSharedQuotaAttempt(identity, {
|
|
139
|
+
provider: update.provider,
|
|
140
|
+
accountKey: update.accountKey,
|
|
141
|
+
quota,
|
|
142
|
+
lastAttemptAt: update.lastAttemptAt,
|
|
143
|
+
nextAttemptAt: now + QUOTA_POLL_INTERVAL_MS,
|
|
144
|
+
lastError: null,
|
|
145
|
+
}, this.options.quotaStateDir);
|
|
74
146
|
this.quotaState = quota;
|
|
75
147
|
this.options.onUpdate();
|
|
76
148
|
}
|
|
77
149
|
this.schedule();
|
|
78
150
|
} catch (error) {
|
|
79
151
|
const retryAfterMs = quotaRetryAfterMs(error);
|
|
152
|
+
const lastError = providerQuotaErrorFromCollectorError(error, retryAfterMs);
|
|
153
|
+
const lastAttemptAt = Date.now();
|
|
154
|
+
const retryDelayMs = retryAfterMs ?? (identity && !readSharedQuotaEntry(identity, this.options.quotaStateDir)?.lastAttemptAt ? QUOTA_FAST_RETRY_MS : QUOTA_POLL_INTERVAL_MS);
|
|
155
|
+
if (identity && this.active) {
|
|
156
|
+
await this.reportProviderQuota({
|
|
157
|
+
provider: identity.provider,
|
|
158
|
+
accountKey: identity.accountKey,
|
|
159
|
+
lastAttemptAt,
|
|
160
|
+
lastError,
|
|
161
|
+
sourceAgentId: this.options.agentId,
|
|
162
|
+
}).catch((publishError) => {
|
|
163
|
+
this.options.log(`quota status publish failed: ${errMessage(publishError)}`);
|
|
164
|
+
});
|
|
165
|
+
finishSharedQuotaAttempt(identity, {
|
|
166
|
+
provider: identity.provider,
|
|
167
|
+
accountKey: identity.accountKey,
|
|
168
|
+
lastAttemptAt,
|
|
169
|
+
nextAttemptAt: lastAttemptAt + retryDelayMs,
|
|
170
|
+
lastError,
|
|
171
|
+
}, this.options.quotaStateDir);
|
|
172
|
+
}
|
|
80
173
|
if (this.active) this.logFailure(error, retryAfterMs);
|
|
81
|
-
this.schedule(
|
|
174
|
+
this.schedule(retryDelayMs);
|
|
82
175
|
} finally {
|
|
176
|
+
if (lockDir && this.activeLockDir === lockDir) {
|
|
177
|
+
releaseQuotaLock(lockDir);
|
|
178
|
+
this.activeLockDir = undefined;
|
|
179
|
+
}
|
|
83
180
|
this.inFlight = false;
|
|
84
181
|
}
|
|
85
182
|
}
|
|
86
183
|
|
|
184
|
+
private async reportProviderQuota(input: ProviderQuotaUpdateInput): Promise<void> {
|
|
185
|
+
if (!this.options.reportProviderQuota) return;
|
|
186
|
+
await this.options.reportProviderQuota(input);
|
|
187
|
+
}
|
|
188
|
+
|
|
87
189
|
private logFailure(error: unknown, retryAfterMs: number | undefined): void {
|
|
88
190
|
const key = retryAfterMs !== undefined ? `retry-after:${retryAfterMs}` : errMessage(error);
|
|
89
191
|
const now = Date.now();
|
|
@@ -101,31 +203,90 @@ export class QuotaRetryAfterError extends Error {
|
|
|
101
203
|
}
|
|
102
204
|
}
|
|
103
205
|
|
|
104
|
-
|
|
206
|
+
class QuotaCollectionError extends Error {
|
|
207
|
+
constructor(readonly type: ProviderQuotaError["type"], message: string) {
|
|
208
|
+
super(message);
|
|
209
|
+
this.name = "QuotaCollectionError";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function quotaRetryAfterMs(error: unknown): number | undefined {
|
|
105
214
|
return error instanceof QuotaRetryAfterError ? error.retryAfterMs : undefined;
|
|
106
215
|
}
|
|
107
216
|
|
|
108
|
-
|
|
217
|
+
function providerQuotaErrorFromCollectorError(error: unknown, retryAfterMs: number | undefined): ProviderQuotaError {
|
|
218
|
+
if (retryAfterMs !== undefined) {
|
|
219
|
+
return { type: "rate_limited", message: errMessage(error), retryAfterMs };
|
|
220
|
+
}
|
|
221
|
+
if (error instanceof QuotaCollectionError) {
|
|
222
|
+
return { type: error.type, message: error.message };
|
|
223
|
+
}
|
|
224
|
+
return { type: "other", message: errMessage(error) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function resolveRunnerQuotaIdentity(input: {
|
|
228
|
+
provider: string;
|
|
229
|
+
process?: ManagedProcess;
|
|
230
|
+
}): Promise<QuotaIdentity> {
|
|
231
|
+
const provider = input.provider.trim().toLowerCase();
|
|
232
|
+
if (provider === "claude") {
|
|
233
|
+
const credentialsPath = claudeCredentialsPathFromProcess(input.process);
|
|
234
|
+
return {
|
|
235
|
+
provider,
|
|
236
|
+
accountKey: hostProviderAccountKey(),
|
|
237
|
+
credentialsPath,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
provider,
|
|
242
|
+
accountKey: codexAccountKeyFromProcess(input.process) ?? hostProviderAccountKey(),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function collectRunnerQuotaSample(input: {
|
|
109
247
|
provider: string;
|
|
110
248
|
agentId: string;
|
|
111
249
|
process?: ManagedProcess;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
250
|
+
credentialsPath?: string;
|
|
251
|
+
fetchImpl?: typeof fetch;
|
|
252
|
+
}): Promise<RunnerQuotaSample> {
|
|
253
|
+
if (input.provider === "claude") return collectClaudeQuotaSample({ agentId: input.agentId, credentialsPath: input.credentialsPath, fetchImpl: input.fetchImpl });
|
|
254
|
+
if (input.provider !== "codex") return {};
|
|
115
255
|
const client = input.process?.meta?.client as CodexQuotaClient | undefined;
|
|
116
|
-
if (!client?.isConnected?.() || typeof client.accountRateLimitsRead !== "function") return
|
|
117
|
-
return
|
|
256
|
+
if (!client?.isConnected?.() || typeof client.accountRateLimitsRead !== "function") return {};
|
|
257
|
+
return collectCodexQuotaSample({
|
|
118
258
|
agentId: codexProcessAgentId(input.process, input.agentId),
|
|
119
259
|
rateLimitsRead: () => client.accountRateLimitsRead!(),
|
|
120
260
|
});
|
|
121
261
|
}
|
|
122
262
|
|
|
263
|
+
async function collectClaudeQuotaSample(options: {
|
|
264
|
+
agentId: string;
|
|
265
|
+
credentialsPath?: string;
|
|
266
|
+
fetchImpl?: typeof fetch;
|
|
267
|
+
now?: number;
|
|
268
|
+
}): Promise<RunnerQuotaSample> {
|
|
269
|
+
const payload = await fetchClaudeUsagePayload(options);
|
|
270
|
+
if (payload === undefined) return {};
|
|
271
|
+
return {
|
|
272
|
+
quota: claudeUsageQuotaState(payload, options.agentId, options.now ?? Date.now()),
|
|
273
|
+
accountKey: providerPayloadAccountKey(payload),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
123
277
|
export async function collectClaudeQuotaState(options: {
|
|
124
278
|
agentId: string;
|
|
125
279
|
credentialsPath?: string;
|
|
126
280
|
fetchImpl?: typeof fetch;
|
|
127
281
|
now?: number;
|
|
128
282
|
}): Promise<QuotaState | undefined> {
|
|
283
|
+
return (await collectClaudeQuotaSample(options)).quota;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function fetchClaudeUsagePayload(options: {
|
|
287
|
+
credentialsPath?: string;
|
|
288
|
+
fetchImpl?: typeof fetch;
|
|
289
|
+
}): Promise<unknown | undefined> {
|
|
129
290
|
const accessToken = await readClaudeOAuthAccessToken(options.credentialsPath ?? defaultClaudeCredentialsPath());
|
|
130
291
|
if (!accessToken) return undefined;
|
|
131
292
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -140,8 +301,9 @@ export async function collectClaudeQuotaState(options: {
|
|
|
140
301
|
if (response.status === 429) {
|
|
141
302
|
throw new QuotaRetryAfterError(retryAfterHeaderMs(response.headers.get("retry-after")) ?? 60_000, "Claude quota source returned 429");
|
|
142
303
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
304
|
+
if (response.status === 401 || response.status === 403) throw new QuotaCollectionError("auth", `Claude quota source returned ${response.status}`);
|
|
305
|
+
if (!response.ok) throw new QuotaCollectionError("other", `Claude quota source returned ${response.status}`);
|
|
306
|
+
return response.json();
|
|
145
307
|
}
|
|
146
308
|
|
|
147
309
|
export function claudeUsageQuotaState(payload: unknown, agentId: string, now = Date.now()): QuotaState | undefined {
|
|
@@ -163,8 +325,20 @@ export async function collectCodexQuotaState(input: {
|
|
|
163
325
|
rateLimitsRead: () => Promise<unknown>;
|
|
164
326
|
now?: number;
|
|
165
327
|
}): Promise<QuotaState | undefined> {
|
|
328
|
+
return (await collectCodexQuotaSample(input)).quota;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function collectCodexQuotaSample(input: {
|
|
332
|
+
agentId: string;
|
|
333
|
+
rateLimitsRead: () => Promise<unknown>;
|
|
334
|
+
now?: number;
|
|
335
|
+
}): Promise<RunnerQuotaSample> {
|
|
166
336
|
try {
|
|
167
|
-
|
|
337
|
+
const payload = await input.rateLimitsRead();
|
|
338
|
+
return {
|
|
339
|
+
quota: codexRateLimitsQuotaState(payload, input.agentId, input.now ?? Date.now()),
|
|
340
|
+
accountKey: providerPayloadAccountKey(payload),
|
|
341
|
+
};
|
|
168
342
|
} catch (error) {
|
|
169
343
|
const retryAfter = codexRetryAfterMs(error);
|
|
170
344
|
if (retryAfter !== undefined) throw new QuotaRetryAfterError(retryAfter, "Codex quota source returned 429");
|
|
@@ -190,7 +364,156 @@ export function codexRateLimitsQuotaState(payload: unknown, agentId: string, now
|
|
|
190
364
|
}
|
|
191
365
|
|
|
192
366
|
function defaultClaudeCredentialsPath(): string {
|
|
193
|
-
return join(
|
|
367
|
+
return join(defaultClaudeConfigDir(), ".credentials.json");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function defaultClaudeConfigDir(): string {
|
|
371
|
+
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function claudeCredentialsPathFromProcess(proc: ManagedProcess | undefined): string {
|
|
375
|
+
const env = processEnvFromMeta(proc);
|
|
376
|
+
const configDir = stringValue(env?.CLAUDE_CONFIG_DIR) ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
377
|
+
return join(configDir, ".credentials.json");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function codexAccountKeyFromProcess(proc: ManagedProcess | undefined): string | undefined {
|
|
381
|
+
const env = processEnvFromMeta(proc);
|
|
382
|
+
const codexHome = stringValue(env?.CODEX_HOME) ?? process.env.CODEX_HOME;
|
|
383
|
+
return codexHome ? `home:${shortHash(codexHome)}` : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function processEnvFromMeta(proc: ManagedProcess | undefined): Record<string, unknown> | undefined {
|
|
387
|
+
const direct = isRecord(proc?.meta?.env) ? proc.meta.env : undefined;
|
|
388
|
+
if (direct) return direct;
|
|
389
|
+
const config = isRecord(proc?.meta?.config) ? proc.meta.config : undefined;
|
|
390
|
+
return isRecord(config?.env) ? config.env : undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function hostProviderAccountKey(): string {
|
|
394
|
+
return `host:${hostname()}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function beginSharedQuotaAttempt(identity: QuotaIdentity, stateDir?: string, now = Date.now()): {
|
|
398
|
+
poll: boolean;
|
|
399
|
+
retryInMs: number;
|
|
400
|
+
entry?: SharedQuotaEntry;
|
|
401
|
+
lockDir?: string;
|
|
402
|
+
} {
|
|
403
|
+
const entry = readSharedQuotaEntry(identity, stateDir);
|
|
404
|
+
const nextAttemptAt = entry?.nextAttemptAt;
|
|
405
|
+
if (nextAttemptAt !== undefined && nextAttemptAt > now) {
|
|
406
|
+
return { poll: false, retryInMs: nextAttemptAt - now, entry };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const lockDir = sharedQuotaLockDir(identity, stateDir);
|
|
410
|
+
if (!acquireQuotaLock(lockDir, now)) {
|
|
411
|
+
return { poll: false, retryInMs: QUOTA_FAST_RETRY_MS, entry };
|
|
412
|
+
}
|
|
413
|
+
return { poll: true, retryInMs: 0, entry, lockDir };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function finishSharedQuotaAttempt(identity: QuotaIdentity, entry: SharedQuotaEntry, stateDir?: string): void {
|
|
417
|
+
const path = sharedQuotaStatePath(identity, stateDir);
|
|
418
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
419
|
+
writeFileSync(path, JSON.stringify(entry, null, 2));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function readSharedQuotaEntry(identity: QuotaIdentity, stateDir?: string): SharedQuotaEntry | undefined {
|
|
423
|
+
try {
|
|
424
|
+
const parsed = JSON.parse(readFileSync(sharedQuotaStatePath(identity, stateDir), "utf8")) as unknown;
|
|
425
|
+
if (!isRecord(parsed)) return undefined;
|
|
426
|
+
const provider = stringValue(parsed.provider);
|
|
427
|
+
const accountKey = stringValue(parsed.accountKey);
|
|
428
|
+
if (!provider || !accountKey) return undefined;
|
|
429
|
+
return {
|
|
430
|
+
provider,
|
|
431
|
+
accountKey,
|
|
432
|
+
...(typeof parsed.lastAttemptAt === "number" && Number.isFinite(parsed.lastAttemptAt) ? { lastAttemptAt: parsed.lastAttemptAt } : {}),
|
|
433
|
+
...(typeof parsed.nextAttemptAt === "number" && Number.isFinite(parsed.nextAttemptAt) ? { nextAttemptAt: parsed.nextAttemptAt } : {}),
|
|
434
|
+
...(isQuotaState(parsed.quota) ? { quota: parsed.quota } : {}),
|
|
435
|
+
...(parsed.lastError === null || isRecord(parsed.lastError) ? { lastError: parsed.lastError as ProviderQuotaError | null } : {}),
|
|
436
|
+
};
|
|
437
|
+
} catch {
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function sharedQuotaStatePath(identity: QuotaIdentity, stateDir?: string): string {
|
|
443
|
+
return join(sharedQuotaStateDir(stateDir), `${sharedQuotaKey(identity)}.json`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function sharedQuotaLockDir(identity: QuotaIdentity, stateDir?: string): string {
|
|
447
|
+
return join(sharedQuotaStateDir(stateDir), `${sharedQuotaKey(identity)}.lock`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sharedQuotaStateDir(stateDir?: string): string {
|
|
451
|
+
return stateDir ?? join(runnerOutboxDirWithInfoFallback() ?? agentRelayHome(), "quota");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function sharedQuotaKey(identity: QuotaIdentity): string {
|
|
455
|
+
return sanitizeFsName(`${identity.provider}-${identity.accountKey}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function acquireQuotaLock(lockDir: string, now = Date.now()): boolean {
|
|
459
|
+
mkdirSync(dirname(lockDir), { recursive: true });
|
|
460
|
+
try {
|
|
461
|
+
mkdirSync(lockDir, { recursive: false });
|
|
462
|
+
writeFileSync(join(lockDir, "owner"), String(now));
|
|
463
|
+
return true;
|
|
464
|
+
} catch {
|
|
465
|
+
const ownerPath = join(lockDir, "owner");
|
|
466
|
+
const lockedAt = quotaLockTimestamp(lockDir, ownerPath);
|
|
467
|
+
if (lockedAt !== undefined && now - lockedAt <= QUOTA_LOCK_STALE_MS) return false;
|
|
468
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
469
|
+
try {
|
|
470
|
+
mkdirSync(lockDir, { recursive: false });
|
|
471
|
+
writeFileSync(ownerPath, String(now));
|
|
472
|
+
return true;
|
|
473
|
+
} catch {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function releaseQuotaLock(lockDir: string): void {
|
|
480
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function quotaLockTimestamp(lockDir: string, ownerPath: string): number | undefined {
|
|
484
|
+
try {
|
|
485
|
+
const lockedAt = Number(readFileSync(ownerPath, "utf8"));
|
|
486
|
+
if (Number.isFinite(lockedAt)) return lockedAt;
|
|
487
|
+
} catch {}
|
|
488
|
+
try {
|
|
489
|
+
return statSync(lockDir).mtimeMs;
|
|
490
|
+
} catch {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function isQuotaState(value: unknown): value is QuotaState {
|
|
496
|
+
return isRecord(value) && Array.isArray(value.windows) && typeof value.updatedAt === "number";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function providerPayloadAccountKey(payload: unknown): string | undefined {
|
|
500
|
+
if (!isRecord(payload)) return undefined;
|
|
501
|
+
const direct = stringValue(payload.accountKey) ??
|
|
502
|
+
stringValue(payload.account_key) ??
|
|
503
|
+
stringValue(payload.accountId) ??
|
|
504
|
+
stringValue(payload.account_id) ??
|
|
505
|
+
stringValue(payload.orgId) ??
|
|
506
|
+
stringValue(payload.org_id) ??
|
|
507
|
+
stringValue(payload.organizationId) ??
|
|
508
|
+
stringValue(payload.organization_id);
|
|
509
|
+
if (direct) return direct;
|
|
510
|
+
const account = isRecord(payload.account) ? payload.account : undefined;
|
|
511
|
+
const org = isRecord(payload.org) ? payload.org : isRecord(payload.organization) ? payload.organization : undefined;
|
|
512
|
+
return stringValue(account?.id) ?? stringValue(org?.id);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function shortHash(value: string): string {
|
|
516
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
194
517
|
}
|
|
195
518
|
|
|
196
519
|
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
|
|