@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.
@@ -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" ? "reset-aware" : "ordered";
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;
@@ -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
- let state;
191
- try {
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(readStateFileLenient(healthStateFile(accountId)), readStateFileLenient(usageStateFile(accountId)));
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 members = memberIds
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 < 2 && !(members.length === 1 && ladder.length > 0)) {
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 poolVerdicts = verdicts.map((v) => ({
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: pool.strategy === "reset-aware"
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: pool.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(members.map(accountConfigDir))]);
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
- return preparedForLaunch(launch, ctx, env, transport);
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((m) => m.profileId),
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
- let shim;
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"));
@@ -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
+ }