@drakon-systems/multi-clawd 1.9.6 → 1.9.7
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 +4 -3
- package/dist/health.js +24 -6
- package/dist/index.js +16 -3
- package/dist/retry-plan.js +16 -7
- package/dist/session-handover.js +135 -1
- package/dist/shim-core.js +57 -1
- package/dist/shim.js +60 -7
- package/dist/sticky.js +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.mjs +57 -12
package/README.md
CHANGED
|
@@ -245,8 +245,9 @@ claude-cli/claude-fable-5 # main login
|
|
|
245
245
|
- 🔁 **In-turn retry on a reactive limit** — when an account hits a model cap
|
|
246
246
|
*during* a launch, the pool no longer loses that turn to the next provider in
|
|
247
247
|
your chain: the refusal is swallowed before anything reaches you and the turn
|
|
248
|
-
is re-spawned on a healthy account.
|
|
249
|
-
|
|
248
|
+
is re-spawned on a healthy account. Covers ongoing conversations too: the
|
|
249
|
+
session is handed to the sibling as it stood before the refused attempt.
|
|
250
|
+
Secret-free sibling accounts, one retry.
|
|
250
251
|
- 📟 **Operator alerts (v0.3)** — dead logins (probed every 15 min without
|
|
251
252
|
spending quota), pool rotations, whole-pool exhaustion, and watchdog
|
|
252
253
|
restarts surface through your agent's next heartbeat (e.g. straight into
|
|
@@ -375,7 +376,7 @@ openclaw plugins install (Get-Location).Path
|
|
|
375
376
|
**Or let your agent install it.** Running an OpenClaw assistant or Claude
|
|
376
377
|
Code on the target machine already? Paste it this and go make coffee:
|
|
377
378
|
|
|
378
|
-
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.
|
|
379
|
+
> Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.7/SETUP-AGENT.md
|
|
379
380
|
> and follow it to set up multi-clawd on this machine. I own a second
|
|
380
381
|
> Claude account — ask me when you need me to log in.
|
|
381
382
|
|
package/dist/health.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { modelWindowKey } from "./shim-core.js";
|
|
1
|
+
import { modelWindowApplies, modelWindowKey } from "./shim-core.js";
|
|
2
2
|
const DEFAULT_UTILIZATION_THRESHOLD = 0.85;
|
|
3
3
|
const DEFAULT_STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
|
4
4
|
export const MODEL_REJECTED_TTL_MS = 60 * 60 * 1000;
|
|
@@ -78,8 +78,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
78
78
|
if (!modelFresh)
|
|
79
79
|
continue;
|
|
80
80
|
hasLiveEvidence = true;
|
|
81
|
-
|
|
82
|
-
if (!requestedWindowKey || canonicalWindow !== requestedWindowKey)
|
|
81
|
+
if (requestedModel === undefined || !modelWindowApplies(window, w, requestedModel))
|
|
83
82
|
continue;
|
|
84
83
|
if (w.status !== "rejected")
|
|
85
84
|
continue;
|
|
@@ -88,6 +87,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
88
87
|
return {
|
|
89
88
|
verdict: "exhausted",
|
|
90
89
|
resumeAt: resetMs,
|
|
90
|
+
observedAt: w.seenAt,
|
|
91
91
|
reason: `${requestedModel} limit rejected until ${new Date(resetMs).toISOString()}`,
|
|
92
92
|
};
|
|
93
93
|
}
|
|
@@ -96,6 +96,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
96
96
|
return {
|
|
97
97
|
verdict: "exhausted",
|
|
98
98
|
resumeAt: w.seenAt + MODEL_REJECTED_TTL_MS,
|
|
99
|
+
observedAt: w.seenAt,
|
|
99
100
|
reason: `${requestedModel} limit hit ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; TTL block)`,
|
|
100
101
|
};
|
|
101
102
|
}
|
|
@@ -114,6 +115,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
114
115
|
return {
|
|
115
116
|
verdict: "exhausted",
|
|
116
117
|
resumeAt: resetMs,
|
|
118
|
+
observedAt: w.seenAt,
|
|
117
119
|
reason: `${window} rejected until ${new Date(resetMs).toISOString()}`,
|
|
118
120
|
};
|
|
119
121
|
}
|
|
@@ -125,6 +127,7 @@ export function classifyAccountHealth(state, options, nowMs, requestedModel) {
|
|
|
125
127
|
return {
|
|
126
128
|
verdict: "exhausted",
|
|
127
129
|
resumeAt: w.seenAt + resetLessBlockMs(window),
|
|
130
|
+
observedAt: w.seenAt,
|
|
128
131
|
reason: `${window} rejected ${Math.round((nowMs - w.seenAt) / 60000)}m ago (no reset time; ${Math.round(resetLessBlockMs(window) / 3600000)}h block)`,
|
|
129
132
|
};
|
|
130
133
|
}
|
|
@@ -200,9 +203,24 @@ export function choosePoolAccount(pool) {
|
|
|
200
203
|
export function allCredentialFailed(pool) {
|
|
201
204
|
return pool.length > 0 && pool.every((a) => a.verdict === "credential_failed");
|
|
202
205
|
}
|
|
203
|
-
export function
|
|
206
|
+
export function overdueProbeAccount(pool, nowMs) {
|
|
207
|
+
let oldest;
|
|
208
|
+
for (const a of pool) {
|
|
209
|
+
if (a.verdict !== "exhausted" || a.observedAt === undefined)
|
|
210
|
+
continue;
|
|
211
|
+
if (nowMs - a.observedAt <= REJECTION_REVALIDATE_AFTER_MS)
|
|
212
|
+
continue;
|
|
213
|
+
if (!oldest || a.observedAt < oldest.observedAt)
|
|
214
|
+
oldest = a;
|
|
215
|
+
}
|
|
216
|
+
return oldest?.id;
|
|
217
|
+
}
|
|
218
|
+
export function fallbackPoolAccount(pool, nowMs) {
|
|
219
|
+
const probe = nowMs === undefined ? undefined : overdueProbeAccount(pool, nowMs);
|
|
220
|
+
if (probe)
|
|
221
|
+
return probe;
|
|
204
222
|
return (pool.find((a) => a.verdict !== "credential_failed") ?? pool[0]).id;
|
|
205
223
|
}
|
|
206
|
-
export function pickPoolAccountForLaunch(pool) {
|
|
207
|
-
return choosePoolAccount(pool) ?? fallbackPoolAccount(pool);
|
|
224
|
+
export function pickPoolAccountForLaunch(pool, nowMs) {
|
|
225
|
+
return choosePoolAccount(pool) ?? fallbackPoolAccount(pool, nowMs);
|
|
208
226
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ 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 } from "./health.js";
|
|
10
|
+
import { allCredentialFailed, classifyAccountHealth, overdueProbeAccount, } from "./health.js";
|
|
11
11
|
import { decideStickySelection } from "./sticky.js";
|
|
12
12
|
import { mergeHealthStates, parseStoredState, recordCredentialFailure, } from "./shim-core.js";
|
|
13
13
|
import { createTokenRefResolver, isSecretRefShape, } from "./token-resolution.js";
|
|
@@ -672,15 +672,28 @@ export function registerPoolBackend(api, pool, accounts, registeredIds, execMode
|
|
|
672
672
|
throw new Error(`[multi-clawd] ${text}`);
|
|
673
673
|
}
|
|
674
674
|
const previousSticky = readStickyEntry(stickyFile);
|
|
675
|
+
const poolVerdicts = verdicts.map((v) => ({
|
|
676
|
+
id: v.id,
|
|
677
|
+
verdict: v.health.verdict,
|
|
678
|
+
observedAt: v.health.observedAt,
|
|
679
|
+
}));
|
|
675
680
|
const decision = decideStickySelection({
|
|
676
|
-
verdicts:
|
|
681
|
+
verdicts: poolVerdicts,
|
|
677
682
|
sticky: previousSticky,
|
|
678
683
|
nowMs: now,
|
|
679
684
|
minDwellMs,
|
|
680
685
|
});
|
|
681
686
|
const chosen = members.find((a) => a.id === decision.account) ?? members[0];
|
|
682
687
|
const previousAccount = previousSticky?.account ?? members[0].id;
|
|
683
|
-
|
|
688
|
+
const probing = overdueProbeAccount(poolVerdicts, now) === decision.account &&
|
|
689
|
+
poolVerdicts.every((v) => v.verdict === "exhausted" || v.verdict === "credential_failed");
|
|
690
|
+
if (probing) {
|
|
691
|
+
const observedAt = poolVerdicts.find((v) => v.id === decision.account)?.observedAt ?? now;
|
|
692
|
+
logger.info(`[multi-clawd] pool ${poolId}: every account is exhausted for ${requestedModel} — ` +
|
|
693
|
+
`launching on ${decision.account} to re-test a rejection last seen ` +
|
|
694
|
+
`${Math.round((now - observedAt) / 3600000)}h ago`);
|
|
695
|
+
}
|
|
696
|
+
else if (decision.account !== previousAccount) {
|
|
684
697
|
const home = verdicts[0];
|
|
685
698
|
const line = decision.account === members[0].id
|
|
686
699
|
? `pool ${poolId}: returning home to ${decision.account}`
|
package/dist/retry-plan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { classifyAccountHealth } from "./health.js";
|
|
1
|
+
import { classifyAccountHealth, overdueProbeAccount, } from "./health.js";
|
|
2
|
+
import { resumeSessionId } from "./session-handover.js";
|
|
2
3
|
export const RETRY_ROSTER_ENV = "MULTI_CLAWD_RETRY_ACCOUNTS";
|
|
3
4
|
const CREDENTIAL_ENV_KEYS = ["CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
4
5
|
export function parseRetryRoster(raw) {
|
|
@@ -33,14 +34,19 @@ export function parseRetryRoster(raw) {
|
|
|
33
34
|
}
|
|
34
35
|
return out;
|
|
35
36
|
}
|
|
36
|
-
export function
|
|
37
|
+
export function isResumeLaunch(argv) {
|
|
38
|
+
return (resumeSessionId(argv) !== undefined ||
|
|
39
|
+
argv.some((a) => a === "--resume" || a === "-r" || a.startsWith("--resume=")));
|
|
40
|
+
}
|
|
41
|
+
export function retryArming(argv, roster, opts = {}) {
|
|
37
42
|
if (roster.length === 0) {
|
|
38
43
|
return { armed: false, reason: "no secret-free sibling account to retry onto" };
|
|
39
44
|
}
|
|
40
|
-
if (argv.
|
|
45
|
+
if (isResumeLaunch(argv) && !opts.resumeReady) {
|
|
41
46
|
return {
|
|
42
47
|
armed: false,
|
|
43
|
-
reason: "resumed session — its
|
|
48
|
+
reason: "resumed session — its transcript is not in this account's config dir in a state " +
|
|
49
|
+
"that can be handed to a sibling",
|
|
44
50
|
};
|
|
45
51
|
}
|
|
46
52
|
if (!argv.some((a) => a === "stream-json" || a === "--output-format=stream-json")) {
|
|
@@ -49,13 +55,16 @@ export function retryArming(argv, roster) {
|
|
|
49
55
|
return { armed: true };
|
|
50
56
|
}
|
|
51
57
|
export function chooseRetryAccount(params) {
|
|
58
|
+
const verdicts = [];
|
|
52
59
|
for (const account of params.roster) {
|
|
53
60
|
const state = params.readState(account.stateFile);
|
|
54
|
-
const
|
|
55
|
-
if (verdict === "ok" || verdict === "no_data")
|
|
61
|
+
const health = classifyAccountHealth(state, params.options ?? {}, params.nowMs, params.modelId);
|
|
62
|
+
if (health.verdict === "ok" || health.verdict === "no_data")
|
|
56
63
|
return account;
|
|
64
|
+
verdicts.push({ id: account.id, verdict: health.verdict, observedAt: health.observedAt });
|
|
57
65
|
}
|
|
58
|
-
|
|
66
|
+
const probe = overdueProbeAccount(verdicts, params.nowMs);
|
|
67
|
+
return probe ? params.roster.find((a) => a.id === probe) : undefined;
|
|
59
68
|
}
|
|
60
69
|
export function buildRetryEnv(base, account) {
|
|
61
70
|
const env = { ...base };
|
package/dist/session-handover.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, copyFileSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { basename, dirname, join } from "node:path";
|
|
4
5
|
export const SESSION_DIRS_ENV = "MULTI_CLAWD_SESSION_DIRS";
|
|
@@ -115,3 +116,136 @@ export function handoverForLaunch(argv, env, deps = {}) {
|
|
|
115
116
|
return `resume handover failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
const FINGERPRINT_HEAD_BYTES = 64 * 1024;
|
|
120
|
+
const FINGERPRINT_TAIL_BYTES = 1024 * 1024;
|
|
121
|
+
function readRegion(fd, position, length) {
|
|
122
|
+
const buf = Buffer.alloc(length);
|
|
123
|
+
let got = 0;
|
|
124
|
+
while (got < length) {
|
|
125
|
+
const n = readSync(fd, buf, got, length - got, position + got);
|
|
126
|
+
if (n <= 0)
|
|
127
|
+
throw new Error("the transcript ended before the snapshot point");
|
|
128
|
+
got += n;
|
|
129
|
+
}
|
|
130
|
+
return buf;
|
|
131
|
+
}
|
|
132
|
+
export function fingerprintPrefix(path, bytes) {
|
|
133
|
+
const fd = openSync(path, "r");
|
|
134
|
+
try {
|
|
135
|
+
const hash = createHash("sha256");
|
|
136
|
+
hash.update(`${bytes}:`);
|
|
137
|
+
const head = Math.min(FINGERPRINT_HEAD_BYTES, bytes);
|
|
138
|
+
hash.update(readRegion(fd, 0, head));
|
|
139
|
+
const tail = Math.min(FINGERPRINT_TAIL_BYTES, bytes - head);
|
|
140
|
+
if (tail > 0)
|
|
141
|
+
hash.update(readRegion(fd, bytes - tail, tail));
|
|
142
|
+
return hash.digest("hex");
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const NEWLINE = 0x0a;
|
|
149
|
+
function lastByte(path, length) {
|
|
150
|
+
if (length <= 0)
|
|
151
|
+
return undefined;
|
|
152
|
+
const fd = openSync(path, "r");
|
|
153
|
+
try {
|
|
154
|
+
const buf = Buffer.alloc(1);
|
|
155
|
+
return readSync(fd, buf, 0, 1, length - 1) === 1 ? buf[0] : undefined;
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export function snapshotResumeTranscript(argv, env) {
|
|
162
|
+
try {
|
|
163
|
+
const sessionId = resumeSessionId(argv);
|
|
164
|
+
if (!sessionId)
|
|
165
|
+
return undefined;
|
|
166
|
+
const projects = join(effectiveConfigDir(env), "projects");
|
|
167
|
+
let newest;
|
|
168
|
+
for (const sub of nodeHandoverFs.listDirs(projects)) {
|
|
169
|
+
const path = join(projects, sub, `${sessionId}.jsonl`);
|
|
170
|
+
let st;
|
|
171
|
+
try {
|
|
172
|
+
st = statSync(path);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!st.isFile())
|
|
178
|
+
continue;
|
|
179
|
+
if (!newest || st.mtimeMs > newest.mtime) {
|
|
180
|
+
newest = { sub, path, mtime: st.mtimeMs, bytes: st.size };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!newest || newest.bytes === 0)
|
|
184
|
+
return undefined;
|
|
185
|
+
if (lastByte(newest.path, newest.bytes) !== NEWLINE)
|
|
186
|
+
return undefined;
|
|
187
|
+
return {
|
|
188
|
+
sessionId,
|
|
189
|
+
sub: newest.sub,
|
|
190
|
+
path: newest.path,
|
|
191
|
+
bytes: newest.bytes,
|
|
192
|
+
fingerprint: fingerprintPrefix(newest.path, newest.bytes),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const COPY_CHUNK_BYTES = 1024 * 1024;
|
|
200
|
+
export function handoverSnapshotTo(snapshot, targetConfigDir) {
|
|
201
|
+
const size = statSync(snapshot.path).size;
|
|
202
|
+
if (size < snapshot.bytes) {
|
|
203
|
+
throw new Error("the transcript is shorter than when the launch began");
|
|
204
|
+
}
|
|
205
|
+
if (lastByte(snapshot.path, snapshot.bytes) !== NEWLINE) {
|
|
206
|
+
throw new Error("the transcript no longer ends on a record boundary at the snapshot point");
|
|
207
|
+
}
|
|
208
|
+
const to = join(targetConfigDir, "projects", snapshot.sub, `${snapshot.sessionId}.jsonl`);
|
|
209
|
+
if (to === snapshot.path)
|
|
210
|
+
throw new Error("the sibling shares this account's config dir");
|
|
211
|
+
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
|
212
|
+
const tmp = join(dirname(to), `.${basename(to)}.handover-${process.pid}`);
|
|
213
|
+
let src;
|
|
214
|
+
let dst;
|
|
215
|
+
try {
|
|
216
|
+
src = openSync(snapshot.path, "r");
|
|
217
|
+
dst = openSync(tmp, "w", 0o600);
|
|
218
|
+
const buf = Buffer.alloc(Math.min(COPY_CHUNK_BYTES, snapshot.bytes));
|
|
219
|
+
let copied = 0;
|
|
220
|
+
while (copied < snapshot.bytes) {
|
|
221
|
+
const want = Math.min(buf.length, snapshot.bytes - copied);
|
|
222
|
+
const got = readSync(src, buf, 0, want, copied);
|
|
223
|
+
if (got <= 0)
|
|
224
|
+
throw new Error("the transcript ended before the snapshot point");
|
|
225
|
+
writeSync(dst, buf, 0, got);
|
|
226
|
+
copied += got;
|
|
227
|
+
}
|
|
228
|
+
closeSync(dst);
|
|
229
|
+
dst = undefined;
|
|
230
|
+
if (fingerprintPrefix(tmp, snapshot.bytes) !== snapshot.fingerprint) {
|
|
231
|
+
throw new Error("the transcript was rewritten after the launch began");
|
|
232
|
+
}
|
|
233
|
+
renameSync(tmp, to);
|
|
234
|
+
return to;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
if (dst !== undefined) {
|
|
238
|
+
try {
|
|
239
|
+
closeSync(dst);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
rmSync(tmp, { force: true });
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
if (src !== undefined)
|
|
249
|
+
closeSync(src);
|
|
250
|
+
}
|
|
251
|
+
}
|
package/dist/shim-core.js
CHANGED
|
@@ -90,6 +90,7 @@ export function parseStoredState(raw) {
|
|
|
90
90
|
seenAt: w.seenAt,
|
|
91
91
|
rawInfo: typeof w.rawInfo === "string" ? w.rawInfo : undefined,
|
|
92
92
|
model: typeof w.model === "string" ? w.model : undefined,
|
|
93
|
+
family: typeof w.family === "string" ? w.family : undefined,
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
let credential;
|
|
@@ -178,6 +179,16 @@ export function canonicalizeModelWindowKeys(windows) {
|
|
|
178
179
|
}
|
|
179
180
|
return out;
|
|
180
181
|
}
|
|
182
|
+
const MODEL_FAMILY_RE = /^claude-([a-z]+)-/;
|
|
183
|
+
export function modelFamily(modelId) {
|
|
184
|
+
return MODEL_FAMILY_RE.exec(canonicalizeModelIdForWindow(modelId))?.[1];
|
|
185
|
+
}
|
|
186
|
+
export function limitFamilyFor(displayName, modelId) {
|
|
187
|
+
const family = modelFamily(modelId);
|
|
188
|
+
if (!family)
|
|
189
|
+
return undefined;
|
|
190
|
+
return displayName.trim().toLowerCase() === family ? family : undefined;
|
|
191
|
+
}
|
|
181
192
|
const LIMIT_TEXT_RE = /reached your (.{1,40}?) limit/i;
|
|
182
193
|
export function parseModelLimitError(line) {
|
|
183
194
|
if (!/reached your /i.test(line))
|
|
@@ -214,7 +225,7 @@ export function parseModelLimitError(line) {
|
|
|
214
225
|
}
|
|
215
226
|
return undefined;
|
|
216
227
|
}
|
|
217
|
-
export function recordModelLimit(state, modelId, now, resetsAt) {
|
|
228
|
+
export function recordModelLimit(state, modelId, now, resetsAt, family) {
|
|
218
229
|
return {
|
|
219
230
|
...state,
|
|
220
231
|
updatedAt: now,
|
|
@@ -224,10 +235,55 @@ export function recordModelLimit(state, modelId, now, resetsAt) {
|
|
|
224
235
|
status: "rejected",
|
|
225
236
|
resetsAt,
|
|
226
237
|
seenAt: now,
|
|
238
|
+
...(family ? { family } : {}),
|
|
227
239
|
},
|
|
228
240
|
},
|
|
229
241
|
};
|
|
230
242
|
}
|
|
243
|
+
export function modelWindowApplies(windowKey, w, requestedModel) {
|
|
244
|
+
if (!windowKey.startsWith("model:"))
|
|
245
|
+
return false;
|
|
246
|
+
if (modelWindowKey(windowKey.slice("model:".length)) === modelWindowKey(requestedModel)) {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
return w.family !== undefined && w.family === modelFamily(requestedModel);
|
|
250
|
+
}
|
|
251
|
+
export function clearModelRejection(state, modelId, now) {
|
|
252
|
+
const windows = { ...state.windows };
|
|
253
|
+
let changed = false;
|
|
254
|
+
for (const [key, w] of Object.entries(state.windows)) {
|
|
255
|
+
if (w.status !== "rejected")
|
|
256
|
+
continue;
|
|
257
|
+
if (!modelWindowApplies(key, w, modelId))
|
|
258
|
+
continue;
|
|
259
|
+
windows[key] = { status: "allowed", seenAt: now };
|
|
260
|
+
changed = true;
|
|
261
|
+
}
|
|
262
|
+
if (!changed)
|
|
263
|
+
return { state, changed };
|
|
264
|
+
return { state: { ...state, updatedAt: now, windows }, changed };
|
|
265
|
+
}
|
|
266
|
+
export function parseSuccessResult(line) {
|
|
267
|
+
if (!line.includes('"result"'))
|
|
268
|
+
return false;
|
|
269
|
+
let record;
|
|
270
|
+
try {
|
|
271
|
+
record = JSON.parse(line);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (typeof record !== "object" || record === null)
|
|
277
|
+
return false;
|
|
278
|
+
const r = record;
|
|
279
|
+
if (r.type !== "result")
|
|
280
|
+
return false;
|
|
281
|
+
if (r.is_error === true)
|
|
282
|
+
return false;
|
|
283
|
+
if (typeof r.subtype === "string" && r.subtype.startsWith("error"))
|
|
284
|
+
return false;
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
231
287
|
const AUTH_FAILURE_PATTERNS = [
|
|
232
288
|
/oauth (session|token) (has )?(expired|been revoked)/i,
|
|
233
289
|
/failed to authenticate/i,
|
package/dist/shim.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { classifyStateReadFailure, clearCredentialFailure, createLineScanner, mergeHealthStates, parseAuthFailure, parseRateLimitEvent, parseStoredState, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
|
|
4
|
+
import { classifyStateReadFailure, clearCredentialFailure, clearModelRejection, createLineScanner, limitFamilyFor, mergeHealthStates, parseAuthFailure, parseRateLimitEvent, parseStoredState, parseSuccessResult, recordCredentialFailure, updateHealthState, } from "./shim-core.js";
|
|
5
5
|
import { rewriteModelArg } from "./degrade.js";
|
|
6
6
|
import { parseModelLimitError, recordModelLimit } from "./shim-core.js";
|
|
7
7
|
import { canonicalModelId } from "./models.js";
|
|
8
8
|
import { buildRetryEnv, chooseRetryAccount, parseRetryRoster, retryArming, RETRY_ROSTER_ENV, } from "./retry-plan.js";
|
|
9
|
-
import { handoverForLaunch } from "./session-handover.js";
|
|
9
|
+
import { effectiveConfigDir, handoverForLaunch, handoverSnapshotTo, parseSessionDirs, SESSION_DIRS_ENV, snapshotResumeTranscript, } from "./session-handover.js";
|
|
10
10
|
function resolveClaudeCommand() {
|
|
11
11
|
const override = process.env.MULTI_CLAWD_CLAUDE_BIN;
|
|
12
12
|
if (override) {
|
|
@@ -95,7 +95,10 @@ if (modelOverride) {
|
|
|
95
95
|
process.stderr.write(`[multi-clawd shim] ${note}\n`);
|
|
96
96
|
}
|
|
97
97
|
const retryRoster = parseRetryRoster(process.env[RETRY_ROSTER_ENV]);
|
|
98
|
-
const
|
|
98
|
+
const resumeSnapshot = retryRoster.length > 0 && parseSessionDirs(process.env[SESSION_DIRS_ENV]).length > 0
|
|
99
|
+
? snapshotResumeTranscript(childArgs, process.env)
|
|
100
|
+
: undefined;
|
|
101
|
+
const arming = retryArming(childArgs, retryRoster, { resumeReady: resumeSnapshot !== undefined });
|
|
99
102
|
let retryArmed = arming.armed;
|
|
100
103
|
if (!retryArmed && retryRoster.length > 0 && arming.reason) {
|
|
101
104
|
process.stderr.write(`[multi-clawd shim] in-turn retry unavailable: ${arming.reason}\n`);
|
|
@@ -148,8 +151,12 @@ function guessLimitResetsAt() {
|
|
|
148
151
|
return undefined;
|
|
149
152
|
}
|
|
150
153
|
let sawAuthFailure = false;
|
|
154
|
+
let sawModelLimit = false;
|
|
155
|
+
let sawSuccessResult = false;
|
|
151
156
|
function observeLine(line) {
|
|
152
157
|
try {
|
|
158
|
+
if (parseSuccessResult(line))
|
|
159
|
+
sawSuccessResult = true;
|
|
153
160
|
const event = parseRateLimitEvent(line);
|
|
154
161
|
if (event) {
|
|
155
162
|
state = updateHealthState(state, event, Date.now(), effectiveModelId());
|
|
@@ -157,11 +164,14 @@ function observeLine(line) {
|
|
|
157
164
|
}
|
|
158
165
|
const limitHit = parseModelLimitError(line);
|
|
159
166
|
if (limitHit) {
|
|
167
|
+
sawModelLimit = true;
|
|
160
168
|
const model = effectiveModelId();
|
|
161
169
|
if (model) {
|
|
162
|
-
|
|
170
|
+
const family = limitFamilyFor(limitHit.displayName, model);
|
|
171
|
+
state = recordModelLimit(state, model, Date.now(), guessLimitResetsAt(), family);
|
|
163
172
|
persistState();
|
|
164
|
-
process.stderr.write(`[multi-clawd shim] model limit hit recorded: ${model} (reported as "${limitHit.displayName}"
|
|
173
|
+
process.stderr.write(`[multi-clawd shim] model limit hit recorded: ${model} (reported as "${limitHit.displayName}"` +
|
|
174
|
+
`${family ? `, applies to every ${family} model` : ""})\n`);
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
177
|
const authFailure = parseAuthFailure(line);
|
|
@@ -235,10 +245,23 @@ function attemptRetry() {
|
|
|
235
245
|
releaseHeldOutput();
|
|
236
246
|
return false;
|
|
237
247
|
}
|
|
248
|
+
if (resumeSnapshot) {
|
|
249
|
+
try {
|
|
250
|
+
handoverSnapshotTo(resumeSnapshot, effectiveConfigDir(target.env));
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
process.stderr.write(`[multi-clawd shim] model limit on ${accountId}, but session ` +
|
|
254
|
+
`${resumeSnapshot.sessionId.slice(0, 8)} could not be handed to ${target.id} ` +
|
|
255
|
+
`(${err instanceof Error ? err.message : String(err)}) — ` +
|
|
256
|
+
`passing the failure through to the host's chain\n`);
|
|
257
|
+
releaseHeldOutput();
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
238
261
|
retryUsed = true;
|
|
239
262
|
retryArmed = false;
|
|
240
263
|
process.stderr.write(`[multi-clawd shim] model limit on ${accountId}${model ? ` for ${model}` : ""} — ` +
|
|
241
|
-
`retrying this turn on ${target.id} (nothing forwarded downstream yet)\n`);
|
|
264
|
+
`retrying this turn on ${target.id}${resumeSnapshot ? `, session ${resumeSnapshot.sessionId.slice(0, 8)} handed over` : ""} (nothing forwarded downstream yet)\n`);
|
|
242
265
|
held = [];
|
|
243
266
|
heldBytes = 0;
|
|
244
267
|
holdTail = "";
|
|
@@ -255,6 +278,8 @@ function attemptRetry() {
|
|
|
255
278
|
stateFile = target.stateFile;
|
|
256
279
|
state = { accountId, windows: {} };
|
|
257
280
|
sawAuthFailure = false;
|
|
281
|
+
sawModelLimit = false;
|
|
282
|
+
sawSuccessResult = false;
|
|
258
283
|
child = spawn(command, childArgs, {
|
|
259
284
|
stdio: ["pipe", "pipe", "inherit"],
|
|
260
285
|
env: buildRetryEnv(process.env, target),
|
|
@@ -340,13 +365,41 @@ function clearRecordedAuthFailureOnSuccess() {
|
|
|
340
365
|
catch {
|
|
341
366
|
}
|
|
342
367
|
}
|
|
368
|
+
function clearRecordedModelRejectionOnSuccess() {
|
|
369
|
+
if (!stateFile || !sawSuccessResult || sawModelLimit)
|
|
370
|
+
return;
|
|
371
|
+
try {
|
|
372
|
+
const model = effectiveModelId();
|
|
373
|
+
if (!model)
|
|
374
|
+
return;
|
|
375
|
+
const disk = readPersistedState();
|
|
376
|
+
if (!disk)
|
|
377
|
+
return;
|
|
378
|
+
const now = Date.now();
|
|
379
|
+
const cleared = clearModelRejection(disk, model, now);
|
|
380
|
+
if (!cleared.changed)
|
|
381
|
+
return;
|
|
382
|
+
const windows = { ...state.windows };
|
|
383
|
+
for (const [key, w] of Object.entries(cleared.state.windows)) {
|
|
384
|
+
if (disk.windows[key] !== w)
|
|
385
|
+
windows[key] = w;
|
|
386
|
+
}
|
|
387
|
+
state = { ...state, updatedAt: now, windows };
|
|
388
|
+
persistState();
|
|
389
|
+
process.stderr.write(`[multi-clawd shim] ${model} served by ${accountId} — model rejection cleared\n`);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
}
|
|
393
|
+
}
|
|
343
394
|
function onChildClose(code, signal) {
|
|
344
395
|
if (signal) {
|
|
345
396
|
process.kill(process.pid, signal);
|
|
346
397
|
return;
|
|
347
398
|
}
|
|
348
|
-
if ((code ?? 0) === 0)
|
|
399
|
+
if ((code ?? 0) === 0) {
|
|
349
400
|
clearRecordedAuthFailureOnSuccess();
|
|
401
|
+
clearRecordedModelRejectionOnSuccess();
|
|
402
|
+
}
|
|
350
403
|
process.exit(code ?? 0);
|
|
351
404
|
}
|
|
352
405
|
function onChildError(err) {
|
package/dist/sticky.js
CHANGED
|
@@ -6,7 +6,7 @@ export function decideStickySelection(params) {
|
|
|
6
6
|
const home = verdicts[0];
|
|
7
7
|
const healthChoice = choosePoolAccount(verdicts);
|
|
8
8
|
if (!healthChoice)
|
|
9
|
-
return { account: fallbackPoolAccount(verdicts) };
|
|
9
|
+
return { account: fallbackPoolAccount(verdicts, nowMs) };
|
|
10
10
|
const stickyVerdict = sticky
|
|
11
11
|
? verdicts.find((v) => v.id === sticky.account)?.verdict
|
|
12
12
|
: undefined;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "multi-clawd",
|
|
3
3
|
"name": "multi-clawd",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.7",
|
|
5
5
|
"description": "Register additional Claude Code logins (Max/Pro accounts) as first-class OpenClaw CLI backends for cross-account failover, keeping the full skills/MCP harness on every account.",
|
|
6
6
|
"cliBackends": [
|
|
7
7
|
"claw1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drakon-systems/multi-clawd",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.7",
|
|
4
4
|
"description": "Multi-account Claude Code failover for OpenClaw — register additional Claude (Max/Pro) logins as first-class CLI backends and keep the full skills/MCP harness across every account. Also imports those accounts' setup tokens into Hermes Agent's Anthropic credential pool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/scripts/doctor.mjs
CHANGED
|
@@ -284,9 +284,10 @@ const { checkAccountCredential, keychainServiceForConfigDir } = await importDist
|
|
|
284
284
|
"checkAccountCredential",
|
|
285
285
|
"keychainServiceForConfigDir",
|
|
286
286
|
]);
|
|
287
|
-
const { summarizeWindowUsage, classifyAccountHealth } = await importDist("health.js", [
|
|
287
|
+
const { summarizeWindowUsage, classifyAccountHealth, overdueProbeAccount } = await importDist("health.js", [
|
|
288
288
|
"summarizeWindowUsage",
|
|
289
289
|
"classifyAccountHealth",
|
|
290
|
+
"overdueProbeAccount",
|
|
290
291
|
]);
|
|
291
292
|
// "cli", like the chain audits: resolving WHICH login backs an account is
|
|
292
293
|
// doctor's own diagnostic, not a description of the running plugin's
|
|
@@ -458,17 +459,15 @@ else {
|
|
|
458
459
|
// sticky can be about to expire, and health outranks both.
|
|
459
460
|
if (members.length > 0) {
|
|
460
461
|
const now = Date.now();
|
|
462
|
+
const healthOptions = {
|
|
463
|
+
staleAfterMs: pool.staleAfterMs,
|
|
464
|
+
utilizationThreshold: pool.utilizationThreshold,
|
|
465
|
+
rotateOnOverage: pool.rotateOnOverage,
|
|
466
|
+
};
|
|
461
467
|
const verdicts = members.map((id) => ({
|
|
462
468
|
id,
|
|
463
|
-
verdict: classifyAccountHealth(
|
|
464
|
-
|
|
465
|
-
{
|
|
466
|
-
staleAfterMs: pool.staleAfterMs,
|
|
467
|
-
utilizationThreshold: pool.utilizationThreshold,
|
|
468
|
-
rotateOnOverage: pool.rotateOnOverage,
|
|
469
|
-
},
|
|
470
|
-
now,
|
|
471
|
-
).verdict,
|
|
469
|
+
verdict: classifyAccountHealth(readJson(join(STATE_DIR, `${id}.json`)), healthOptions, now)
|
|
470
|
+
.verdict,
|
|
472
471
|
}));
|
|
473
472
|
const decision = decideStickySelection({
|
|
474
473
|
verdicts,
|
|
@@ -499,8 +498,54 @@ else {
|
|
|
499
498
|
);
|
|
500
499
|
}
|
|
501
500
|
// The launch decision is model-aware (a 429 can bench one account for one
|
|
502
|
-
// model only); doctor has no model in hand, so
|
|
503
|
-
// answer.
|
|
501
|
+
// model only); doctor has no model in hand, so the line above is the
|
|
502
|
+
// account-wide answer. A model limit is therefore reported separately, per
|
|
503
|
+
// model, from the same classifier the launch uses — "health ok" beside an
|
|
504
|
+
// account that refuses every turn for one model read as a clean bill of
|
|
505
|
+
// health during the very outage it should have named (#26).
|
|
506
|
+
const limitedModels = new Set();
|
|
507
|
+
for (const id of members) {
|
|
508
|
+
const state = readJson(join(STATE_DIR, `${id}.json`));
|
|
509
|
+
for (const key of Object.keys(state?.windows ?? {})) {
|
|
510
|
+
if (key.startsWith("model:")) limitedModels.add(key.slice("model:".length));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const model of [...limitedModels].sort()) {
|
|
514
|
+
const perAccount = members.map((id) => {
|
|
515
|
+
const health = classifyAccountHealth(
|
|
516
|
+
readJson(join(STATE_DIR, `${id}.json`)),
|
|
517
|
+
healthOptions,
|
|
518
|
+
now,
|
|
519
|
+
model,
|
|
520
|
+
);
|
|
521
|
+
return { id, verdict: health.verdict, observedAt: health.observedAt, resumeAt: health.resumeAt };
|
|
522
|
+
});
|
|
523
|
+
const limited = perAccount.filter((v) => v.verdict === "exhausted");
|
|
524
|
+
if (limited.length === 0) continue;
|
|
525
|
+
const detail = limited
|
|
526
|
+
.map((v) => {
|
|
527
|
+
const ageMin = typeof v.observedAt === "number" ? Math.round((now - v.observedAt) / 60000) : undefined;
|
|
528
|
+
const seen =
|
|
529
|
+
ageMin === undefined
|
|
530
|
+
? ""
|
|
531
|
+
: `, seen ${ageMin < 90 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h`} ago`;
|
|
532
|
+
const until =
|
|
533
|
+
typeof v.resumeAt === "number" ? ` until ${new Date(v.resumeAt).toISOString()}` : "";
|
|
534
|
+
return `${v.id}${until}${seen}`;
|
|
535
|
+
})
|
|
536
|
+
.join("; ");
|
|
537
|
+
if (limited.length === perAccount.length) {
|
|
538
|
+
const probe = overdueProbeAccount(perAccount, now);
|
|
539
|
+
bad(
|
|
540
|
+
`${model}: EVERY account is limited (${detail}) — turns for this model fall through to ` +
|
|
541
|
+
`the host's chain${
|
|
542
|
+
probe ? `; the next launch re-tests ${probe}, whose rejection is the oldest` : ""
|
|
543
|
+
}`,
|
|
544
|
+
);
|
|
545
|
+
} else {
|
|
546
|
+
warn(`${model}: limited on ${detail} — served by the remaining account(s)`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
504
549
|
if (VERBOSE) {
|
|
505
550
|
note(`account-wide verdicts: ${verdicts.map((v) => `${v.id}:${v.verdict}`).join(" ")}`);
|
|
506
551
|
}
|