@tecorb/agent-pool 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@tecorb/agent-pool`. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## 0.1.1 - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Automatic switching** no longer stops with "no other subscription can take over" when the other accounts are only Unknown. Claude Code reports usage only for the account it runs on, so an idle account is Unknown after `availability.maxAgeMinutes`. Such accounts are now tried after the Available ones, once their login check passes. An account whose last reported window is still at 100%, or that was left for a limit and has not reset since, stays out until its reset.
|
|
10
|
+
- When the chosen account's login check fails, the next account is tried, including Limited ones.
|
|
11
|
+
- After a full stop, Agent Pool checks again every 5 minutes and at the earliest known reset, and switches back to the first account that can serve again.
|
|
12
|
+
- A high-confidence authentication error now moves the work to the next account.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Events `autoswitch.candidates` (every account with its decision and why), `autoswitch.precheck` (which account would take over while the current one is Limited) and `autoswitch.retry`.
|
|
17
|
+
|
|
5
18
|
## 0.1.0 - 2026-10-15
|
|
6
19
|
|
|
7
20
|
The first release.
|
|
@@ -7419,7 +7419,11 @@ function readToken(dp) {
|
|
|
7419
7419
|
}
|
|
7420
7420
|
|
|
7421
7421
|
// ../daemon/src/autoswitch/policy.ts
|
|
7422
|
-
var TRIGGERS = [
|
|
7422
|
+
var TRIGGERS = [
|
|
7423
|
+
"LIMIT_REACHED",
|
|
7424
|
+
"SUBSCRIPTION_UNAVAILABLE",
|
|
7425
|
+
"AUTHENTICATION_ERROR"
|
|
7426
|
+
];
|
|
7423
7427
|
var REASONS = {
|
|
7424
7428
|
LIMIT_REACHED: "limit reached",
|
|
7425
7429
|
SUBSCRIPTION_UNAVAILABLE: "subscription unavailable",
|
|
@@ -7447,8 +7451,11 @@ function isFreshProvider(a) {
|
|
|
7447
7451
|
return a.source === "provider" && !a.stale;
|
|
7448
7452
|
}
|
|
7449
7453
|
function chooseCandidates(fromSubscriptionId, inPriorityOrder, now) {
|
|
7450
|
-
const
|
|
7451
|
-
|
|
7454
|
+
const tiers = {
|
|
7455
|
+
available: [],
|
|
7456
|
+
unverified: [],
|
|
7457
|
+
limited: []
|
|
7458
|
+
};
|
|
7452
7459
|
const skipped = [];
|
|
7453
7460
|
const resets = [];
|
|
7454
7461
|
for (const c of inPriorityOrder) {
|
|
@@ -7473,22 +7480,57 @@ function chooseCandidates(fromSubscriptionId, inPriorityOrder, now) {
|
|
|
7473
7480
|
continue;
|
|
7474
7481
|
}
|
|
7475
7482
|
const a = c.availability;
|
|
7483
|
+
const exhaustion = c.exhaustion ?? null;
|
|
7484
|
+
if (exhaustion !== null && a.state !== "AVAILABLE" && Date.parse(exhaustion.until) > now.getTime()) {
|
|
7485
|
+
skip(`${exhaustion.why} (until ${exhaustion.until})`);
|
|
7486
|
+
resets.push(exhaustion.until);
|
|
7487
|
+
continue;
|
|
7488
|
+
}
|
|
7476
7489
|
if (a.state === "AVAILABLE" && (isFreshProvider(a) || a.source === "estimated")) {
|
|
7477
|
-
available.push(c);
|
|
7490
|
+
tiers.available.push({ ...c, tier: "available" });
|
|
7491
|
+
} else if (a.state === "UNKNOWN") {
|
|
7492
|
+
tiers.unverified.push({ ...c, tier: "unverified" });
|
|
7478
7493
|
} else if (a.state === "LIMITED" && isFreshProvider(a)) {
|
|
7479
|
-
limited.push(c);
|
|
7494
|
+
tiers.limited.push({ ...c, tier: "limited" });
|
|
7480
7495
|
} else {
|
|
7481
|
-
skip(
|
|
7496
|
+
skip(`${a.state} (${a.source})`);
|
|
7482
7497
|
}
|
|
7483
7498
|
}
|
|
7484
7499
|
const earliest = resets.filter((r) => Date.parse(r) > now.getTime()).sort((x, y) => Date.parse(x) - Date.parse(y))[0];
|
|
7485
7500
|
return {
|
|
7486
|
-
ordered:
|
|
7487
|
-
limitedOnly: available.length === 0 && limited.length > 0,
|
|
7501
|
+
ordered: [...tiers.available, ...tiers.unverified, ...tiers.limited],
|
|
7502
|
+
limitedOnly: tiers.available.length === 0 && tiers.unverified.length === 0 && tiers.limited.length > 0,
|
|
7488
7503
|
earliestReset: earliest ?? null,
|
|
7489
7504
|
skipped
|
|
7490
7505
|
};
|
|
7491
7506
|
}
|
|
7507
|
+
function knownExhaustion(input) {
|
|
7508
|
+
const nowMs = input.now.getTime();
|
|
7509
|
+
const windows = (input.snapshot?.windows ?? []).flatMap((w) => {
|
|
7510
|
+
const resetMs = w.resetsAt === null ? Number.NaN : Date.parse(w.resetsAt);
|
|
7511
|
+
return w.usedPercentage === null || Number.isNaN(resetMs) || resetMs <= nowMs ? [] : [{ name: w.name, pct: w.usedPercentage, resetsAt: w.resetsAt, resetMs }];
|
|
7512
|
+
});
|
|
7513
|
+
const latestOf = (ws) => [...ws].sort((a, b) => b.resetMs - a.resetMs)[0];
|
|
7514
|
+
const full = latestOf(windows.filter((w) => w.pct >= 100));
|
|
7515
|
+
if (full !== void 0) {
|
|
7516
|
+
return {
|
|
7517
|
+
until: full.resetsAt,
|
|
7518
|
+
why: `the ${full.name} usage window was at ${Math.round(full.pct)}% when last reported`
|
|
7519
|
+
};
|
|
7520
|
+
}
|
|
7521
|
+
const away = input.lastAutoAway;
|
|
7522
|
+
if (away === null || away.category !== "LIMIT_REACHED") return null;
|
|
7523
|
+
const awayMs = Date.parse(away.at);
|
|
7524
|
+
const snapMs = input.snapshot === null ? Number.NaN : Date.parse(input.snapshot.observedAt);
|
|
7525
|
+
if (!Number.isNaN(snapMs) && snapMs > awayMs) return null;
|
|
7526
|
+
const high = latestOf(windows.filter((w) => w.pct >= input.limitedThreshold));
|
|
7527
|
+
const next = high ?? [...windows].sort((a, b) => a.resetMs - b.resetMs)[0];
|
|
7528
|
+
if (next === void 0) return null;
|
|
7529
|
+
return {
|
|
7530
|
+
until: next.resetsAt,
|
|
7531
|
+
why: `the limit was reached at ${away.at}; the ${next.name} usage window resets later`
|
|
7532
|
+
};
|
|
7533
|
+
}
|
|
7492
7534
|
function checkRateGuard(input) {
|
|
7493
7535
|
if (input.switchesLastHour >= input.maxPerSessionPerHour) {
|
|
7494
7536
|
return {
|
|
@@ -9066,6 +9108,7 @@ export {
|
|
|
9066
9108
|
evaluateTrigger,
|
|
9067
9109
|
canSwitchNow,
|
|
9068
9110
|
chooseCandidates,
|
|
9111
|
+
knownExhaustion,
|
|
9069
9112
|
checkRateGuard,
|
|
9070
9113
|
cooldownUntil,
|
|
9071
9114
|
connectDaemon,
|
package/dist/index.js
CHANGED
|
@@ -23,7 +23,7 @@ if (isCallback) {
|
|
|
23
23
|
});
|
|
24
24
|
process.stdin.destroy();
|
|
25
25
|
} else {
|
|
26
|
-
const { run } = await import("./main-
|
|
26
|
+
const { run } = await import("./main-LFJGWMAM.js");
|
|
27
27
|
process.exitCode = await run(argv, {
|
|
28
28
|
stdout: process.stdout,
|
|
29
29
|
stderr: process.stderr,
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
withSubscriptionEnv,
|
|
62
62
|
workerJson,
|
|
63
63
|
workerManager
|
|
64
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-6AS5JAKB.js";
|
|
65
65
|
import {
|
|
66
66
|
AppError,
|
|
67
67
|
EXIT_CODES,
|
|
@@ -353,7 +353,7 @@ Example:
|
|
|
353
353
|
);
|
|
354
354
|
group.command("serve", { hidden: true }).description("Run the daemon in this process (used by daemon start)").action(
|
|
355
355
|
action(rt, async (ctx) => {
|
|
356
|
-
const { serveDaemon } = await import("./serve-
|
|
356
|
+
const { serveDaemon } = await import("./serve-MAWWQPZB.js");
|
|
357
357
|
const daemon = await serveDaemon({
|
|
358
358
|
env: ctx.io.env,
|
|
359
359
|
entryPath: ctx.io.entryPath,
|
|
@@ -651,8 +651,12 @@ function registerEvents(program, rt) {
|
|
|
651
651
|
Events come from the audit log in SQLite, so this works with or without the
|
|
652
652
|
daemon. The daemon's availability monitor records availability.changed and
|
|
653
653
|
availability.signal; automatic switching records autoswitch.* (with the
|
|
654
|
-
reason, confidence, evidence, and from and to subscription).
|
|
655
|
-
|
|
654
|
+
reason, confidence, evidence, and from and to subscription).
|
|
655
|
+
autoswitch.candidates lists every subscription with its decision (selected,
|
|
656
|
+
rejected or not tried) and why; autoswitch.precheck looks ahead while a
|
|
657
|
+
subscription is LIMITED; autoswitch.retry records a new check after no
|
|
658
|
+
subscription could take over. With --json, --follow prints one JSON object
|
|
659
|
+
per line.
|
|
656
660
|
|
|
657
661
|
Examples:
|
|
658
662
|
agent-pool events
|
|
@@ -2435,7 +2439,7 @@ Claude Code credential files.`
|
|
|
2435
2439
|
}
|
|
2436
2440
|
|
|
2437
2441
|
// src/main.ts
|
|
2438
|
-
var VERSION = true ? "0.1.
|
|
2442
|
+
var VERSION = true ? "0.1.1" : "0.0.0-dev";
|
|
2439
2443
|
async function run(argv, io) {
|
|
2440
2444
|
const rt = { io, version: VERSION, exitCode: 0 };
|
|
2441
2445
|
const program = buildProgram(rt);
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
errorObject,
|
|
23
23
|
evaluateTrigger,
|
|
24
24
|
handoffService,
|
|
25
|
+
knownExhaustion,
|
|
25
26
|
newNonce,
|
|
26
27
|
openSubscriptionEnv,
|
|
27
28
|
parseMessage,
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
switchService,
|
|
34
35
|
withSubscriptionEnv,
|
|
35
36
|
workerManager
|
|
36
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-6AS5JAKB.js";
|
|
37
38
|
import {
|
|
38
39
|
AppError,
|
|
39
40
|
ENFORCES_POSIX_MODES,
|
|
@@ -708,6 +709,27 @@ function effectiveState(row, lastHook) {
|
|
|
708
709
|
return row;
|
|
709
710
|
}
|
|
710
711
|
var QUIET_MS = 5 * 6e4;
|
|
712
|
+
var RETRY_MS = 5 * 6e4;
|
|
713
|
+
function ageText(ms) {
|
|
714
|
+
const minutes = Math.round(ms / 6e4);
|
|
715
|
+
if (minutes < 120) return `${minutes} min`;
|
|
716
|
+
const hours = Math.round(minutes / 60);
|
|
717
|
+
return hours < 48 ? `${hours} h` : `${Math.round(hours / 24)} days`;
|
|
718
|
+
}
|
|
719
|
+
function tierWhy(tier, view) {
|
|
720
|
+
if (tier === "available") {
|
|
721
|
+
return `AVAILABLE (${view?.source ?? "unknown"})`;
|
|
722
|
+
}
|
|
723
|
+
if (tier === "limited") return "LIMITED (provider): close to its limit";
|
|
724
|
+
const age = view?.usage === null || view?.usage === void 0 ? "no usage data from the provider yet" : `provider data ${ageText(view.usage.ageMs)} old`;
|
|
725
|
+
return `availability Unknown (${age}); only a session on it can tell, so it is tried after the login check`;
|
|
726
|
+
}
|
|
727
|
+
function atLimit(snapshot, now) {
|
|
728
|
+
if (snapshot === null) return false;
|
|
729
|
+
return [snapshot.fiveHour, snapshot.sevenDay].some(
|
|
730
|
+
(w) => w !== void 0 && w.usedPercentage !== null && w.usedPercentage >= 100 && w.resetsAt !== null && Date.parse(w.resetsAt) > now.getTime()
|
|
731
|
+
);
|
|
732
|
+
}
|
|
711
733
|
var AUTO_DECISIONS = {
|
|
712
734
|
overrideGate: () => Promise.resolve(false),
|
|
713
735
|
checkpointCommit: () => Promise.resolve(false),
|
|
@@ -757,7 +779,9 @@ var AutoSwitchWatcher = class {
|
|
|
757
779
|
quietUntil: 0,
|
|
758
780
|
switched: false,
|
|
759
781
|
lastHook: null,
|
|
760
|
-
approved: false
|
|
782
|
+
approved: false,
|
|
783
|
+
waiting: null,
|
|
784
|
+
prechecked: false
|
|
761
785
|
});
|
|
762
786
|
}
|
|
763
787
|
untrack(run) {
|
|
@@ -837,14 +861,48 @@ var AutoSwitchWatcher = class {
|
|
|
837
861
|
this.signal(t, worker, { classification, source });
|
|
838
862
|
}
|
|
839
863
|
}
|
|
840
|
-
if (t.
|
|
864
|
+
if (t.waiting !== null) {
|
|
865
|
+
if (t.lastHook === "hook.Stop" || newSnapshot && !atLimit(usage.snapshot, this.now())) {
|
|
866
|
+
t.waiting = null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const switchable = canSwitchNow(effectiveState(worker.state, t.lastHook));
|
|
870
|
+
if (t.pending !== null && switchable) {
|
|
841
871
|
const signal = t.pending;
|
|
842
872
|
t.pending = null;
|
|
843
873
|
t.busy = true;
|
|
844
874
|
void this.decideLive(t, worker, signal).finally(() => {
|
|
845
875
|
t.busy = false;
|
|
846
876
|
});
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (t.waiting !== null && switchable && this.now().getTime() >= t.waiting.retryAt) {
|
|
880
|
+
const { signal } = t.waiting;
|
|
881
|
+
t.busy = true;
|
|
882
|
+
void this.decideLive(t, worker, signal, { retry: true }).finally(() => {
|
|
883
|
+
t.busy = false;
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
847
886
|
}
|
|
887
|
+
if (newSnapshot && t.pending === null && t.waiting === null) {
|
|
888
|
+
const sub = this.storage.subscriptions.getById(worker.subscriptionId);
|
|
889
|
+
const state = sub === void 0 ? null : this.options.env.availability.evaluate(sub).state;
|
|
890
|
+
if (state === "LIMITED" && !t.prechecked) {
|
|
891
|
+
t.prechecked = true;
|
|
892
|
+
t.busy = true;
|
|
893
|
+
void this.precheck(t, worker).finally(() => {
|
|
894
|
+
t.busy = false;
|
|
895
|
+
});
|
|
896
|
+
} else if (state === "AVAILABLE") {
|
|
897
|
+
t.prechecked = false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
/** When the candidates are checked again after none could take over. */
|
|
902
|
+
retryAt(earliestReset) {
|
|
903
|
+
const next = this.now().getTime() + (this.options.retryMs ?? RETRY_MS);
|
|
904
|
+
const reset = earliestReset === null ? Number.NaN : Date.parse(earliestReset) + 1e3;
|
|
905
|
+
return Number.isNaN(reset) ? next : Math.min(next, reset);
|
|
848
906
|
}
|
|
849
907
|
/** Records a classified signal and decides whether it waits for a switch. */
|
|
850
908
|
signal(t, worker, signal) {
|
|
@@ -895,10 +953,59 @@ var AutoSwitchWatcher = class {
|
|
|
895
953
|
sessionId: worker.sessionId
|
|
896
954
|
};
|
|
897
955
|
}
|
|
956
|
+
/**
|
|
957
|
+
* Every subscription as a switching candidate, in priority order, and the
|
|
958
|
+
* policy's choice. Availability that is no longer current does not rule a
|
|
959
|
+
* subscription out: an idle account has no fresh usage data, because only a
|
|
960
|
+
* running session reports it. What older data still proves (a window at
|
|
961
|
+
* its limit, or a switch away for a limit) keeps it out until the reset.
|
|
962
|
+
*/
|
|
963
|
+
candidates(fromSubscriptionId, config, now) {
|
|
964
|
+
const { storage } = this;
|
|
965
|
+
const all = storage.subscriptions.list();
|
|
966
|
+
const views = /* @__PURE__ */ new Map();
|
|
967
|
+
const inputs = all.map((s) => {
|
|
968
|
+
const view = this.options.env.availability.evaluate(s, now);
|
|
969
|
+
views.set(s.id, view);
|
|
970
|
+
const snap = storage.snapshots.latestBySource(s.id, "provider");
|
|
971
|
+
const away = storage.switches.lastAutoAwayFrom(s.id);
|
|
972
|
+
return {
|
|
973
|
+
id: s.id,
|
|
974
|
+
label: s.label,
|
|
975
|
+
removed: s.removedAt !== null,
|
|
976
|
+
status: s.status,
|
|
977
|
+
availability: {
|
|
978
|
+
state: view.state,
|
|
979
|
+
source: view.source,
|
|
980
|
+
stale: view.stale,
|
|
981
|
+
resetsAt: view.resetsAt
|
|
982
|
+
},
|
|
983
|
+
cooldownUntil: cooldownUntil(away?.requestedAt ?? null, config.switching.cooldownMinutes),
|
|
984
|
+
exhaustion: knownExhaustion({
|
|
985
|
+
snapshot: snap === void 0 ? null : {
|
|
986
|
+
observedAt: snap.observedAt,
|
|
987
|
+
windows: [
|
|
988
|
+
{ name: "5-hour", ...snap.fiveHour },
|
|
989
|
+
{ name: "7-day", ...snap.sevenDay }
|
|
990
|
+
]
|
|
991
|
+
},
|
|
992
|
+
lastAutoAway: away === void 0 ? null : { at: away.requestedAt, category: away.details?.category ?? null },
|
|
993
|
+
limitedThreshold: config.availability.limitedThreshold,
|
|
994
|
+
now
|
|
995
|
+
})
|
|
996
|
+
};
|
|
997
|
+
});
|
|
998
|
+
return { all, views, choice: chooseCandidates(fromSubscriptionId, inputs, now) };
|
|
999
|
+
}
|
|
898
1000
|
/**
|
|
899
1001
|
* The shared part of both paths: mode, loop guards and candidates. Returns
|
|
900
|
-
* the target, or
|
|
901
|
-
* attached terminal).
|
|
1002
|
+
* the target, or why there is none (after recording it and telling the
|
|
1003
|
+
* attached terminal). Each candidate's login is checked in turn; one that
|
|
1004
|
+
* fails is skipped and the next one is tried.
|
|
1005
|
+
*
|
|
1006
|
+
* revalidate: the ask path checking again after the answer (no new
|
|
1007
|
+
* triggered or candidates event). retry: checking again after no
|
|
1008
|
+
* subscription could take over (records only what changed).
|
|
902
1009
|
*/
|
|
903
1010
|
async plan(run, worker, classification, options = {}) {
|
|
904
1011
|
const { storage } = this;
|
|
@@ -908,6 +1015,7 @@ var AutoSwitchWatcher = class {
|
|
|
908
1015
|
const label = sub?.label ?? worker.subscriptionId;
|
|
909
1016
|
const reason = categoryReason(classification.category);
|
|
910
1017
|
const base = this.base(worker);
|
|
1018
|
+
const quiet = options.revalidate === true || options.retry === true;
|
|
911
1019
|
const details = {
|
|
912
1020
|
mode,
|
|
913
1021
|
reason,
|
|
@@ -915,7 +1023,7 @@ var AutoSwitchWatcher = class {
|
|
|
915
1023
|
confidence: classification.confidence,
|
|
916
1024
|
evidence: classification.evidence
|
|
917
1025
|
};
|
|
918
|
-
if (
|
|
1026
|
+
if (!quiet) {
|
|
919
1027
|
storage.events.append({
|
|
920
1028
|
type: "autoswitch.triggered",
|
|
921
1029
|
severity: "warn",
|
|
@@ -926,22 +1034,24 @@ var AutoSwitchWatcher = class {
|
|
|
926
1034
|
}
|
|
927
1035
|
});
|
|
928
1036
|
}
|
|
929
|
-
const say = (type,
|
|
1037
|
+
const say = (type, summary2, extra2 = {}) => {
|
|
930
1038
|
storage.events.append({
|
|
931
1039
|
type,
|
|
932
1040
|
severity: "warn",
|
|
933
1041
|
...base,
|
|
934
|
-
data: { ...details, ...
|
|
1042
|
+
data: { ...details, ...extra2, summary: summary2 }
|
|
935
1043
|
});
|
|
936
|
-
run.line(`Agent Pool: ${
|
|
937
|
-
run.notice(
|
|
938
|
-
return null;
|
|
1044
|
+
run.line(`Agent Pool: ${summary2}`);
|
|
1045
|
+
run.notice(summary2);
|
|
939
1046
|
};
|
|
940
1047
|
if (mode === "off") {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1048
|
+
if (options.retry !== true) {
|
|
1049
|
+
say(
|
|
1050
|
+
"autoswitch.off",
|
|
1051
|
+
`"${label}": ${reason} (${classification.confidence}). autoSwitch is off, so the worker stays on "${label}"; switch by hand with "agent-pool worker switch".`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
return { kind: "off" };
|
|
945
1055
|
}
|
|
946
1056
|
const now = this.now();
|
|
947
1057
|
if (worker.sessionId !== null) {
|
|
@@ -953,48 +1063,138 @@ var AutoSwitchWatcher = class {
|
|
|
953
1063
|
maxPerSessionPerHour: config.switching.maxPerSessionPerHour
|
|
954
1064
|
});
|
|
955
1065
|
if (!guard.ok) {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1066
|
+
if (options.retry !== true) {
|
|
1067
|
+
say(
|
|
1068
|
+
"autoswitch.blocked",
|
|
1069
|
+
`"${label}": ${reason}, but automatic switching stopped: ${guard.reason}. Switch by hand with "agent-pool worker switch".`,
|
|
1070
|
+
{ guard: "rate" }
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return { kind: "blocked" };
|
|
961
1074
|
}
|
|
962
1075
|
}
|
|
963
|
-
const all =
|
|
964
|
-
const inputs = all.map((s) => {
|
|
965
|
-
const view = this.options.env.availability.evaluate(s, now);
|
|
966
|
-
return {
|
|
967
|
-
id: s.id,
|
|
968
|
-
label: s.label,
|
|
969
|
-
removed: s.removedAt !== null,
|
|
970
|
-
status: s.status,
|
|
971
|
-
availability: {
|
|
972
|
-
state: view.state,
|
|
973
|
-
source: view.source,
|
|
974
|
-
stale: view.stale,
|
|
975
|
-
resetsAt: view.resetsAt
|
|
976
|
-
},
|
|
977
|
-
cooldownUntil: cooldownUntil(
|
|
978
|
-
storage.switches.lastAutoAwayFrom(s.id)?.requestedAt ?? null,
|
|
979
|
-
config.switching.cooldownMinutes
|
|
980
|
-
)
|
|
981
|
-
};
|
|
982
|
-
});
|
|
983
|
-
const choice = chooseCandidates(worker.subscriptionId, inputs, now);
|
|
1076
|
+
const { all, views, choice } = this.candidates(worker.subscriptionId, config, now);
|
|
984
1077
|
const switches = switchService(run.ctx ?? this.options.ctx, this.options.env, quietReporter());
|
|
1078
|
+
const rejected = new Map(choice.skipped.map((x) => [x.id, x.why]));
|
|
1079
|
+
let selected = null;
|
|
985
1080
|
for (const candidate of choice.ordered) {
|
|
986
1081
|
const target = all.find((s) => s.id === candidate.id);
|
|
987
1082
|
if (target === void 0) continue;
|
|
988
1083
|
const check = await switches.checkTarget(target, worker.subscriptionId);
|
|
989
|
-
if (check.usable)
|
|
990
|
-
|
|
1084
|
+
if (check.usable) {
|
|
1085
|
+
selected = { target, tier: candidate.tier };
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
const why = check.reasons.join(" ");
|
|
1089
|
+
rejected.set(target.id, why);
|
|
1090
|
+
choice.skipped.push({ id: target.id, label: target.label, why });
|
|
1091
|
+
}
|
|
1092
|
+
const rows = all.filter((s) => s.removedAt === null).map((s) => {
|
|
1093
|
+
const ranked = choice.ordered.find((c) => c.id === s.id);
|
|
1094
|
+
const tier = ranked === void 0 ? {} : { tier: ranked.tier };
|
|
1095
|
+
if (s.id === worker.subscriptionId) {
|
|
1096
|
+
return { id: s.id, label: s.label, decision: "current", why: reason };
|
|
1097
|
+
}
|
|
1098
|
+
const no = rejected.get(s.id);
|
|
1099
|
+
if (no !== void 0)
|
|
1100
|
+
return { id: s.id, label: s.label, decision: "rejected", ...tier, why: no };
|
|
1101
|
+
if (selected?.target.id === s.id) {
|
|
1102
|
+
return {
|
|
1103
|
+
id: s.id,
|
|
1104
|
+
label: s.label,
|
|
1105
|
+
decision: "selected",
|
|
1106
|
+
...tier,
|
|
1107
|
+
why: `${tierWhy(selected.tier, views.get(s.id))}; logged in`
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
return {
|
|
1111
|
+
id: s.id,
|
|
1112
|
+
label: s.label,
|
|
1113
|
+
decision: "not tried",
|
|
1114
|
+
...tier,
|
|
1115
|
+
why: "a subscription earlier in the order was selected"
|
|
1116
|
+
};
|
|
1117
|
+
});
|
|
1118
|
+
const key = JSON.stringify(rows.map((r) => [r.id, r.decision, r.why]));
|
|
1119
|
+
const changed = options.retry !== true || key !== options.previousKey;
|
|
1120
|
+
if (options.revalidate !== true && changed) {
|
|
1121
|
+
storage.events.append({
|
|
1122
|
+
type: "autoswitch.candidates",
|
|
1123
|
+
...base,
|
|
1124
|
+
data: {
|
|
1125
|
+
summary: selected === null ? `No subscription can take over from "${label}".` : `Selected "${selected.target.label}" to take over from "${label}" (${selected.tier}).`,
|
|
1126
|
+
selected: selected?.target.id ?? null,
|
|
1127
|
+
...options.retry === true ? { retry: true } : {},
|
|
1128
|
+
candidates: rows
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
991
1131
|
}
|
|
1132
|
+
if (selected !== null) return { kind: "target", ...selected };
|
|
992
1133
|
const reset = choice.earliestReset === null ? "No reset time is known." : `The earliest known reset is ${choice.earliestReset}.`;
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
)
|
|
1134
|
+
const summary = `"${label}": ${reason}, and no other subscription can take over. ${reset} Agent Pool checks again every ${Math.round((this.options.retryMs ?? RETRY_MS) / 6e4)} min and at the earliest reset; Claude Code continues automatically after a reset.`;
|
|
1135
|
+
const extra = { earliestReset: choice.earliestReset, skipped: choice.skipped };
|
|
1136
|
+
if (options.retry !== true) {
|
|
1137
|
+
say("autoswitch.exhausted", summary, extra);
|
|
1138
|
+
} else if (changed) {
|
|
1139
|
+
storage.events.append({
|
|
1140
|
+
type: "autoswitch.retry",
|
|
1141
|
+
...base,
|
|
1142
|
+
data: { ...details, ...extra, summary }
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
return { kind: "exhausted", earliestReset: choice.earliestReset, key };
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* While the worker's subscription is LIMITED: checks ahead which
|
|
1149
|
+
* subscription would take over, so a missing login shows before the limit
|
|
1150
|
+
* is reached. Usage of an idle account cannot be read, so only logins are
|
|
1151
|
+
* checked; the switch itself checks everything again.
|
|
1152
|
+
*/
|
|
1153
|
+
async precheck(t, worker) {
|
|
1154
|
+
try {
|
|
1155
|
+
const config = this.liveConfig();
|
|
1156
|
+
if (config.autoSwitch === "off") return;
|
|
1157
|
+
const { storage } = this;
|
|
1158
|
+
const label = storage.subscriptions.getById(worker.subscriptionId)?.label ?? worker.subscriptionId;
|
|
1159
|
+
const { all, views, choice } = this.candidates(worker.subscriptionId, config, this.now());
|
|
1160
|
+
const switches = switchService(
|
|
1161
|
+
t.run.ctx ?? this.options.ctx,
|
|
1162
|
+
this.options.env,
|
|
1163
|
+
quietReporter()
|
|
1164
|
+
);
|
|
1165
|
+
const rows = choice.skipped.map((x) => ({
|
|
1166
|
+
id: x.id,
|
|
1167
|
+
label: x.label,
|
|
1168
|
+
decision: "rejected",
|
|
1169
|
+
why: x.why
|
|
1170
|
+
}));
|
|
1171
|
+
const ready = [];
|
|
1172
|
+
for (const candidate of choice.ordered) {
|
|
1173
|
+
const target = all.find((s) => s.id === candidate.id);
|
|
1174
|
+
if (target === void 0) continue;
|
|
1175
|
+
const check = await switches.checkTarget(target, worker.subscriptionId);
|
|
1176
|
+
if (check.usable) ready.push(target.label);
|
|
1177
|
+
rows.push({
|
|
1178
|
+
id: target.id,
|
|
1179
|
+
label: target.label,
|
|
1180
|
+
tier: candidate.tier,
|
|
1181
|
+
decision: check.usable ? "ready" : "rejected",
|
|
1182
|
+
why: check.usable ? `${tierWhy(candidate.tier, views.get(target.id))}; logged in` : check.reasons.join(" ")
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
const summary = ready.length > 0 ? `"${label}" is close to its limit; next in line: ${ready.map((l) => `"${l}"`).join(", ")}.` : `"${label}" is close to its limit and no other subscription is ready to take over: ${rows.map((r) => `"${r.label}" ${r.why}`).join("; ") || "there is no other subscription"}.`;
|
|
1186
|
+
storage.events.append({
|
|
1187
|
+
type: "autoswitch.precheck",
|
|
1188
|
+
severity: ready.length > 0 ? "info" : "warn",
|
|
1189
|
+
...this.base(worker),
|
|
1190
|
+
data: { summary, candidates: rows }
|
|
1191
|
+
});
|
|
1192
|
+
if (ready.length === 0) {
|
|
1193
|
+
t.run.line(`Agent Pool: ${summary}`);
|
|
1194
|
+
t.run.notice(summary);
|
|
1195
|
+
}
|
|
1196
|
+
} catch {
|
|
1197
|
+
}
|
|
998
1198
|
}
|
|
999
1199
|
/** In ask mode: the question waits in the daemon until a terminal answers. */
|
|
1000
1200
|
async confirm(run, worker, target, classification) {
|
|
@@ -1019,18 +1219,35 @@ var AutoSwitchWatcher = class {
|
|
|
1019
1219
|
}
|
|
1020
1220
|
return ok;
|
|
1021
1221
|
}
|
|
1022
|
-
/**
|
|
1023
|
-
|
|
1222
|
+
/**
|
|
1223
|
+
* A signal while the worker is alive: hand off and ask its owner (this
|
|
1224
|
+
* daemon) to switch. With retry: the candidates are checked again after
|
|
1225
|
+
* none could take over earlier.
|
|
1226
|
+
*/
|
|
1227
|
+
async decideLive(t, worker, signal, options = {}) {
|
|
1024
1228
|
const { storage } = this;
|
|
1025
1229
|
const quiet = () => {
|
|
1026
1230
|
t.quietUntil = Date.now() + QUIET_MS;
|
|
1027
1231
|
};
|
|
1232
|
+
const retry = options.retry === true;
|
|
1233
|
+
const previousKey = t.waiting?.key;
|
|
1028
1234
|
try {
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1235
|
+
const outcome = await this.plan(t.run, worker, signal.classification, {
|
|
1236
|
+
retry,
|
|
1237
|
+
...previousKey === void 0 ? {} : { previousKey }
|
|
1238
|
+
});
|
|
1239
|
+
if (outcome.kind !== "target") {
|
|
1240
|
+
if (outcome.kind === "exhausted") {
|
|
1241
|
+
t.waiting = { signal, retryAt: this.retryAt(outcome.earliestReset), key: outcome.key };
|
|
1242
|
+
} else if (outcome.kind === "blocked" && t.waiting !== null) {
|
|
1243
|
+
t.waiting.retryAt = this.retryAt(null);
|
|
1244
|
+
} else {
|
|
1245
|
+
t.waiting = null;
|
|
1246
|
+
}
|
|
1247
|
+
if (!retry) quiet();
|
|
1032
1248
|
return;
|
|
1033
1249
|
}
|
|
1250
|
+
const { target, tier } = outcome;
|
|
1034
1251
|
if (this.mode() === "ask") {
|
|
1035
1252
|
if (!t.approved) {
|
|
1036
1253
|
if (!await this.confirm(t.run, worker, target, signal.classification)) {
|
|
@@ -1052,7 +1269,7 @@ var AutoSwitchWatcher = class {
|
|
|
1052
1269
|
return;
|
|
1053
1270
|
}
|
|
1054
1271
|
const again = await this.plan(t.run, current, signal.classification, { revalidate: true });
|
|
1055
|
-
if (again
|
|
1272
|
+
if (again.kind !== "target" || again.target.id !== target.id) {
|
|
1056
1273
|
quiet();
|
|
1057
1274
|
return;
|
|
1058
1275
|
}
|
|
@@ -1087,9 +1304,15 @@ var AutoSwitchWatcher = class {
|
|
|
1087
1304
|
});
|
|
1088
1305
|
t.run.line(`Agent Pool: ${summary}`);
|
|
1089
1306
|
t.run.notice(summary);
|
|
1307
|
+
if (t.waiting !== null) t.waiting.retryAt = this.retryAt(null);
|
|
1090
1308
|
quiet();
|
|
1091
1309
|
return;
|
|
1092
1310
|
}
|
|
1311
|
+
if (tier === "unverified") {
|
|
1312
|
+
t.run.line(
|
|
1313
|
+
`Agent Pool: switching to "${target.label}": its availability is Unknown (no current usage data), and its login is verified.`
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1093
1316
|
const details = {
|
|
1094
1317
|
category: signal.classification.category,
|
|
1095
1318
|
confidence: signal.classification.confidence,
|
|
@@ -1116,12 +1339,15 @@ var AutoSwitchWatcher = class {
|
|
|
1116
1339
|
reason,
|
|
1117
1340
|
...details,
|
|
1118
1341
|
from: worker.subscriptionId,
|
|
1119
|
-
to: target.id
|
|
1342
|
+
to: target.id,
|
|
1343
|
+
tier,
|
|
1344
|
+
verified: tier !== "unverified"
|
|
1120
1345
|
}
|
|
1121
1346
|
});
|
|
1122
1347
|
}
|
|
1123
1348
|
});
|
|
1124
1349
|
t.switched = true;
|
|
1350
|
+
t.waiting = null;
|
|
1125
1351
|
} catch (err) {
|
|
1126
1352
|
storage.events.append({
|
|
1127
1353
|
type: "autoswitch.failed",
|
|
@@ -1141,8 +1367,9 @@ var AutoSwitchWatcher = class {
|
|
|
1141
1367
|
const decision = evaluateTrigger(finished.classification);
|
|
1142
1368
|
if (!decision.trigger) return null;
|
|
1143
1369
|
const worker = finished.worker;
|
|
1144
|
-
const
|
|
1145
|
-
if (
|
|
1370
|
+
const outcome = await this.plan(run, worker, finished.classification);
|
|
1371
|
+
if (outcome.kind !== "target") return null;
|
|
1372
|
+
const { target, tier } = outcome;
|
|
1146
1373
|
if (this.mode() === "ask" && !await this.confirm(run, worker, target, finished.classification)) {
|
|
1147
1374
|
return null;
|
|
1148
1375
|
}
|
|
@@ -1181,6 +1408,8 @@ var AutoSwitchWatcher = class {
|
|
|
1181
1408
|
...details,
|
|
1182
1409
|
from: subscription.id,
|
|
1183
1410
|
to: target.id,
|
|
1411
|
+
tier,
|
|
1412
|
+
verified: tier !== "unverified",
|
|
1184
1413
|
after: "exit"
|
|
1185
1414
|
}
|
|
1186
1415
|
});
|
|
@@ -1517,7 +1746,8 @@ async function serveDaemon(io) {
|
|
|
1517
1746
|
env,
|
|
1518
1747
|
config,
|
|
1519
1748
|
clock,
|
|
1520
|
-
...io.watchIntervalMs === void 0 ? {} : { intervalMs: io.watchIntervalMs }
|
|
1749
|
+
...io.watchIntervalMs === void 0 ? {} : { intervalMs: io.watchIntervalMs },
|
|
1750
|
+
...io.autoSwitchRetryMs === void 0 ? {} : { retryMs: io.autoSwitchRetryMs }
|
|
1521
1751
|
});
|
|
1522
1752
|
const runFor = (params) => {
|
|
1523
1753
|
const runId = optString(obj(params), "runId", 64);
|