@drakon-systems/multi-clawd 2.3.0 → 2.5.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/README.md +121 -31
- package/SECURITY.md +3 -0
- package/dist/credential-state.js +8 -24
- package/dist/direct-report.js +12 -1
- package/dist/direct-route.js +12 -6
- package/dist/direct-sync.js +12 -4
- package/dist/explain-core.js +33 -10
- package/dist/health-journal.js +123 -0
- package/dist/health.js +13 -1
- package/dist/history.js +103 -0
- package/dist/index.js +89 -38
- package/dist/retry-plan.js +2 -0
- package/dist/session-affinity.js +108 -0
- package/dist/shim-core.js +197 -34
- package/dist/shim-lock.js +106 -0
- package/dist/shim.js +170 -79
- package/dist/spread.js +54 -0
- package/dist/sticky.js +2 -0
- package/dist/update-core.js +75 -1
- package/openclaw.plugin.json +26 -3
- package/package.json +1 -1
- package/scripts/account-config.mjs +115 -0
- package/scripts/cli.mjs +131 -25
- package/scripts/doctor.mjs +113 -6
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { acquireShimLock } from "./shim-lock.js";
|
|
5
|
+
import { mergeHealthStates, parseStoredState } from "./shim-core.js";
|
|
6
|
+
function pendingFiles(file) {
|
|
7
|
+
const prefix = `${basename(file)}.pending-`;
|
|
8
|
+
try {
|
|
9
|
+
return readdirSync(dirname(file)).filter((name) => name.startsWith(prefix) && /^\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(name.slice(prefix.length))).map((name) => join(dirname(file), name));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function snapshot(file, onError, strict = false) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(file, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err.code !== "ENOENT") {
|
|
22
|
+
onError?.(undefined);
|
|
23
|
+
if (strict)
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const state = parseStoredState(raw);
|
|
29
|
+
if (!state) {
|
|
30
|
+
onError?.(raw);
|
|
31
|
+
if (strict)
|
|
32
|
+
throw new Error("not valid health-state JSON");
|
|
33
|
+
}
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
export function readHealthStateFile(file, opts = {}) {
|
|
37
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
38
|
+
let state = snapshot(file, opts.onSnapshotError, opts.strictSnapshot);
|
|
39
|
+
const files = pendingFiles(file);
|
|
40
|
+
opts.afterList?.(files);
|
|
41
|
+
let missing = false;
|
|
42
|
+
for (const pending of files) {
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = readFileSync(pending, "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err.code === "ENOENT")
|
|
49
|
+
missing = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const delta = parseStoredState(raw);
|
|
53
|
+
if (delta)
|
|
54
|
+
state = state ? mergeHealthStates(state, delta) : delta;
|
|
55
|
+
}
|
|
56
|
+
if (!missing || attempt === 2)
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function publish(file, state) {
|
|
62
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
63
|
+
const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`;
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
66
|
+
renameSync(tmp, file);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
try {
|
|
70
|
+
unlinkSync(tmp);
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function persistHealthState(file, state, opts = {}) {
|
|
77
|
+
const release = acquireShimLock(file);
|
|
78
|
+
try {
|
|
79
|
+
if (!release) {
|
|
80
|
+
publish(`${file}.pending-${process.pid}-${randomUUID()}`, state);
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
83
|
+
let merged = snapshot(file, opts.onSnapshotError);
|
|
84
|
+
const files = pendingFiles(file);
|
|
85
|
+
opts.afterList?.(files);
|
|
86
|
+
const folded = [];
|
|
87
|
+
for (const pending of files) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = readFileSync(pending, "utf8");
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const delta = parseStoredState(raw);
|
|
96
|
+
if (!delta) {
|
|
97
|
+
try {
|
|
98
|
+
renameSync(pending, `${pending}.corrupt-${Date.now()}`);
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
merged = merged ? mergeHealthStates(merged, delta) : delta;
|
|
104
|
+
folded.push(pending);
|
|
105
|
+
}
|
|
106
|
+
merged = merged ? mergeHealthStates(merged, state, Date.now()) : mergeHealthStates(state, state, Date.now());
|
|
107
|
+
publish(file, merged);
|
|
108
|
+
for (const pending of folded) {
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(pending);
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
}
|
|
114
|
+
return merged;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
process.stderr.write(`[multi-clawd shim] state write failed: ${String(err)}\n`);
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
release?.();
|
|
122
|
+
}
|
|
123
|
+
}
|
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;
|
|
@@ -223,7 +224,7 @@ export function summarizeWindowUsage(state, options, nowMs) {
|
|
|
223
224
|
return usage.sort((a, b) => (b.resetsAt ?? 0) - (a.resetsAt ?? 0));
|
|
224
225
|
}
|
|
225
226
|
export function poolStrategy(value) {
|
|
226
|
-
return value === "reset-aware"
|
|
227
|
+
return value === "reset-aware" || value === "spread" ? value : "ordered";
|
|
227
228
|
}
|
|
228
229
|
export function sevenDayReset(state, options, nowMs) {
|
|
229
230
|
const usage = summarizeWindowUsage(state, options, nowMs);
|
|
@@ -243,6 +244,17 @@ export function choosePoolAccount(pool, strategy = "ordered") {
|
|
|
243
244
|
if (best?.sevenDayResetsAt !== undefined)
|
|
244
245
|
return best.id;
|
|
245
246
|
}
|
|
247
|
+
if (strategy === "spread") {
|
|
248
|
+
let best;
|
|
249
|
+
for (const member of pool) {
|
|
250
|
+
if (member.verdict !== "ok" && member.verdict !== "no_data")
|
|
251
|
+
continue;
|
|
252
|
+
if (!best || spreadBefore(member, best))
|
|
253
|
+
best = member;
|
|
254
|
+
}
|
|
255
|
+
if (best)
|
|
256
|
+
return best.id;
|
|
257
|
+
}
|
|
246
258
|
const usable = pool.find((a) => a.verdict === "ok" || a.verdict === "no_data");
|
|
247
259
|
if (usable)
|
|
248
260
|
return usable.id;
|
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,11 @@ 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";
|
|
14
|
+
import { DEFAULT_SESSION_AFFINITY_TTL_MS, launchSessionId, readSessionPin, SESSION_AFFINITY_ENV, } from "./session-affinity.js";
|
|
12
15
|
import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
|
|
13
16
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
14
17
|
import { resolveSecretRefValues } from "openclaw/plugin-sdk/secret-ref-runtime";
|
|
@@ -17,6 +20,7 @@ import { RETRY_ROSTER_ENV } from "./retry-plan.js";
|
|
|
17
20
|
import { SESSION_DIRS_ENV } from "./session-handover.js";
|
|
18
21
|
import { addAlert, alertKeysWithPrefix, clearAlert, pendingAlertText, } from "./alerts.js";
|
|
19
22
|
import { healthStateFile, usageStateFile, clearAccountCredentialFailure } from "./credential-state.js";
|
|
23
|
+
import { persistHealthState, readHealthStateFile } from "./health-journal.js";
|
|
20
24
|
import { createUsagePollController, effectiveUsagePollInterval, effectiveUsageWarnThreshold, usageAlertPrefix, usagePoolAlertKey, } from "./usage-poll.js";
|
|
21
25
|
export { healthStateFile, usageStateFile, clearAccountCredentialFailure };
|
|
22
26
|
import { accountConfigDir, AccountCredentialError, buildAccountChildEnv, credentialFailureText, parseSetupTokenFile, tokenFileModeWarning, validateAccountTokenSources, } from "./account-env.js";
|
|
@@ -187,22 +191,8 @@ const refProbeTrackers = new Map();
|
|
|
187
191
|
const lastProbeStatus = new Map();
|
|
188
192
|
function recordAccountCredentialFailure(accountId, reason, nowMs) {
|
|
189
193
|
const file = healthStateFile(accountId);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
state = parseStoredState(readFileSync(file, "utf8")) ?? { accountId, windows: {} };
|
|
193
|
-
}
|
|
194
|
-
catch {
|
|
195
|
-
state = { accountId, windows: {} };
|
|
196
|
-
}
|
|
197
|
-
const next = mergeHealthStates(state, recordCredentialFailure(state, reason, nowMs), nowMs);
|
|
198
|
-
try {
|
|
199
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
200
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
201
|
-
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
202
|
-
renameSync(tmp, file);
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
}
|
|
194
|
+
const state = readHealthStateFile(file) ?? { accountId, windows: {} };
|
|
195
|
+
persistHealthState(file, recordCredentialFailure(state, reason, nowMs));
|
|
206
196
|
}
|
|
207
197
|
export async function runLoginHealthProbe(accounts, logger, deps = {}) {
|
|
208
198
|
const now = deps.nowMs ?? Date.now();
|
|
@@ -436,6 +426,7 @@ export function resolveBackendLaunch(params) {
|
|
|
436
426
|
const SHIM_ONLY_ENV = [
|
|
437
427
|
RETRY_ROSTER_ENV,
|
|
438
428
|
SESSION_DIRS_ENV,
|
|
429
|
+
SESSION_AFFINITY_ENV,
|
|
439
430
|
"MULTI_CLAWD_MODEL_OVERRIDE",
|
|
440
431
|
CLAUDE_BIN_ENV,
|
|
441
432
|
CLAUDE_ARGV0_ENV,
|
|
@@ -572,6 +563,8 @@ const launchTransports = new WeakMap();
|
|
|
572
563
|
export function buildRetryRoster(members, launchedId) {
|
|
573
564
|
const roster = [];
|
|
574
565
|
for (const member of members) {
|
|
566
|
+
if (member.disabled === true)
|
|
567
|
+
continue;
|
|
575
568
|
if (member.id === launchedId)
|
|
576
569
|
continue;
|
|
577
570
|
const tokenFile = !member.native && member.oauthTokenFile ? expandHome(member.oauthTokenFile) : undefined;
|
|
@@ -736,7 +729,7 @@ export default definePluginEntry({
|
|
|
736
729
|
},
|
|
737
730
|
});
|
|
738
731
|
function readHealthState(accountId) {
|
|
739
|
-
return mergeStoredHealth(
|
|
732
|
+
return mergeStoredHealth(readHealthStateFile(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
|
|
740
733
|
}
|
|
741
734
|
function readStateFileLenient(file) {
|
|
742
735
|
try {
|
|
@@ -753,6 +746,9 @@ function mergeStoredHealth(shim, usage) {
|
|
|
753
746
|
return shim;
|
|
754
747
|
return mergeHealthStates(shim, usage);
|
|
755
748
|
}
|
|
749
|
+
function poolHistoryFile() {
|
|
750
|
+
return join(homedir(), ".openclaw", "state", "multi-clawd", HISTORY_FILE);
|
|
751
|
+
}
|
|
756
752
|
function readStickyEntry(file) {
|
|
757
753
|
try {
|
|
758
754
|
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -785,9 +781,10 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
785
781
|
return;
|
|
786
782
|
const poolId = pool.id?.trim() || "clawd";
|
|
787
783
|
const memberIds = (pool.accounts ?? []).filter((id) => registeredIds.has(id));
|
|
788
|
-
const
|
|
784
|
+
const configuredMembers = memberIds
|
|
789
785
|
.map((id) => accounts.find((a) => a.id.trim() === id))
|
|
790
786
|
.filter((a) => a !== undefined);
|
|
787
|
+
const members = configuredMembers.filter((a) => a.disabled !== true);
|
|
791
788
|
const ladder = (pool.degrade?.ladder ?? []).filter((m) => {
|
|
792
789
|
if (isModernClaudeModelId(m))
|
|
793
790
|
return true;
|
|
@@ -795,7 +792,10 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
795
792
|
return false;
|
|
796
793
|
});
|
|
797
794
|
const pins = pool.degrade?.pins ?? [];
|
|
798
|
-
if (members.length
|
|
795
|
+
if (members.length === 0 && memberIds.length > 0) {
|
|
796
|
+
logger.error(`[multi-clawd] pool "${poolId}": every member has pool.accounts[].disabled: true`);
|
|
797
|
+
}
|
|
798
|
+
if (members.length < 2 && !(members.length === 0 && memberIds.length > 0) && !(members.length === 1 && (ladder.length > 0 || memberIds.length > 1))) {
|
|
799
799
|
logger.warn(`[multi-clawd] pool "${poolId}" has ${members.length} registered account(s) — need at least 2 (or 1 with a degrade ladder); pool not registered`);
|
|
800
800
|
return;
|
|
801
801
|
}
|
|
@@ -816,10 +816,17 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
816
816
|
defaultModel: pool.defaultModel,
|
|
817
817
|
};
|
|
818
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
|
+
}
|
|
819
824
|
const stickyFile = join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sticky.json`);
|
|
820
825
|
const backend = buildBackend(poolAccount, execMode, deps?.launch);
|
|
821
826
|
const { launch, transport } = launchTransports.get(backend);
|
|
822
827
|
backend.prepareExecution = async (ctx) => {
|
|
828
|
+
if (members.length === 0)
|
|
829
|
+
throw new Error(`[multi-clawd] pool "${poolId}": every member has pool.accounts[].disabled: true`);
|
|
823
830
|
const now = Date.now();
|
|
824
831
|
const requestedModel = canonicalModelId(ctx.modelId) ?? ctx.modelId;
|
|
825
832
|
const states = members.map((a) => ({ id: a.id, state: readHealthState(a.id) }));
|
|
@@ -839,20 +846,23 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
839
846
|
throw new Error(`[multi-clawd] ${text}`);
|
|
840
847
|
}
|
|
841
848
|
const previousSticky = readStickyEntry(stickyFile);
|
|
842
|
-
const
|
|
849
|
+
const rawVerdicts = verdicts.map((v) => ({
|
|
843
850
|
id: v.id,
|
|
844
851
|
verdict: v.health.verdict,
|
|
845
852
|
observedAt: v.health.observedAt,
|
|
846
|
-
sevenDayResetsAt:
|
|
853
|
+
sevenDayResetsAt: strategy === "reset-aware"
|
|
847
854
|
? sevenDayReset(states.find((s) => s.id === v.id)?.state, options, now)
|
|
848
855
|
: undefined,
|
|
849
856
|
}));
|
|
857
|
+
const poolVerdicts = strategy === "spread"
|
|
858
|
+
? withSpreadStats(rawVerdicts, spreadStatsFromHistory(readHistory(poolHistoryFile()), now, spreadWindowMs(pool.spreadWindowMs)))
|
|
859
|
+
: rawVerdicts;
|
|
850
860
|
const decision = decideStickySelection({
|
|
851
861
|
verdicts: poolVerdicts,
|
|
852
862
|
sticky: previousSticky,
|
|
853
863
|
nowMs: now,
|
|
854
864
|
minDwellMs,
|
|
855
|
-
strategy
|
|
865
|
+
strategy,
|
|
856
866
|
});
|
|
857
867
|
const chosen = members.find((a) => a.id === decision.account) ?? members[0];
|
|
858
868
|
const previousAccount = previousSticky?.account ?? members[0].id;
|
|
@@ -868,7 +878,9 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
868
878
|
logger.info(`[multi-clawd] pool ${poolId}: no proven account can serve ${requestedModel} — ` +
|
|
869
879
|
`launching on ${decision.account} to re-test a login last rejected by the Claude CLI`);
|
|
870
880
|
}
|
|
871
|
-
else if (decision.account !== previousAccount) {
|
|
881
|
+
else if (strategy !== "spread" && decision.account !== previousAccount) {
|
|
882
|
+
recordHistory(join(homedir(), ".openclaw", "state", "multi-clawd", `${previousAccount}.json`), { at: new Date(now).toISOString(), kind: "rotation", accountId: previousAccount,
|
|
883
|
+
toAccountId: decision.account, modelId: requestedModel, reason: "pool_selection" });
|
|
872
884
|
const home = verdicts[0];
|
|
873
885
|
const line = decision.account === members[0].id
|
|
874
886
|
? `pool ${poolId}: returning home to ${decision.account}`
|
|
@@ -964,7 +976,17 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
964
976
|
if (roster.length > 0)
|
|
965
977
|
env[RETRY_ROSTER_ENV] = JSON.stringify(roster);
|
|
966
978
|
}
|
|
967
|
-
env[SESSION_DIRS_ENV] = JSON.stringify([...new Set(
|
|
979
|
+
env[SESSION_DIRS_ENV] = JSON.stringify([...new Set(configuredMembers.map(accountConfigDir))]);
|
|
980
|
+
if (affinityOn) {
|
|
981
|
+
env[SESSION_AFFINITY_ENV] = JSON.stringify({
|
|
982
|
+
file: join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sessions.json`),
|
|
983
|
+
ttlMs: typeof pool.sessionAffinityTtlMs === "number" &&
|
|
984
|
+
Number.isFinite(pool.sessionAffinityTtlMs) && pool.sessionAffinityTtlMs > 0
|
|
985
|
+
? pool.sessionAffinityTtlMs : DEFAULT_SESSION_AFFINITY_TTL_MS,
|
|
986
|
+
verdicts: poolVerdicts.map((v) => ({ id: v.id, verdict: v.verdict })),
|
|
987
|
+
roster: buildRetryRoster(members, ""),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
968
990
|
if (ladder.length > 0) {
|
|
969
991
|
const pinned = matchesPin(pins, {
|
|
970
992
|
agentDir: ctx.agentDir ?? "",
|
|
@@ -992,7 +1014,38 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
992
1014
|
logger.info(`[multi-clawd] pool ${poolId}: pinned lane keeps ${ctx.modelId} despite exhausted pool (will fail over via the chain)`);
|
|
993
1015
|
}
|
|
994
1016
|
}
|
|
995
|
-
|
|
1017
|
+
const affinityTransport = affinityOn && transport && launch.kind === "verified"
|
|
1018
|
+
? async function* (execution) {
|
|
1019
|
+
const id = launchSessionId(execution.args);
|
|
1020
|
+
const pin = id ? readSessionPin(join(homedir(), ".openclaw", "state", "multi-clawd", `pool-${poolId}.sessions.json`), id, Date.now(), typeof pool.sessionAffinityTtlMs === "number" && pool.sessionAffinityTtlMs > 0
|
|
1021
|
+
? pool.sessionAffinityTtlMs : DEFAULT_SESSION_AFFINITY_TTL_MS) : undefined;
|
|
1022
|
+
const member = members.find((m) => m.id === pin?.account && m.oauthTokenRef);
|
|
1023
|
+
if (member && member.id !== execution.env.MULTI_CLAWD_ACCOUNT_ID &&
|
|
1024
|
+
poolVerdicts.some((v) => v.id === member.id &&
|
|
1025
|
+
(v.verdict === "ok" || v.verdict === "no_data"))) {
|
|
1026
|
+
let accountEnv;
|
|
1027
|
+
try {
|
|
1028
|
+
accountEnv = await buildAccountEnv(member, deps?.resolver);
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
}
|
|
1032
|
+
if (accountEnv) {
|
|
1033
|
+
const selectedEnv = { ...execution.env };
|
|
1034
|
+
delete selectedEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
|
1035
|
+
delete selectedEnv.CLAUDE_CONFIG_DIR;
|
|
1036
|
+
const pinnedRoster = buildRetryRoster(members, member.id);
|
|
1037
|
+
if (pinnedRoster.length > 0)
|
|
1038
|
+
selectedEnv[RETRY_ROSTER_ENV] = JSON.stringify(pinnedRoster);
|
|
1039
|
+
else
|
|
1040
|
+
delete selectedEnv[RETRY_ROSTER_ENV];
|
|
1041
|
+
yield* transport({ ...execution, env: { ...selectedEnv, ...accountEnv } });
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
yield* transport(execution);
|
|
1046
|
+
}
|
|
1047
|
+
: transport;
|
|
1048
|
+
return preparedForLaunch(launch, ctx, env, affinityTransport);
|
|
996
1049
|
};
|
|
997
1050
|
api.registerCliBackend(backend);
|
|
998
1051
|
api.registerProvider(buildCatalogProvider(poolAccount, members));
|
|
@@ -1031,6 +1084,11 @@ export function startDirectOrderSync(params) {
|
|
|
1031
1084
|
stopDirectOrderSync();
|
|
1032
1085
|
return { active: false, members: members.map((m) => m.accountId) };
|
|
1033
1086
|
}
|
|
1087
|
+
if (members.every((m) => m.disabled)) {
|
|
1088
|
+
stopDirectOrderSync();
|
|
1089
|
+
logger.warn("[multi-clawd] direct route: pool.accounts[].disabled: every direct member is disabled");
|
|
1090
|
+
return { active: false, members: [] };
|
|
1091
|
+
}
|
|
1034
1092
|
const requested = params.directRoute?.agents?.length ? params.directRoute.agents : ["main"];
|
|
1035
1093
|
const agents = requested.filter((a) => {
|
|
1036
1094
|
if (isValidAgentId(a))
|
|
@@ -1050,12 +1108,13 @@ export function startDirectOrderSync(params) {
|
|
|
1050
1108
|
rotateOnOverage: params.pool?.rotateOnOverage,
|
|
1051
1109
|
};
|
|
1052
1110
|
const signature = JSON.stringify({
|
|
1053
|
-
members: members.map((
|
|
1111
|
+
members: members.map(({ accountId, profileId, source, disabled }) => ({ accountId, profileId, source, disabled })),
|
|
1054
1112
|
agents,
|
|
1055
1113
|
intervalMs,
|
|
1056
1114
|
healthOptions,
|
|
1057
1115
|
minDwellMs: params.pool?.minDwellMs,
|
|
1058
1116
|
strategy: params.pool?.strategy,
|
|
1117
|
+
spreadWindowMs: params.pool?.spreadWindowMs,
|
|
1059
1118
|
command: params.directRoute?.openclawCommand,
|
|
1060
1119
|
});
|
|
1061
1120
|
if (directController?.signature === signature && directTimer) {
|
|
@@ -1071,6 +1130,8 @@ export function startDirectOrderSync(params) {
|
|
|
1071
1130
|
healthOptions,
|
|
1072
1131
|
minDwellMs: params.pool?.minDwellMs,
|
|
1073
1132
|
strategy: params.pool?.strategy,
|
|
1133
|
+
readHistory: () => readHistory(poolHistoryFile()),
|
|
1134
|
+
spreadWindowMs: params.pool?.spreadWindowMs,
|
|
1074
1135
|
configOrder: params.configOrder,
|
|
1075
1136
|
readSticky: () => readStickyEntry(stickyFile),
|
|
1076
1137
|
writeSticky: (entry) => writeStickyEntry(stickyFile, entry, logger),
|
|
@@ -1172,17 +1233,7 @@ export function startUsagePoll(params) {
|
|
|
1172
1233
|
}
|
|
1173
1234
|
},
|
|
1174
1235
|
readHealth: (id) => {
|
|
1175
|
-
|
|
1176
|
-
try {
|
|
1177
|
-
shim = parseStoredState(readFileSync(healthStateFile(id), "utf8"));
|
|
1178
|
-
if (!shim)
|
|
1179
|
-
throw new Error("not valid health-state JSON");
|
|
1180
|
-
}
|
|
1181
|
-
catch (err) {
|
|
1182
|
-
if (err.code !== "ENOENT")
|
|
1183
|
-
throw err;
|
|
1184
|
-
shim = undefined;
|
|
1185
|
-
}
|
|
1236
|
+
const shim = readHealthStateFile(healthStateFile(id), { strictSnapshot: true });
|
|
1186
1237
|
let usage;
|
|
1187
1238
|
try {
|
|
1188
1239
|
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
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { resumeSessionId } from "./session-handover.js";
|
|
5
|
+
export const SESSION_AFFINITY_ENV = "MULTI_CLAWD_SESSION_AFFINITY";
|
|
6
|
+
export const DEFAULT_SESSION_AFFINITY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
export const MAX_SESSION_PINS = 1000;
|
|
8
|
+
export function launchSessionId(argv) {
|
|
9
|
+
const resumed = resumeSessionId(argv);
|
|
10
|
+
if (resumed)
|
|
11
|
+
return resumed;
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const arg = argv[i];
|
|
14
|
+
const value = arg === "--session-id" ? argv[i + 1]
|
|
15
|
+
: arg.startsWith("--session-id=") ? arg.slice("--session-id=".length) : undefined;
|
|
16
|
+
if (value !== undefined)
|
|
17
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value) ? value : undefined;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export function eligibleSessionAccount(pin, currentAccount, verdicts, availableAccounts) {
|
|
22
|
+
if (!pin)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (!verdicts.some((v) => v.id === pin.account &&
|
|
25
|
+
(v.verdict === "ok" || v.verdict === "no_data")))
|
|
26
|
+
return undefined;
|
|
27
|
+
return pin.account === currentAccount || availableAccounts.includes(pin.account)
|
|
28
|
+
? pin.account : undefined;
|
|
29
|
+
}
|
|
30
|
+
const WRITE_CLOCK_SLACK_MS = 60_000;
|
|
31
|
+
function readPins(file, now, ttlMs, futureSlackMs = 0) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
34
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
35
|
+
return {};
|
|
36
|
+
const pins = {};
|
|
37
|
+
for (const [id, value] of Object.entries(parsed)) {
|
|
38
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id))
|
|
39
|
+
continue;
|
|
40
|
+
if (!value || typeof value !== "object")
|
|
41
|
+
continue;
|
|
42
|
+
const pin = value;
|
|
43
|
+
if (typeof pin.account !== "string" || !pin.account ||
|
|
44
|
+
typeof pin.servedAt !== "number" || !Number.isFinite(pin.servedAt) ||
|
|
45
|
+
pin.servedAt > now + futureSlackMs || now - pin.servedAt >= ttlMs)
|
|
46
|
+
continue;
|
|
47
|
+
pins[id] = { account: pin.account, servedAt: pin.servedAt };
|
|
48
|
+
}
|
|
49
|
+
return pins;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function readSessionPin(file, sessionId, now, ttlMs) {
|
|
56
|
+
return readPins(file, now, ttlMs)[sessionId];
|
|
57
|
+
}
|
|
58
|
+
export function recordSessionPin(file, sessionId, account, now, ttlMs) {
|
|
59
|
+
const lock = `${file}.lock`;
|
|
60
|
+
let locked = false;
|
|
61
|
+
let tmp;
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
64
|
+
const deadline = Date.now() + 1000;
|
|
65
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
66
|
+
while (!locked) {
|
|
67
|
+
try {
|
|
68
|
+
closeSync(openSync(lock, "wx", 0o600));
|
|
69
|
+
locked = true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
try {
|
|
73
|
+
if (Date.now() - statSync(lock).mtimeMs > 5000)
|
|
74
|
+
rmSync(lock, { force: true });
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
if (Date.now() >= deadline)
|
|
78
|
+
return;
|
|
79
|
+
Atomics.wait(wait, 0, 0, 10);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const pins = readPins(file, now, ttlMs, WRITE_CLOCK_SLACK_MS);
|
|
83
|
+
pins[sessionId] = { account, servedAt: now };
|
|
84
|
+
const entries = Object.entries(pins);
|
|
85
|
+
const bounded = entries.length > MAX_SESSION_PINS
|
|
86
|
+
? entries.sort((a, b) => b[1].servedAt - a[1].servedAt).slice(0, MAX_SESSION_PINS)
|
|
87
|
+
: entries;
|
|
88
|
+
tmp = `${file}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
89
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(bounded)), { mode: 0o600 });
|
|
90
|
+
renameSync(tmp, file);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (tmp) {
|
|
96
|
+
try {
|
|
97
|
+
rmSync(tmp, { force: true });
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
}
|
|
101
|
+
if (locked) {
|
|
102
|
+
try {
|
|
103
|
+
rmSync(lock, { force: true });
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|