@runuai/host 0.9.162 → 0.9.164
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/lib/agent-cli.ts +9 -5
- package/lib/agents/claude.ts +112 -5
- package/lib/agents/codex.ts +177 -25
- package/lib/agents/grok.ts +2 -1
- package/lib/agents/opencode.ts +35 -12
- package/lib/agents/rate-limit.ts +299 -3
- package/lib/agents/types.ts +36 -1
- package/lib/engine-accounts.ts +94 -6
- package/lib/orchestrator.ts +211 -12
- package/package.json +9 -1
- package/src/engine-models.ts +31 -0
- package/src/engine-usage.ts +104 -0
- package/src/main.ts +16 -0
- package/src/protocol.ts +65 -0
package/lib/agents/rate-limit.ts
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
* When an engine reports an error that means "this account is temporarily out
|
|
5
5
|
* of capacity" (usage cap, 429, quota, overloaded), the orchestrator cools the
|
|
6
6
|
* current account and fails over to the next one. Each adapter's pure mapper
|
|
7
|
-
* runs the error text through {@link isRateLimitMessage}
|
|
8
|
-
*
|
|
9
|
-
* without re-parsing
|
|
7
|
+
* runs the error text through {@link isRateLimitMessage}, emits a secret-free
|
|
8
|
+
* account usage observation, and tags the resulting `error` event with
|
|
9
|
+
* `retryable: "rate_limit"` so rotation can act on it without re-parsing
|
|
10
|
+
* engine-specific strings downstream.
|
|
10
11
|
*
|
|
11
12
|
* Deliberately broad but anchored on unambiguous tokens — a false positive
|
|
12
13
|
* only rotates to another account (cheap, self-correcting), a false negative
|
|
13
14
|
* just surfaces the error as today.
|
|
14
15
|
*/
|
|
16
|
+
import type { AgentAccountUsage } from "./types";
|
|
17
|
+
|
|
15
18
|
const RATE_LIMIT_PATTERNS: RegExp[] = [
|
|
16
19
|
/rate[\s_-]?limit/i,
|
|
17
20
|
/\b429\b/,
|
|
@@ -39,3 +42,296 @@ export function isRateLimitMessage(message: string | undefined | null): boolean
|
|
|
39
42
|
if (!message) return false;
|
|
40
43
|
return RATE_LIMIT_PATTERNS.some((re) => re.test(message));
|
|
41
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Build the account observation shared by every adapter's error-text path. */
|
|
47
|
+
export function rateLimitErrorUsage(
|
|
48
|
+
message: string,
|
|
49
|
+
now: number = Date.now(),
|
|
50
|
+
timeZone?: string,
|
|
51
|
+
): AgentAccountUsage | null {
|
|
52
|
+
if (!isRateLimitMessage(message)) return null;
|
|
53
|
+
const resetsAt = parseResetHint(message, now, timeZone);
|
|
54
|
+
return {
|
|
55
|
+
status: "limited",
|
|
56
|
+
...(resetsAt === null ? {} : { resetsAt }),
|
|
57
|
+
windows: [],
|
|
58
|
+
source: "error-text",
|
|
59
|
+
observedAt: now,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MONTHS = new Map(
|
|
64
|
+
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].map(
|
|
65
|
+
(month, index) => [month, index + 1] as const,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
const MAX_RESET_SECONDS = 100_000_000_000;
|
|
69
|
+
const MAX_RESET_AHEAD_MS = 365 * 24 * 60 * 60_000;
|
|
70
|
+
|
|
71
|
+
/** Convert a provider Unix-seconds reset without accepting milliseconds or a
|
|
72
|
+
* corrupt timestamp that is more than a year beyond this observation. */
|
|
73
|
+
export function resetSecondsToEpochMs(
|
|
74
|
+
value: unknown,
|
|
75
|
+
observedAt: number,
|
|
76
|
+
): number | undefined {
|
|
77
|
+
if (
|
|
78
|
+
typeof value !== "number" ||
|
|
79
|
+
!Number.isSafeInteger(value) ||
|
|
80
|
+
value <= 0 ||
|
|
81
|
+
value > MAX_RESET_SECONDS ||
|
|
82
|
+
!Number.isFinite(observedAt)
|
|
83
|
+
) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const candidate = value * 1_000;
|
|
87
|
+
return candidate <= observedAt + MAX_RESET_AHEAD_MS
|
|
88
|
+
? candidate
|
|
89
|
+
: undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface CivilTime {
|
|
93
|
+
year: number;
|
|
94
|
+
month: number;
|
|
95
|
+
day: number;
|
|
96
|
+
hour: number;
|
|
97
|
+
minute: number;
|
|
98
|
+
second: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatter(timeZone: string): Intl.DateTimeFormat | null {
|
|
102
|
+
try {
|
|
103
|
+
return new Intl.DateTimeFormat("en-US-u-ca-gregory-nu-latn", {
|
|
104
|
+
timeZone,
|
|
105
|
+
year: "numeric",
|
|
106
|
+
month: "2-digit",
|
|
107
|
+
day: "2-digit",
|
|
108
|
+
hour: "2-digit",
|
|
109
|
+
minute: "2-digit",
|
|
110
|
+
second: "2-digit",
|
|
111
|
+
hourCycle: "h23",
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function civilAt(epochMs: number, fmt: Intl.DateTimeFormat): CivilTime | null {
|
|
119
|
+
const values = new Map(
|
|
120
|
+
fmt
|
|
121
|
+
.formatToParts(new Date(epochMs))
|
|
122
|
+
.filter((part) => part.type !== "literal")
|
|
123
|
+
.map((part) => [part.type, Number(part.value)]),
|
|
124
|
+
);
|
|
125
|
+
const civil = {
|
|
126
|
+
year: values.get("year") ?? NaN,
|
|
127
|
+
month: values.get("month") ?? NaN,
|
|
128
|
+
day: values.get("day") ?? NaN,
|
|
129
|
+
hour: values.get("hour") ?? NaN,
|
|
130
|
+
minute: values.get("minute") ?? NaN,
|
|
131
|
+
second: values.get("second") ?? NaN,
|
|
132
|
+
};
|
|
133
|
+
return Object.values(civil).every(Number.isFinite) ? civil : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sameCivil(left: CivilTime | null, right: CivilTime): boolean {
|
|
137
|
+
return (
|
|
138
|
+
left !== null &&
|
|
139
|
+
left.year === right.year &&
|
|
140
|
+
left.month === right.month &&
|
|
141
|
+
left.day === right.day &&
|
|
142
|
+
left.hour === right.hour &&
|
|
143
|
+
left.minute === right.minute &&
|
|
144
|
+
left.second === right.second
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Convert a civil time in an IANA zone to an instant. Sampling the offsets
|
|
149
|
+
* on both sides of the date makes DST folds visible: zero or multiple matching
|
|
150
|
+
* instants are refused instead of guessing. */
|
|
151
|
+
function epochForCivil(civil: CivilTime, timeZone: string): number | null {
|
|
152
|
+
const fmt = formatter(timeZone);
|
|
153
|
+
if (!fmt) return null;
|
|
154
|
+
const target = Date.UTC(
|
|
155
|
+
civil.year,
|
|
156
|
+
civil.month - 1,
|
|
157
|
+
civil.day,
|
|
158
|
+
civil.hour,
|
|
159
|
+
civil.minute,
|
|
160
|
+
civil.second,
|
|
161
|
+
);
|
|
162
|
+
const normalized = new Date(target);
|
|
163
|
+
if (
|
|
164
|
+
normalized.getUTCFullYear() !== civil.year ||
|
|
165
|
+
normalized.getUTCMonth() + 1 !== civil.month ||
|
|
166
|
+
normalized.getUTCDate() !== civil.day ||
|
|
167
|
+
normalized.getUTCHours() !== civil.hour ||
|
|
168
|
+
normalized.getUTCMinutes() !== civil.minute
|
|
169
|
+
) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const offsets = new Set<number>();
|
|
174
|
+
for (let hours = -36; hours <= 36; hours += 6) {
|
|
175
|
+
const probe = target + hours * 60 * 60_000;
|
|
176
|
+
const parts = civilAt(probe, fmt);
|
|
177
|
+
if (!parts) continue;
|
|
178
|
+
offsets.add(
|
|
179
|
+
Date.UTC(
|
|
180
|
+
parts.year,
|
|
181
|
+
parts.month - 1,
|
|
182
|
+
parts.day,
|
|
183
|
+
parts.hour,
|
|
184
|
+
parts.minute,
|
|
185
|
+
parts.second,
|
|
186
|
+
) - probe,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const candidates = [...offsets]
|
|
190
|
+
.map((offset) => target - offset)
|
|
191
|
+
.filter((candidate) => sameCivil(civilAt(candidate, fmt), civil));
|
|
192
|
+
const unique = [...new Set(candidates)];
|
|
193
|
+
return unique.length === 1 ? unique[0]! : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function localDate(now: number, timeZone: string): CivilTime | null {
|
|
197
|
+
const fmt = formatter(timeZone);
|
|
198
|
+
return fmt ? civilAt(now, fmt) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parsedZone(raw: string | undefined, fallback: string): string | null {
|
|
202
|
+
if (!raw) return fallback;
|
|
203
|
+
const zone = raw.trim();
|
|
204
|
+
if (/^(?:UTC|GMT)$/i.test(zone)) return "UTC";
|
|
205
|
+
// Abbreviations such as CST name several zones and are therefore unsafe.
|
|
206
|
+
if (!zone.includes("/")) return null;
|
|
207
|
+
return formatter(zone) ? zone : null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function clockHour(rawHour: string, meridiem: string): number | null {
|
|
211
|
+
const hour = Number(rawHour);
|
|
212
|
+
if (!Number.isInteger(hour) || hour < 1 || hour > 12) return null;
|
|
213
|
+
return (hour % 12) + (meridiem.toLowerCase() === "pm" ? 12 : 0);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse the deliberately small set of reset hints emitted by engine CLIs.
|
|
218
|
+
* Returns epoch milliseconds, or null when the text is invalid, names an
|
|
219
|
+
* ambiguous civil instant, contains multiple hints, or needs assumptions the
|
|
220
|
+
* message did not supply.
|
|
221
|
+
*/
|
|
222
|
+
export function parseResetHint(
|
|
223
|
+
message: string,
|
|
224
|
+
now: number,
|
|
225
|
+
timeZone: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
226
|
+
): number | null {
|
|
227
|
+
if (!message || !Number.isFinite(now) || !formatter(timeZone)) return null;
|
|
228
|
+
const found: number[] = [];
|
|
229
|
+
|
|
230
|
+
// Claude also emits a machine-readable suffix in some terminal errors.
|
|
231
|
+
// It is Unix seconds; reject millisecond-scale or implausibly distant input
|
|
232
|
+
// rather than turning malformed text into a long account bench.
|
|
233
|
+
const claudeEpoch = /\bClaude AI usage limit reached\|(\d+)\b/gi;
|
|
234
|
+
for (const match of message.matchAll(claudeEpoch)) {
|
|
235
|
+
const seconds = Number(match[1]);
|
|
236
|
+
const candidate = resetSecondsToEpochMs(seconds, now);
|
|
237
|
+
if (candidate !== undefined && candidate > now) found.push(candidate);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const relative = /\b(?:try\s+again|resets?)\s+in\s+((?:\d+\s*(?:days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\s*){1,4})(?=$|[.,;!?])/gi;
|
|
241
|
+
for (const match of message.matchAll(relative)) {
|
|
242
|
+
const units = new Set<string>();
|
|
243
|
+
let duration = 0;
|
|
244
|
+
const token = /(\d+)\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/gi;
|
|
245
|
+
let consumed = "";
|
|
246
|
+
for (const part of match[1]!.matchAll(token)) {
|
|
247
|
+
const value = Number(part[1]);
|
|
248
|
+
const rawUnit = part[2]!.toLowerCase();
|
|
249
|
+
const unit = rawUnit.startsWith("d")
|
|
250
|
+
? "d"
|
|
251
|
+
: rawUnit.startsWith("h")
|
|
252
|
+
? "h"
|
|
253
|
+
: rawUnit.startsWith("m")
|
|
254
|
+
? "m"
|
|
255
|
+
: "s";
|
|
256
|
+
if (!Number.isSafeInteger(value) || value <= 0 || units.has(unit)) {
|
|
257
|
+
duration = NaN;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
units.add(unit);
|
|
261
|
+
consumed += part[0].replace(/\s+/g, "");
|
|
262
|
+
duration +=
|
|
263
|
+
value *
|
|
264
|
+
(unit === "d"
|
|
265
|
+
? 24 * 60 * 60_000
|
|
266
|
+
: unit === "h"
|
|
267
|
+
? 60 * 60_000
|
|
268
|
+
: unit === "m"
|
|
269
|
+
? 60_000
|
|
270
|
+
: 1_000);
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
Number.isSafeInteger(duration) &&
|
|
274
|
+
duration > 0 &&
|
|
275
|
+
consumed.toLowerCase() === match[1]!.replace(/\s+/g, "").toLowerCase()
|
|
276
|
+
) {
|
|
277
|
+
found.push(now + duration);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const monthNames = "Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?";
|
|
282
|
+
const dated = new RegExp(
|
|
283
|
+
`\\bresets?\\s+(${monthNames})\\s+(\\d{1,2})(?:,?\\s+(\\d{4}))?(?:,?\\s+(\\d{1,2})(?::(\\d{2}))?\\s*(am|pm))?(?:\\s*\\(([^)]+)\\))?(?=$|[.,;!?])`,
|
|
284
|
+
"gi",
|
|
285
|
+
);
|
|
286
|
+
for (const match of message.matchAll(dated)) {
|
|
287
|
+
const month = MONTHS.get(match[1]!.slice(0, 3).toLowerCase());
|
|
288
|
+
const day = Number(match[2]);
|
|
289
|
+
const explicitYear = match[3] === undefined ? null : Number(match[3]);
|
|
290
|
+
const hour = match[4] === undefined ? 0 : clockHour(match[4], match[6]!);
|
|
291
|
+
const minute = match[5] === undefined ? 0 : Number(match[5]);
|
|
292
|
+
// The CLI runs in a container whose local zone need not match this host
|
|
293
|
+
// process. A calendar date is zone-sensitive even when it names no clock,
|
|
294
|
+
// so every absolute hint must name its zone.
|
|
295
|
+
const zone =
|
|
296
|
+
match[7] === undefined ? null : parsedZone(match[7], timeZone);
|
|
297
|
+
const base = zone ? localDate(now, zone) : null;
|
|
298
|
+
if (!base || !month || !zone || hour === null || minute < 0 || minute > 59) continue;
|
|
299
|
+
let year = explicitYear ?? base.year;
|
|
300
|
+
let candidate = epochForCivil({ year, month, day, hour, minute, second: 0 }, zone);
|
|
301
|
+
if (explicitYear === null && candidate !== null && candidate <= now) {
|
|
302
|
+
year += 1;
|
|
303
|
+
candidate = epochForCivil({ year, month, day, hour, minute, second: 0 }, zone);
|
|
304
|
+
}
|
|
305
|
+
if (candidate !== null && candidate > now) found.push(candidate);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const clock = /\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s*\(([^)]+)\))?(?=$|[.,;!?])/gi;
|
|
309
|
+
for (const match of message.matchAll(clock)) {
|
|
310
|
+
const hour = clockHour(match[1]!, match[3]!);
|
|
311
|
+
const minute = match[2] === undefined ? 0 : Number(match[2]);
|
|
312
|
+
const zone = match[4] === undefined ? null : parsedZone(match[4], timeZone);
|
|
313
|
+
const base = zone ? localDate(now, zone) : null;
|
|
314
|
+
if (!base || hour === null || minute < 0 || minute > 59 || !zone) continue;
|
|
315
|
+
let candidate = epochForCivil(
|
|
316
|
+
{ year: base.year, month: base.month, day: base.day, hour, minute, second: 0 },
|
|
317
|
+
zone,
|
|
318
|
+
);
|
|
319
|
+
if (candidate !== null && candidate <= now) {
|
|
320
|
+
const tomorrow = new Date(Date.UTC(base.year, base.month - 1, base.day + 1));
|
|
321
|
+
candidate = epochForCivil(
|
|
322
|
+
{
|
|
323
|
+
year: tomorrow.getUTCFullYear(),
|
|
324
|
+
month: tomorrow.getUTCMonth() + 1,
|
|
325
|
+
day: tomorrow.getUTCDate(),
|
|
326
|
+
hour,
|
|
327
|
+
minute,
|
|
328
|
+
second: 0,
|
|
329
|
+
},
|
|
330
|
+
zone,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
if (candidate !== null && candidate > now) found.push(candidate);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return found.length === 1 ? found[0]! : null;
|
|
337
|
+
}
|
package/lib/agents/types.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
|
|
16
|
+
import type { EngineUsageWindow } from "../../src/protocol";
|
|
16
17
|
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
17
18
|
|
|
18
19
|
/** The host-advertised adapter kind ("claude" | "codex" | "gemini" | …). */
|
|
@@ -50,6 +51,13 @@ export const RosterAgent = z.object({
|
|
|
50
51
|
kind: AgentKind,
|
|
51
52
|
model: z.string().min(1).optional(),
|
|
52
53
|
effort: z.string().min(1).optional(), // CLI reasoning level, passed through
|
|
54
|
+
// ADR-157 addendum §7: preferred cloud-leased AI account.
|
|
55
|
+
accountId: z
|
|
56
|
+
.string()
|
|
57
|
+
.min(1)
|
|
58
|
+
.max(128)
|
|
59
|
+
.regex(/^[A-Za-z0-9_.:-]+$/)
|
|
60
|
+
.optional(),
|
|
53
61
|
defaultPrompt: z.string().optional(), // persona
|
|
54
62
|
initialPrompt: z.string().optional(), // first-turn instructions, this task only
|
|
55
63
|
skills: z.array(RosterSkill).optional(), // ADR-046 reference material
|
|
@@ -108,6 +116,24 @@ export interface AgentUsage {
|
|
|
108
116
|
costUsd?: number;
|
|
109
117
|
}
|
|
110
118
|
|
|
119
|
+
/** Secret-free account usage exactly as an engine CLI reported it. The
|
|
120
|
+
* orchestrator adds task/account identity only after proving the session is
|
|
121
|
+
* running on a cloud-leased account. */
|
|
122
|
+
export interface AgentAccountUsage {
|
|
123
|
+
status: "allowed" | "warning" | "limited";
|
|
124
|
+
resetsAt?: number;
|
|
125
|
+
usingOverage?: boolean;
|
|
126
|
+
windows: EngineUsageWindow[];
|
|
127
|
+
source: "claude-rate-limit-event" | "codex-rate-limits" | "error-text";
|
|
128
|
+
observedAt: number;
|
|
129
|
+
/** Which provider capacity this reading can prove usable again. Claude emits
|
|
130
|
+
* one event per named window; Codex snapshots cover every window. Internal
|
|
131
|
+
* runner metadata only: it is deliberately not part of engine.usage. */
|
|
132
|
+
coverage?:
|
|
133
|
+
| { kind: "window"; window: string }
|
|
134
|
+
| { kind: "all-windows" };
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
export type AgentEvent =
|
|
112
138
|
/** A chunk of streaming assistant text. Appended to the in-progress message. */
|
|
113
139
|
| { type: "message_delta"; text: string }
|
|
@@ -129,10 +155,19 @@ export type AgentEvent =
|
|
|
129
155
|
/** The turn (one request → response cycle) is done; agent is idle.
|
|
130
156
|
* `usage` carries this turn's token/cost accounting when the CLI reports it. */
|
|
131
157
|
| { type: "turn_complete"; usage?: AgentUsage }
|
|
158
|
+
/** Account-level capacity reported by the engine CLI. This never contains
|
|
159
|
+
* credentials or provider account identifiers. */
|
|
160
|
+
| { type: "account_usage"; usage: AgentAccountUsage }
|
|
132
161
|
/** A recoverable error surfaced by the agent. `retryable` marks errors the
|
|
133
162
|
* orchestrator can fail over from — "rate_limit" triggers account rotation
|
|
134
163
|
* (ADR-076). */
|
|
135
|
-
| {
|
|
164
|
+
| {
|
|
165
|
+
type: "error";
|
|
166
|
+
message: string;
|
|
167
|
+
retryable?: "rate_limit";
|
|
168
|
+
/** Parsed from the same rate-limit error text, when unambiguous. */
|
|
169
|
+
resetsAt?: number;
|
|
170
|
+
}
|
|
136
171
|
/** The underlying process exited. */
|
|
137
172
|
| { type: "exit"; code: number };
|
|
138
173
|
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -440,7 +440,11 @@ export function engineAccountCount(
|
|
|
440
440
|
// ---------------------------------------------------------------------------
|
|
441
441
|
|
|
442
442
|
interface RotationState {
|
|
443
|
-
|
|
443
|
+
/** One account may be capped by several independent provider windows. Null
|
|
444
|
+
* is a rate-limit error that named no window and needs a full snapshot to
|
|
445
|
+
* disprove. Each cause keeps its own deadline so clearing one cannot clear
|
|
446
|
+
* or extend another. */
|
|
447
|
+
cooldowns: Map<string | null, number>;
|
|
444
448
|
lastUsedAt: number;
|
|
445
449
|
}
|
|
446
450
|
|
|
@@ -448,23 +452,97 @@ const rotationState = new Map<string, RotationState>();
|
|
|
448
452
|
|
|
449
453
|
/** Default cooldown when the engine gives no reset hint. */
|
|
450
454
|
export const DEFAULT_COOLDOWN_MS = 15 * 60_000;
|
|
455
|
+
/** Provider resets too close to the current instant still need a short bench
|
|
456
|
+
* so repeated terminal events cannot hot-loop the same account. */
|
|
457
|
+
export const MIN_ENGINE_ACCOUNT_COOLDOWN_MS = 60_000;
|
|
458
|
+
/** Refuse a corrupt/far-future provider timestamp from benching an account
|
|
459
|
+
* indefinitely. */
|
|
460
|
+
export const MAX_ENGINE_ACCOUNT_COOLDOWN_MS = 7 * 24 * 60 * 60_000;
|
|
451
461
|
|
|
452
462
|
function state(id: string): RotationState {
|
|
453
463
|
let st = rotationState.get(id);
|
|
454
464
|
if (!st) {
|
|
455
|
-
st = {
|
|
465
|
+
st = { cooldowns: new Map(), lastUsedAt: 0 };
|
|
456
466
|
rotationState.set(id, st);
|
|
457
467
|
}
|
|
458
468
|
return st;
|
|
459
469
|
}
|
|
460
470
|
|
|
471
|
+
function cooldown(
|
|
472
|
+
id: string,
|
|
473
|
+
window: string | null,
|
|
474
|
+
ms: number,
|
|
475
|
+
now: number,
|
|
476
|
+
): void {
|
|
477
|
+
const account = state(id);
|
|
478
|
+
const previous = account.cooldowns.get(window) ?? 0;
|
|
479
|
+
account.cooldowns.set(window, Math.max(previous, now + ms));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function cooldownDuration(resetsAt: number, now: number): number {
|
|
483
|
+
return Number.isFinite(resetsAt)
|
|
484
|
+
? Math.min(
|
|
485
|
+
MAX_ENGINE_ACCOUNT_COOLDOWN_MS,
|
|
486
|
+
Math.max(MIN_ENGINE_ACCOUNT_COOLDOWN_MS, resetsAt - now),
|
|
487
|
+
)
|
|
488
|
+
: DEFAULT_COOLDOWN_MS;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function accountCooldownUntil(id: string): number {
|
|
492
|
+
return Math.max(0, ...state(id).cooldowns.values());
|
|
493
|
+
}
|
|
494
|
+
|
|
461
495
|
/** Put an account on cooldown (rate-limited). */
|
|
462
496
|
export function cooldownEngineAccount(
|
|
463
497
|
id: string,
|
|
464
498
|
ms: number = DEFAULT_COOLDOWN_MS,
|
|
465
499
|
now: number = Date.now(),
|
|
466
500
|
): void {
|
|
467
|
-
|
|
501
|
+
cooldown(id, null, ms, now);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Put an account on cooldown through the engine's real reset, bounded against
|
|
505
|
+
* stale or corrupt timestamps. */
|
|
506
|
+
export function cooldownEngineAccountUntil(
|
|
507
|
+
id: string,
|
|
508
|
+
resetsAt: number,
|
|
509
|
+
now: number = Date.now(),
|
|
510
|
+
): void {
|
|
511
|
+
cooldown(id, null, cooldownDuration(resetsAt, now), now);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Bench one named provider window without conflating it with another. */
|
|
515
|
+
export function cooldownEngineAccountWindow(
|
|
516
|
+
id: string,
|
|
517
|
+
window: string,
|
|
518
|
+
ms: number = DEFAULT_COOLDOWN_MS,
|
|
519
|
+
now: number = Date.now(),
|
|
520
|
+
): void {
|
|
521
|
+
cooldown(id, window, ms, now);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Bench one named provider window through its bounded real reset. */
|
|
525
|
+
export function cooldownEngineAccountWindowUntil(
|
|
526
|
+
id: string,
|
|
527
|
+
window: string,
|
|
528
|
+
resetsAt: number,
|
|
529
|
+
now: number = Date.now(),
|
|
530
|
+
): void {
|
|
531
|
+
cooldown(id, window, cooldownDuration(resetsAt, now), now);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** A per-window reading proves only that same provider window usable. */
|
|
535
|
+
export function clearEngineAccountCooldownWindow(
|
|
536
|
+
id: string,
|
|
537
|
+
window: string,
|
|
538
|
+
): void {
|
|
539
|
+
rotationState.get(id)?.cooldowns.delete(window);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** A complete provider snapshot with no limited window disproves every bench,
|
|
543
|
+
* including an error that could not name which window was exhausted. */
|
|
544
|
+
export function clearEngineAccountCooldowns(id: string): void {
|
|
545
|
+
rotationState.get(id)?.cooldowns.clear();
|
|
468
546
|
}
|
|
469
547
|
|
|
470
548
|
/** Record that an account was just used (least-recently-used ordering). */
|
|
@@ -480,14 +558,18 @@ export function noteEngineAccountUsed(id: string, now: number = Date.now()): voi
|
|
|
480
558
|
*/
|
|
481
559
|
export function selectAccount(
|
|
482
560
|
accounts: ResolvedEngineAccount[],
|
|
483
|
-
opts: { exclude?: Set<string>; now?: number } = {},
|
|
561
|
+
opts: { exclude?: Set<string>; now?: number; preferredId?: string } = {},
|
|
484
562
|
): ResolvedEngineAccount | null {
|
|
485
563
|
const now = opts.now ?? Date.now();
|
|
486
564
|
const exclude = opts.exclude ?? new Set<string>();
|
|
487
565
|
const candidates = accounts.filter((a) => !exclude.has(a.id));
|
|
488
566
|
if (candidates.length === 0) return null;
|
|
489
567
|
const lru = (a: ResolvedEngineAccount): number => state(a.id).lastUsedAt;
|
|
490
|
-
const healthy = candidates.filter((a) =>
|
|
568
|
+
const healthy = candidates.filter((a) => accountCooldownUntil(a.id) <= now);
|
|
569
|
+
const preferred = opts.preferredId
|
|
570
|
+
? healthy.find((account) => account.id === opts.preferredId)
|
|
571
|
+
: undefined;
|
|
572
|
+
if (preferred) return preferred;
|
|
491
573
|
// Manage-v1: the list arrives in PRIORITY order (default slot, then extras
|
|
492
574
|
// by position) — the first healthy account wins, so the operator's
|
|
493
575
|
// ordering decides which account a task uses and which are fallbacks.
|
|
@@ -507,6 +589,8 @@ export function pickEngineAccount(
|
|
|
507
589
|
exclude?: Set<string>;
|
|
508
590
|
seams?: Partial<AccountSeams>;
|
|
509
591
|
now?: number;
|
|
592
|
+
/** One agent's durable preference; cooling or absent falls through. */
|
|
593
|
+
preferredId?: string;
|
|
510
594
|
/** ADR-131 D3: the task's scope; default = the host's own accounts. */
|
|
511
595
|
scope?: string;
|
|
512
596
|
} = {},
|
|
@@ -516,7 +600,11 @@ export function pickEngineAccount(
|
|
|
516
600
|
opts.seams ?? {},
|
|
517
601
|
opts.scope ?? HOST_ACCOUNT_SCOPE,
|
|
518
602
|
);
|
|
519
|
-
return selectAccount(accounts, {
|
|
603
|
+
return selectAccount(accounts, {
|
|
604
|
+
exclude: opts.exclude,
|
|
605
|
+
now: opts.now,
|
|
606
|
+
preferredId: opts.preferredId,
|
|
607
|
+
});
|
|
520
608
|
}
|
|
521
609
|
|
|
522
610
|
/**
|