@drakon-systems/multi-clawd 2.4.0 → 2.5.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/README.md +90 -31
- package/SECURITY.md +3 -0
- package/dist/credential-state.js +8 -24
- package/dist/direct-report.js +8 -1
- package/dist/direct-route.js +9 -1
- package/dist/direct-sync.js +8 -1
- package/dist/explain-core.js +49 -8
- package/dist/health-journal.js +123 -0
- package/dist/health.js +49 -2
- package/dist/history.js +103 -0
- package/dist/index.js +31 -35
- package/dist/retry-plan.js +2 -0
- package/dist/shim-core.js +197 -34
- package/dist/shim-lock.js +106 -0
- package/dist/shim.js +122 -73
- package/dist/spread.js +54 -0
- package/dist/sticky.js +2 -0
- package/dist/update-core.js +75 -1
- package/openclaw.plugin.json +12 -3
- package/package.json +1 -1
- package/scripts/cli.mjs +107 -22
- package/scripts/doctor.mjs +104 -5
package/dist/health.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { modelWindowApplies, modelWindowKey } from "./shim-core.js";
|
|
2
|
+
import { spreadBefore } from "./spread.js";
|
|
2
3
|
export const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
|
|
3
4
|
const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
|
4
5
|
export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
@@ -19,6 +20,9 @@ export function isPeriodWindow(window) {
|
|
|
19
20
|
export function isOverageWindow(window) {
|
|
20
21
|
return /overage/i.test(window);
|
|
21
22
|
}
|
|
23
|
+
export function logicalWindowKey(window) {
|
|
24
|
+
return window.startsWith("usage:") ? window.slice("usage:".length) : window;
|
|
25
|
+
}
|
|
22
26
|
export function isLongQuotaWindow(window) {
|
|
23
27
|
return /(^|_)(days?|weeks?|months?)(_|$)/.test(window) && !isOverageWindow(window);
|
|
24
28
|
}
|
|
@@ -89,6 +93,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
89
93
|
let hasLiveEvidence = false;
|
|
90
94
|
let overageUtilization;
|
|
91
95
|
let quotaUtilization;
|
|
96
|
+
const freshestByWindow = new Map();
|
|
92
97
|
for (const [window, w] of Object.entries(state.windows)) {
|
|
93
98
|
const resetMs = typeof w.resetsAt === "number" ? w.resetsAt * 1000 : undefined;
|
|
94
99
|
const resetBearing = resetMs !== undefined && resetMs > nowMs;
|
|
@@ -175,6 +180,14 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
175
180
|
if (!isOverageWindow(window) && typeof w.utilization === "number") {
|
|
176
181
|
quotaUtilization = Math.max(quotaUtilization ?? 0, w.utilization);
|
|
177
182
|
}
|
|
183
|
+
if (typeof w.utilization === "number" &&
|
|
184
|
+
(resetBearing || resetMs === undefined) &&
|
|
185
|
+
(!isOverageWindow(window) || options.rotateOnOverage)) {
|
|
186
|
+
const logical = logicalWindowKey(window);
|
|
187
|
+
const seen = freshestByWindow.get(logical);
|
|
188
|
+
if (!seen || w.seenAt > seen.seenAt)
|
|
189
|
+
freshestByWindow.set(logical, { utilization: w.utilization, seenAt: w.seenAt });
|
|
190
|
+
}
|
|
178
191
|
if (worst.verdict === "ok" &&
|
|
179
192
|
typeof w.utilization !== "number" &&
|
|
180
193
|
isShortWindow(window) &&
|
|
@@ -188,6 +201,12 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
188
201
|
}
|
|
189
202
|
if (worst.verdict === "ok" && !hasLiveEvidence)
|
|
190
203
|
return { verdict: "no_data" };
|
|
204
|
+
let utilization;
|
|
205
|
+
for (const { utilization: u } of freshestByWindow.values()) {
|
|
206
|
+
utilization = utilization === undefined ? u : Math.max(utilization, u);
|
|
207
|
+
}
|
|
208
|
+
if (utilization !== undefined)
|
|
209
|
+
worst = { ...worst, utilization };
|
|
191
210
|
if (worst.verdict === "ok" && overageUtilization !== undefined) {
|
|
192
211
|
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
193
212
|
worst = {
|
|
@@ -223,7 +242,7 @@ export function summarizeWindowUsage(state, options, nowMs) {
|
|
|
223
242
|
return usage.sort((a, b) => (b.resetsAt ?? 0) - (a.resetsAt ?? 0));
|
|
224
243
|
}
|
|
225
244
|
export function poolStrategy(value) {
|
|
226
|
-
return value === "reset-aware"
|
|
245
|
+
return value === "reset-aware" || value === "spread" ? value : "ordered";
|
|
227
246
|
}
|
|
228
247
|
export function sevenDayReset(state, options, nowMs) {
|
|
229
248
|
const usage = summarizeWindowUsage(state, options, nowMs);
|
|
@@ -243,10 +262,38 @@ export function choosePoolAccount(pool, strategy = "ordered") {
|
|
|
243
262
|
if (best?.sevenDayResetsAt !== undefined)
|
|
244
263
|
return best.id;
|
|
245
264
|
}
|
|
265
|
+
if (strategy === "spread") {
|
|
266
|
+
let best;
|
|
267
|
+
for (const member of pool) {
|
|
268
|
+
if (member.verdict !== "ok" && member.verdict !== "no_data")
|
|
269
|
+
continue;
|
|
270
|
+
if (!best || spreadBefore(member, best))
|
|
271
|
+
best = member;
|
|
272
|
+
}
|
|
273
|
+
if (best)
|
|
274
|
+
return best.id;
|
|
275
|
+
}
|
|
246
276
|
const usable = pool.find((a) => a.verdict === "ok" || a.verdict === "no_data");
|
|
247
277
|
if (usable)
|
|
248
278
|
return usable.id;
|
|
249
|
-
return pool
|
|
279
|
+
return nearLimitWithMostHeadroom(pool)?.id;
|
|
280
|
+
}
|
|
281
|
+
export function headroomBefore(a, b) {
|
|
282
|
+
if (a.utilization === undefined)
|
|
283
|
+
return false;
|
|
284
|
+
if (b.utilization === undefined)
|
|
285
|
+
return true;
|
|
286
|
+
return a.utilization < b.utilization;
|
|
287
|
+
}
|
|
288
|
+
export function nearLimitWithMostHeadroom(pool) {
|
|
289
|
+
let best;
|
|
290
|
+
for (const member of pool) {
|
|
291
|
+
if (member.verdict !== "near_limit")
|
|
292
|
+
continue;
|
|
293
|
+
if (!best || headroomBefore(member, best))
|
|
294
|
+
best = member;
|
|
295
|
+
}
|
|
296
|
+
return best;
|
|
250
297
|
}
|
|
251
298
|
export function allCredentialFailed(pool) {
|
|
252
299
|
return pool.length > 0 && pool.every((a) => a.verdict === "credential_failed");
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { acquireShimLock } from "./shim-lock.js";
|
|
5
|
+
export const HISTORY_FILE = "history.jsonl";
|
|
6
|
+
export const HISTORY_MAX_BYTES = 64 * 1024;
|
|
7
|
+
export const HISTORY_MAX_EVENTS = 500;
|
|
8
|
+
export function historyFile(stateFile) {
|
|
9
|
+
return join(dirname(stateFile), HISTORY_FILE);
|
|
10
|
+
}
|
|
11
|
+
const safeAccount = (value) => typeof value === "string" &&
|
|
12
|
+
value.length > 0 && value.length <= 100 && !/[\x00-\x1f\x7f]/.test(value);
|
|
13
|
+
const safeModel = (value) => typeof value === "string" &&
|
|
14
|
+
value.length > 0 && value.length <= 100 && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(value);
|
|
15
|
+
function valid(raw) {
|
|
16
|
+
if (!raw || typeof raw !== "object")
|
|
17
|
+
return undefined;
|
|
18
|
+
const e = raw;
|
|
19
|
+
if (typeof e.at !== "string" || !Number.isFinite(Date.parse(e.at)) ||
|
|
20
|
+
!["served", "retry", "rotation"].includes(String(e.kind)) || !safeAccount(e.accountId) ||
|
|
21
|
+
(e.toAccountId !== undefined && !safeAccount(e.toAccountId)) ||
|
|
22
|
+
(e.reason !== undefined && !["model_limit", "auth_failure", "transient", "pool_selection"].includes(String(e.reason))))
|
|
23
|
+
return undefined;
|
|
24
|
+
return { at: e.at, kind: e.kind, accountId: e.accountId,
|
|
25
|
+
...(e.toAccountId ? { toAccountId: e.toAccountId } : {}),
|
|
26
|
+
...(safeModel(e.modelId) ? { modelId: e.modelId } : {}),
|
|
27
|
+
...(e.reason ? { reason: e.reason } : {}) };
|
|
28
|
+
}
|
|
29
|
+
export function readHistory(file) {
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = readFileSync(file, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return raw.split("\n").flatMap((line) => {
|
|
38
|
+
try {
|
|
39
|
+
const event = valid(JSON.parse(line));
|
|
40
|
+
return event ? [event] : [];
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function recordHistory(stateFile, event) {
|
|
48
|
+
if (!stateFile)
|
|
49
|
+
return;
|
|
50
|
+
let release;
|
|
51
|
+
let tmp;
|
|
52
|
+
try {
|
|
53
|
+
const checked = valid(event);
|
|
54
|
+
if (!checked)
|
|
55
|
+
return;
|
|
56
|
+
const file = historyFile(stateFile);
|
|
57
|
+
release = acquireShimLock(file);
|
|
58
|
+
if (!release)
|
|
59
|
+
return;
|
|
60
|
+
const events = [...readHistory(file), checked].slice(-HISTORY_MAX_EVENTS);
|
|
61
|
+
let lines = events.map((e) => JSON.stringify(e) + "\n");
|
|
62
|
+
let bytes = lines.reduce((sum, line) => sum + Buffer.byteLength(line), 0);
|
|
63
|
+
while (bytes > HISTORY_MAX_BYTES && lines.length)
|
|
64
|
+
bytes -= Buffer.byteLength(lines.shift());
|
|
65
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
66
|
+
tmp = `${file}.tmp-${process.pid}-${randomUUID()}`;
|
|
67
|
+
writeFileSync(tmp, lines.join(""), { mode: 0o600 });
|
|
68
|
+
renameSync(tmp, file);
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
finally {
|
|
72
|
+
if (tmp)
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(tmp);
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
release?.();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function parseHistorySince(value) {
|
|
81
|
+
const match = /^(\d+)([dh])$/.exec(value);
|
|
82
|
+
if (!match || Number(match[1]) < 1 || Number(match[1]) > 3650) {
|
|
83
|
+
throw new Error("--since must be a positive duration such as 7d or 24h");
|
|
84
|
+
}
|
|
85
|
+
return Number(match[1]) * (match[2] === "d" ? 86_400_000 : 3_600_000);
|
|
86
|
+
}
|
|
87
|
+
export function renderHistory(events, accountIds, since, nowMs = Date.now()) {
|
|
88
|
+
const cutoff = nowMs - parseHistorySince(since);
|
|
89
|
+
const recent = events.filter((e) => Date.parse(e.at) >= cutoff && Date.parse(e.at) <= nowMs);
|
|
90
|
+
const ids = [...new Set([...accountIds, ...recent.map((e) => e.accountId)])];
|
|
91
|
+
const lines = [`Account history (last ${since}):`];
|
|
92
|
+
if (!ids.length)
|
|
93
|
+
return [...lines, " No accounts configured."].join("\n");
|
|
94
|
+
for (const id of ids) {
|
|
95
|
+
const own = recent.filter((e) => e.accountId === id);
|
|
96
|
+
const count = (kind) => own.filter((e) => e.kind === kind).length;
|
|
97
|
+
const last = own.filter((e) => e.kind === "served")
|
|
98
|
+
.reduce((latest, e) => !latest || Date.parse(e.at) > Date.parse(latest) ? e.at : latest, undefined);
|
|
99
|
+
const n = (k, one, many) => `${k} ${k === 1 ? one : many}`;
|
|
100
|
+
lines.push(` ${id}: ${n(count("served"), "served launch", "served launches")}, ${n(count("retry"), "retry", "retries")}, ${n(count("rotation"), "rotation", "rotations")}; last served ${last ?? "never in this window"}`);
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,10 @@ import { MODEL_ALIASES, buildCatalogEntries, canonicalModelId, isModernClaudeMod
|
|
|
7
7
|
import { decideDegradation, matchesPin } from "./degrade.js";
|
|
8
8
|
import { resolveExecMode, permissionModeArgs } from "./exec-policy.js";
|
|
9
9
|
import { resolveBaseModelIds } from "./catalog-source.js";
|
|
10
|
-
import { allCredentialFailed, classifyAccountHealth, credentialFailureFor, lastAuthSuccessAt, overdueProbeAccount, sevenDayReset, } from "./health.js";
|
|
10
|
+
import { allCredentialFailed, classifyAccountHealth, credentialFailureFor, lastAuthSuccessAt, overdueProbeAccount, poolStrategy, sevenDayReset, } from "./health.js";
|
|
11
11
|
import { decideStickySelection } from "./sticky.js";
|
|
12
|
+
import { HISTORY_FILE, readHistory, recordHistory } from "./history.js";
|
|
13
|
+
import { spreadStatsFromHistory, spreadWindowMs, withSpreadStats } from "./spread.js";
|
|
12
14
|
import { DEFAULT_SESSION_AFFINITY_TTL_MS, launchSessionId, readSessionPin, SESSION_AFFINITY_ENV, } from "./session-affinity.js";
|
|
13
15
|
import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
|
|
14
16
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
@@ -18,6 +20,7 @@ import { RETRY_ROSTER_ENV } from "./retry-plan.js";
|
|
|
18
20
|
import { SESSION_DIRS_ENV } from "./session-handover.js";
|
|
19
21
|
import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
|
|
20
22
|
import { healthStateFile, usageStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
23
|
+
import { persistHealthState, readHealthStateFile } from "./health-journal.js";
|
|
21
24
|
import { createUsagePollController, effectiveUsagePollInterval, effectiveUsageWarnThreshold, usageAlertPrefix, usagePoolAlertKey, } from "./usage-poll.js";
|
|
22
25
|
export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
|
|
23
26
|
import { accountConfigDir, AccountCredentialError, buildAccountChildEnv, credentialFailureText, parseSetupTokenFile, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
@@ -188,22 +191,8 @@ const refProbeTrackers = new Map();
|
|
|
188
191
|
const lastProbeStatus = new Map();
|
|
189
192
|
function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
190
193
|
const file = healthStateFile(accountId);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
state = parseStoredState(readFileSync(file, "utf8")) ?? { accountId, windows: {} };
|
|
194
|
-
}
|
|
195
|
-
catch {
|
|
196
|
-
state = { accountId, windows: {} };
|
|
197
|
-
}
|
|
198
|
-
const next = mergeHealthStates(state, recordCredentialFailure(state, reason, nowMs), nowMs);
|
|
199
|
-
try {
|
|
200
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
201
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
202
|
-
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
203
|
-
renameSync(tmp, file);
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
}
|
|
194
|
+
const state = readHealthStateFile(file) ?? { accountId, windows: {} };
|
|
195
|
+
persistHealthState(file, recordCredentialFailure(state, reason, nowMs));
|
|
207
196
|
}
|
|
208
197
|
export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
209
198
|
const now = deps.nowMs ?? Date.now();
|
|
@@ -740,7 +729,7 @@ export default definePluginEntry({
|
|
|
740
729
|
},
|
|
741
730
|
});
|
|
742
731
|
function readHealthState(accountId) {
|
|
743
|
-
return mergeStoredHealth(
|
|
732
|
+
return mergeStoredHealth(readHealthStateFile(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
|
|
744
733
|
}
|
|
745
734
|
function readStateFileLenient(file) {
|
|
746
735
|
try {
|
|
@@ -757,6 +746,9 @@ function mergeStoredHealth(shim, usage) {
|
|
|
757
746
|
return shim;
|
|
758
747
|
return mergeHealthStates(shim, usage);
|
|
759
748
|
}
|
|
749
|
+
function poolHistoryFile() {
|
|
750
|
+
return join(homedir(), ".openclaw", "state", "multi-clawd", HISTORY_FILE);
|
|
751
|
+
}
|
|
760
752
|
function readStickyEntry(file) {
|
|
761
753
|
try {
|
|
762
754
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -824,6 +816,11 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
824
816
|
defaultModel: pool.defaultModel,
|
|
825
817
|
};
|
|
826
818
|
const minDwellMs = pool.minDwellMs;
|
|
819
|
+
const strategy = poolStrategy(pool.strategy);
|
|
820
|
+
const affinityOn = pool.sessionAffinity === true || strategy === "spread";
|
|
821
|
+
if (strategy === "spread" && pool.sessionAffinity === false) {
|
|
822
|
+
logger.warn(`[multi-clawd] pool "${poolId}": strategy "spread" keeps each session on the account that first served it — sessionAffinity: false is ignored`);
|
|
823
|
+
}
|
|
827
824
|
const stickyFile = join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sticky.json`);
|
|
828
825
|
const backend = buildBackend(poolAccount, execMode, deps?.launch);
|
|
829
826
|
const { launch, transport } = launchTransports.get(backend);
|
|
@@ -849,20 +846,24 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
849
846
|
throw new Error(`[multi-clawd] ${text}`);
|
|
850
847
|
}
|
|
851
848
|
const previousSticky = readStickyEntry(stickyFile);
|
|
852
|
-
const
|
|
849
|
+
const rawVerdicts = verdicts.map((v) => ({
|
|
853
850
|
id: v.id,
|
|
854
851
|
verdict: v.health.verdict,
|
|
855
852
|
observedAt: v.health.observedAt,
|
|
856
|
-
|
|
853
|
+
utilization: v.health.utilization,
|
|
854
|
+
sevenDayResetsAt: strategy === "reset-aware"
|
|
857
855
|
? sevenDayReset(states.find((s) => s.id === v.id)?.state, options, now)
|
|
858
856
|
: undefined,
|
|
859
857
|
}));
|
|
858
|
+
const poolVerdicts = strategy === "spread"
|
|
859
|
+
? withSpreadStats(rawVerdicts, spreadStatsFromHistory(readHistory(poolHistoryFile()), now, spreadWindowMs(pool.spreadWindowMs)))
|
|
860
|
+
: rawVerdicts;
|
|
860
861
|
const decision = decideStickySelection({
|
|
861
862
|
verdicts: poolVerdicts,
|
|
862
863
|
sticky: previousSticky,
|
|
863
864
|
nowMs: now,
|
|
864
865
|
minDwellMs,
|
|
865
|
-
strategy
|
|
866
|
+
strategy,
|
|
866
867
|
});
|
|
867
868
|
const chosen = members.find((a) => a.id === decision.account) ?? members[0];
|
|
868
869
|
const previousAccount = previousSticky?.account ?? members[0].id;
|
|
@@ -878,7 +879,9 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
878
879
|
logger.info(`[multi-clawd] pool ${poolId}: no proven account can serve ${requestedModel} — ` +
|
|
879
880
|
`launching on ${decision.account} to re-test a login last rejected by the Claude CLI`);
|
|
880
881
|
}
|
|
881
|
-
else if (decision.account !== previousAccount) {
|
|
882
|
+
else if (strategy !== "spread" && decision.account !== previousAccount) {
|
|
883
|
+
recordHistory(join(homedir(), ".openclaw", "state", "multi-clawd", `${previousAccount}.json`), { at: new Date(now).toISOString(), kind: "rotation", accountId: previousAccount,
|
|
884
|
+
toAccountId: decision.account, modelId: requestedModel, reason: "pool_selection" });
|
|
882
885
|
const home = verdicts[0];
|
|
883
886
|
const line = decision.account === members[0].id
|
|
884
887
|
? `pool ${poolId}: returning home to ${decision.account}`
|
|
@@ -975,7 +978,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
975
978
|
env[RETRY_ROSTER_ENV] = JSON.stringify(roster);
|
|
976
979
|
}
|
|
977
980
|
env[SESSION_DIRS_ENV] = JSON.stringify([...new Set(configuredMembers.map(accountConfigDir))]);
|
|
978
|
-
if (
|
|
981
|
+
if (affinityOn) {
|
|
979
982
|
env[SESSION_AFFINITY_ENV] = JSON.stringify({
|
|
980
983
|
file: join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sessions.json`),
|
|
981
984
|
ttlMs: typeof pool.sessionAffinityTtlMs === "number" &&
|
|
@@ -1012,7 +1015,7 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
1012
1015
|
logger.info(`[multi-clawd] pool ${poolId}: pinned lane keeps ${ctx.modelId} despite exhausted pool (will fail over via the chain)`);
|
|
1013
1016
|
}
|
|
1014
1017
|
}
|
|
1015
|
-
const affinityTransport =
|
|
1018
|
+
const affinityTransport = affinityOn && transport && launch.kind === "verified"
|
|
1016
1019
|
? async function* (execution) {
|
|
1017
1020
|
const id = launchSessionId(execution.args);
|
|
1018
1021
|
const pin = id ? readSessionPin(join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sessions.json`), id, Date.now(), typeof pool.sessionAffinityTtlMs === "number" && pool.sessionAffinityTtlMs > 0
|
|
@@ -1112,6 +1115,7 @@ export function startDirectOrderSync(params) {
|
|
|
1112
1115
|
healthOptions,
|
|
1113
1116
|
minDwellMs: params.pool?.minDwellMs,
|
|
1114
1117
|
strategy: params.pool?.strategy,
|
|
1118
|
+
spreadWindowMs: params.pool?.spreadWindowMs,
|
|
1115
1119
|
command: params.directRoute?.openclawCommand,
|
|
1116
1120
|
});
|
|
1117
1121
|
if (directController?.signature === signature && directTimer) {
|
|
@@ -1127,6 +1131,8 @@ export function startDirectOrderSync(params) {
|
|
|
1127
1131
|
healthOptions,
|
|
1128
1132
|
minDwellMs: params.pool?.minDwellMs,
|
|
1129
1133
|
strategy: params.pool?.strategy,
|
|
1134
|
+
readHistory: () => readHistory(poolHistoryFile()),
|
|
1135
|
+
spreadWindowMs: params.pool?.spreadWindowMs,
|
|
1130
1136
|
configOrder: params.configOrder,
|
|
1131
1137
|
readSticky: () => readStickyEntry(stickyFile),
|
|
1132
1138
|
writeSticky: (entry) => writeStickyEntry(stickyFile, entry, logger),
|
|
@@ -1228,17 +1234,7 @@ export function startUsagePoll(params) {
|
|
|
1228
1234
|
}
|
|
1229
1235
|
},
|
|
1230
1236
|
readHealth: (id) => {
|
|
1231
|
-
|
|
1232
|
-
try {
|
|
1233
|
-
shim = parseStoredState(readFileSync(healthStateFile(id), "utf8"));
|
|
1234
|
-
if (!shim)
|
|
1235
|
-
throw new Error("not valid health-state JSON");
|
|
1236
|
-
}
|
|
1237
|
-
catch (err) {
|
|
1238
|
-
if (err.code !== "ENOENT")
|
|
1239
|
-
throw err;
|
|
1240
|
-
shim = undefined;
|
|
1241
|
-
}
|
|
1237
|
+
const shim = readHealthStateFile(healthStateFile(id), { strictSnapshot: true });
|
|
1242
1238
|
let usage;
|
|
1243
1239
|
try {
|
|
1244
1240
|
usage = parseStoredState(readFileSync(usageStateFile(id), "utf8"));
|
package/dist/retry-plan.js
CHANGED
|
@@ -66,6 +66,8 @@ export function chooseRetryAccount(params) {
|
|
|
66
66
|
return account;
|
|
67
67
|
verdicts.push({ id: account.id, verdict: health.verdict, observedAt: health.observedAt });
|
|
68
68
|
}
|
|
69
|
+
if (params.allowOverdueProbe === false)
|
|
70
|
+
return undefined;
|
|
69
71
|
const probe = overdueProbeAccount(verdicts, params.nowMs);
|
|
70
72
|
return probe ? params.roster.find((a) => a.id === probe) : undefined;
|
|
71
73
|
}
|
package/dist/shim-core.js
CHANGED
|
@@ -192,9 +192,9 @@ export function limitFamilyFor(displayName, modelId) {
|
|
|
192
192
|
return displayName.trim().toLowerCase() === family ? family : undefined;
|
|
193
193
|
}
|
|
194
194
|
const LIMIT_TEXT_RE = /reached your (.{1,40}?) limit/i;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
const USAGE_LIMIT_API_ERROR = "usage_limit_reached";
|
|
196
|
+
const AUTH_FAILED_ERROR_KIND = "authentication_failed";
|
|
197
|
+
function parseRecord(line) {
|
|
198
198
|
let record;
|
|
199
199
|
try {
|
|
200
200
|
record = JSON.parse(line);
|
|
@@ -204,12 +204,15 @@ export function parseModelLimitError(line) {
|
|
|
204
204
|
}
|
|
205
205
|
if (typeof record !== "object" || record === null)
|
|
206
206
|
return undefined;
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
return record;
|
|
208
|
+
}
|
|
209
|
+
function isErrorRecord(r) {
|
|
210
|
+
return (r.type === "error" ||
|
|
209
211
|
r.is_error === true ||
|
|
210
|
-
(typeof r.subtype === "string" && r.subtype.startsWith("error"))
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
(typeof r.subtype === "string" && r.subtype.startsWith("error")) ||
|
|
213
|
+
(r.type === "assistant" && r.is_api_error_message === true));
|
|
214
|
+
}
|
|
215
|
+
function errorRecordTexts(r) {
|
|
213
216
|
const texts = [];
|
|
214
217
|
if (typeof r.result === "string")
|
|
215
218
|
texts.push(r.result);
|
|
@@ -220,13 +223,193 @@ export function parseModelLimitError(line) {
|
|
|
220
223
|
if (typeof msg === "string")
|
|
221
224
|
texts.push(msg);
|
|
222
225
|
}
|
|
223
|
-
|
|
226
|
+
if (r.type === "assistant" && r.is_api_error_message === true && Array.isArray(r.message?.content)) {
|
|
227
|
+
for (const block of r.message.content) {
|
|
228
|
+
if (block && block.type === "text" && typeof block.text === "string")
|
|
229
|
+
texts.push(block.text);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return texts;
|
|
233
|
+
}
|
|
234
|
+
const TRANSIENT_TEXT_PATTERNS = [
|
|
235
|
+
{ re: /repeated 529 overloaded errors/i, kind: "overloaded" },
|
|
236
|
+
{ re: /overloaded_error/i, kind: "overloaded" },
|
|
237
|
+
{ re: /the api is at capacity/i, kind: "overloaded" },
|
|
238
|
+
{ re: /server-side issue, usually temporary/i, kind: "server_error" },
|
|
239
|
+
{ re: /temporarily limiting requests \(not your usage limit\)/i, kind: "rate_limit_transient" },
|
|
240
|
+
{ re: /request rejected \(429\)/i, kind: "rate_limit_transient" },
|
|
241
|
+
];
|
|
242
|
+
const TRANSIENT_TEXT_MAX_CHARS = 160;
|
|
243
|
+
export function parseTransientApiError(line) {
|
|
244
|
+
if (!/("error"|is_error|api_error_status|overloaded|server-side|temporarily limiting|request rejected|at capacity)/i.test(line)) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const r = parseRecord(line);
|
|
248
|
+
if (!r || !isErrorRecord(r))
|
|
249
|
+
return undefined;
|
|
250
|
+
if (parseModelLimitError(line) || parseAuthFailure(line))
|
|
251
|
+
return undefined;
|
|
252
|
+
const joined = errorRecordTexts(r).join("\n");
|
|
253
|
+
const status = typeof r.api_error_status === "number" ? r.api_error_status : undefined;
|
|
254
|
+
const text = joined.trim().slice(0, TRANSIENT_TEXT_MAX_CHARS);
|
|
255
|
+
const kindFromCli = typeof r.error === "string" ? r.error : undefined;
|
|
256
|
+
if (status === 529 || kindFromCli === "overloaded")
|
|
257
|
+
return { kind: "overloaded", status, text };
|
|
258
|
+
if ((status !== undefined && status >= 500) || kindFromCli === "server_error") {
|
|
259
|
+
return { kind: "server_error", status, text };
|
|
260
|
+
}
|
|
261
|
+
for (const { re, kind } of TRANSIENT_TEXT_PATTERNS) {
|
|
262
|
+
if (re.test(joined))
|
|
263
|
+
return { kind, status, text };
|
|
264
|
+
}
|
|
265
|
+
if (status === 429)
|
|
266
|
+
return { kind: "rate_limit_transient", status, text };
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
export function parseApiRetryNotice(line) {
|
|
270
|
+
if (!line.includes('"api_retry"'))
|
|
271
|
+
return undefined;
|
|
272
|
+
let record;
|
|
273
|
+
try {
|
|
274
|
+
record = JSON.parse(line);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
if (typeof record !== "object" || record === null)
|
|
280
|
+
return undefined;
|
|
281
|
+
const r = record;
|
|
282
|
+
if (r.type !== "system" || r.subtype !== "api_retry")
|
|
283
|
+
return undefined;
|
|
284
|
+
const delay = typeof r.retry_delay_ms === "number" && r.retry_delay_ms >= 0 ? r.retry_delay_ms : 0;
|
|
285
|
+
return { retryDelayMs: delay };
|
|
286
|
+
}
|
|
287
|
+
export function parseModelLimitError(line) {
|
|
288
|
+
if (!/(reached your |usage_limit_reached)/i.test(line))
|
|
289
|
+
return undefined;
|
|
290
|
+
const r = parseRecord(line);
|
|
291
|
+
if (!r || !isErrorRecord(r))
|
|
292
|
+
return undefined;
|
|
293
|
+
for (const text of errorRecordTexts(r)) {
|
|
224
294
|
const match = LIMIT_TEXT_RE.exec(text);
|
|
225
295
|
if (match)
|
|
226
296
|
return { displayName: match[1].trim() };
|
|
227
297
|
}
|
|
298
|
+
if (r.api_error === USAGE_LIMIT_API_ERROR)
|
|
299
|
+
return { displayName: USAGE_LIMIT_API_ERROR };
|
|
228
300
|
return undefined;
|
|
229
301
|
}
|
|
302
|
+
export const LIMIT_PASSTHROUGH_MARKER = " (rate limit: 429)";
|
|
303
|
+
const HOST_DIAGNOSTIC_ERROR_PREFIX = "[ede_diagnostic]";
|
|
304
|
+
export function markForwardedLimitLine(line) {
|
|
305
|
+
if (!parseModelLimitError(line))
|
|
306
|
+
return line;
|
|
307
|
+
let record;
|
|
308
|
+
try {
|
|
309
|
+
record = JSON.parse(line);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return line;
|
|
313
|
+
}
|
|
314
|
+
if (typeof record !== "object" || record === null || Array.isArray(record))
|
|
315
|
+
return line;
|
|
316
|
+
const r = record;
|
|
317
|
+
const subtype = typeof r.subtype === "string" ? r.subtype : "";
|
|
318
|
+
if (r.type !== "result" || !(r.is_error === true || subtype.startsWith("error")))
|
|
319
|
+
return line;
|
|
320
|
+
const mark = (text) => text.includes(LIMIT_PASSTHROUGH_MARKER) ? undefined : `${text.trimEnd()}${LIMIT_PASSTHROUGH_MARKER}`;
|
|
321
|
+
if (Array.isArray(r.errors)) {
|
|
322
|
+
const idx = r.errors.findIndex((e) => typeof e === "string" && e.trim() !== "" && !e.trim().startsWith(HOST_DIAGNOSTIC_ERROR_PREFIX));
|
|
323
|
+
if (idx >= 0) {
|
|
324
|
+
const marked = mark(r.errors[idx]);
|
|
325
|
+
if (marked === undefined)
|
|
326
|
+
return line;
|
|
327
|
+
const errors = [...r.errors];
|
|
328
|
+
errors[idx] = marked;
|
|
329
|
+
return JSON.stringify({ ...r, errors });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (typeof r.result === "string" && r.result.trim() !== "") {
|
|
333
|
+
const marked = mark(r.result);
|
|
334
|
+
return marked === undefined ? line : JSON.stringify({ ...r, result: marked });
|
|
335
|
+
}
|
|
336
|
+
if (typeof r.error === "object" && r.error !== null && !Array.isArray(r.error)) {
|
|
337
|
+
const err = r.error;
|
|
338
|
+
if (typeof err.message === "string" && err.message.trim() !== "") {
|
|
339
|
+
const marked = mark(err.message);
|
|
340
|
+
return marked === undefined ? line : JSON.stringify({ ...r, error: { ...err, message: marked } });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (typeof r.error === "string" && r.error.trim() !== "") {
|
|
344
|
+
const marked = mark(r.error);
|
|
345
|
+
return marked === undefined ? line : JSON.stringify({ ...r, error: marked });
|
|
346
|
+
}
|
|
347
|
+
return line;
|
|
348
|
+
}
|
|
349
|
+
const RESULT_LINE_PREFIX = Buffer.from('{"type":"result"', "utf8");
|
|
350
|
+
const FORWARD_HOLD_CAP_BYTES = 256 * 1024;
|
|
351
|
+
function couldBeResultLine(pending) {
|
|
352
|
+
let i = 0;
|
|
353
|
+
while (i < pending.length && (pending[i] === 0x20 || pending[i] === 0x09 || pending[i] === 0x0d))
|
|
354
|
+
i++;
|
|
355
|
+
const rest = pending.subarray(i);
|
|
356
|
+
const n = Math.min(rest.length, RESULT_LINE_PREFIX.length);
|
|
357
|
+
return rest.subarray(0, n).equals(RESULT_LINE_PREFIX.subarray(0, n));
|
|
358
|
+
}
|
|
359
|
+
function forwardCompleteLine(line, onMark) {
|
|
360
|
+
const text = line.toString("utf8");
|
|
361
|
+
const ending = text.endsWith("\r\n") ? "\r\n" : text.endsWith("\n") ? "\n" : "";
|
|
362
|
+
const body = text.slice(0, text.length - ending.length).trim();
|
|
363
|
+
const marked = markForwardedLimitLine(body);
|
|
364
|
+
if (marked === body)
|
|
365
|
+
return line;
|
|
366
|
+
onMark?.();
|
|
367
|
+
return Buffer.from(marked + ending, "utf8");
|
|
368
|
+
}
|
|
369
|
+
export function createForwardWriter(write, onMark) {
|
|
370
|
+
let pending;
|
|
371
|
+
let streaming = false;
|
|
372
|
+
return {
|
|
373
|
+
push(chunk) {
|
|
374
|
+
const out = [];
|
|
375
|
+
let start = 0;
|
|
376
|
+
while (start < chunk.length) {
|
|
377
|
+
const nl = chunk.indexOf(0x0a, start);
|
|
378
|
+
const end = nl < 0 ? chunk.length : nl + 1;
|
|
379
|
+
const piece = chunk.subarray(start, end);
|
|
380
|
+
start = end;
|
|
381
|
+
if (streaming) {
|
|
382
|
+
out.push(piece);
|
|
383
|
+
if (nl >= 0)
|
|
384
|
+
streaming = false;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const line = pending ? Buffer.concat([pending, piece]) : piece;
|
|
388
|
+
pending = undefined;
|
|
389
|
+
if (nl >= 0) {
|
|
390
|
+
out.push(forwardCompleteLine(line, onMark));
|
|
391
|
+
}
|
|
392
|
+
else if (!couldBeResultLine(line) || line.length > FORWARD_HOLD_CAP_BYTES) {
|
|
393
|
+
out.push(line);
|
|
394
|
+
streaming = true;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
pending = Buffer.from(line);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (out.length > 0)
|
|
401
|
+
write(out.length === 1 ? out[0] : Buffer.concat(out));
|
|
402
|
+
},
|
|
403
|
+
flush() {
|
|
404
|
+
if (pending) {
|
|
405
|
+
const line = pending;
|
|
406
|
+
pending = undefined;
|
|
407
|
+
write(forwardCompleteLine(line, onMark));
|
|
408
|
+
}
|
|
409
|
+
streaming = false;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
230
413
|
export function recordModelLimit(state, modelId, now, resetsAt, family) {
|
|
231
414
|
return {
|
|
232
415
|
...state,
|
|
@@ -300,36 +483,16 @@ export function parseAuthFailure(line) {
|
|
|
300
483
|
if (!/(authenticat|oauth|logged in|\/login|unauthori|api key|access token|bearer token)/i.test(line)) {
|
|
301
484
|
return undefined;
|
|
302
485
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
record = JSON.parse(line);
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
return undefined;
|
|
309
|
-
}
|
|
310
|
-
if (typeof record !== "object" || record === null)
|
|
311
|
-
return undefined;
|
|
312
|
-
const r = record;
|
|
313
|
-
const isErrorRecord = r.type === "error" ||
|
|
314
|
-
r.is_error === true ||
|
|
315
|
-
(typeof r.subtype === "string" && r.subtype.startsWith("error"));
|
|
316
|
-
if (!isErrorRecord)
|
|
486
|
+
const r = parseRecord(line);
|
|
487
|
+
if (!r || !isErrorRecord(r))
|
|
317
488
|
return undefined;
|
|
318
|
-
const
|
|
319
|
-
if (typeof r.result === "string")
|
|
320
|
-
texts.push(r.result);
|
|
321
|
-
if (typeof r.error === "string")
|
|
322
|
-
texts.push(r.error);
|
|
323
|
-
if (typeof r.error === "object" && r.error !== null) {
|
|
324
|
-
const msg = r.error.message;
|
|
325
|
-
if (typeof msg === "string")
|
|
326
|
-
texts.push(msg);
|
|
327
|
-
}
|
|
328
|
-
for (const text of texts) {
|
|
489
|
+
for (const text of errorRecordTexts(r)) {
|
|
329
490
|
if (AUTH_FAILURE_PATTERNS.some((p) => p.test(text))) {
|
|
330
491
|
return { reason: text.trim().slice(0, AUTH_REASON_MAX_CHARS) };
|
|
331
492
|
}
|
|
332
493
|
}
|
|
494
|
+
if (r.error === AUTH_FAILED_ERROR_KIND)
|
|
495
|
+
return { reason: AUTH_FAILED_ERROR_KIND };
|
|
333
496
|
return undefined;
|
|
334
497
|
}
|
|
335
498
|
export function recordCredentialFailure(state, reason, now) {
|