@indigoai-us/hq-cli 5.187.0 → 5.188.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/command-catalog.generated.d.ts +43 -0
- package/dist/command-catalog.generated.js +56 -0
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +633 -10
- package/dist/lib/lanes/claude-account-balance.d.ts +82 -0
- package/dist/lib/lanes/claude-account-balance.js +253 -0
- package/dist/lib/lanes/host-capacity.d.ts +19 -0
- package/dist/lib/lanes/host-capacity.js +68 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -0
- package/dist/lib/lanes/outpost-dispatch.js +19 -0
- package/dist/lib/lanes/store.js +86 -0
- package/dist/lib/lanes/types.d.ts +48 -0
- package/dist/lib/lanes/types.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { HostCapacityAccount, HostCapacitySnapshot } from "./host-capacity.js";
|
|
2
|
+
import type { LaneHost, LaneRecord, LanesConfig } from "./types.js";
|
|
3
|
+
export declare const CLAUDE_ACCOUNT_BALANCE_FLAG = "lanes.claude-account-balance";
|
|
4
|
+
export declare const BALANCE_LIVE_STATES: readonly ["running", "queued", "awaiting_input"];
|
|
5
|
+
export declare const BALANCE_MOVABLE_STATES: readonly ["interrupted", "done", "awaiting_input"];
|
|
6
|
+
export interface BalanceHostSnapshot {
|
|
7
|
+
capacity: HostCapacitySnapshot;
|
|
8
|
+
lanes: readonly LaneRecord[];
|
|
9
|
+
/** False means the lane list could not be read; capacity counts may be partial. */
|
|
10
|
+
laneListAvailable: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface BalanceHostGap {
|
|
13
|
+
host: LaneHost | {
|
|
14
|
+
kind: "unknown";
|
|
15
|
+
id: "unknown";
|
|
16
|
+
};
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export interface AccountHostLoad {
|
|
20
|
+
host: LaneHost;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
logged_in: boolean | null;
|
|
23
|
+
live_lanes: number | null;
|
|
24
|
+
lane_counts: {
|
|
25
|
+
running: number | null;
|
|
26
|
+
queued: number | null;
|
|
27
|
+
awaiting_input: number | null;
|
|
28
|
+
};
|
|
29
|
+
count_source: "lane_list" | "capacity" | "capacity_partial" | "unavailable";
|
|
30
|
+
}
|
|
31
|
+
export interface ClaudeAccountLoad {
|
|
32
|
+
account: string;
|
|
33
|
+
target_enabled: boolean;
|
|
34
|
+
target_logged_in: boolean | null;
|
|
35
|
+
eligible: boolean;
|
|
36
|
+
ineligible_reason?: string;
|
|
37
|
+
live_lanes: number;
|
|
38
|
+
counts_complete: boolean;
|
|
39
|
+
usage: NonNullable<HostCapacityAccount["usage"]> | null;
|
|
40
|
+
hosts: AccountHostLoad[];
|
|
41
|
+
}
|
|
42
|
+
export interface ClaudeAccountSelection {
|
|
43
|
+
account: string;
|
|
44
|
+
signal: "usage" | "lane_count" | "explicit";
|
|
45
|
+
reason: "least_loaded" | "explicit";
|
|
46
|
+
target_host: LaneHost;
|
|
47
|
+
considered: ClaudeAccountLoad[];
|
|
48
|
+
skipped_accounts: Array<{
|
|
49
|
+
account: string;
|
|
50
|
+
host: LaneHost;
|
|
51
|
+
reason: string;
|
|
52
|
+
}>;
|
|
53
|
+
unavailable_hosts: BalanceHostGap[];
|
|
54
|
+
}
|
|
55
|
+
export interface BalanceMoveSuggestion {
|
|
56
|
+
lane_id: string;
|
|
57
|
+
host: LaneHost;
|
|
58
|
+
state: LaneRecord["state"];
|
|
59
|
+
from: string;
|
|
60
|
+
to: string;
|
|
61
|
+
reason: "account_load" | "source_unavailable";
|
|
62
|
+
apply: boolean;
|
|
63
|
+
skip_reason?: "running_turn" | "state_not_movable" | "no_provider_session" | "pending_resume";
|
|
64
|
+
}
|
|
65
|
+
export declare function selectClaudeAccount(input: {
|
|
66
|
+
hosts: readonly BalanceHostSnapshot[];
|
|
67
|
+
targetHost: LaneHost;
|
|
68
|
+
config: LanesConfig;
|
|
69
|
+
allowedAccounts?: readonly string[];
|
|
70
|
+
explicitAccount?: string;
|
|
71
|
+
unavailableHosts?: readonly BalanceHostGap[];
|
|
72
|
+
}): ClaudeAccountSelection;
|
|
73
|
+
export declare function accountLoadsForReport(input: {
|
|
74
|
+
hosts: readonly BalanceHostSnapshot[];
|
|
75
|
+
config: LanesConfig;
|
|
76
|
+
}): ClaudeAccountLoad[];
|
|
77
|
+
export declare function planClaudeBalanceMoves(input: {
|
|
78
|
+
hosts: readonly BalanceHostSnapshot[];
|
|
79
|
+
config: LanesConfig;
|
|
80
|
+
}): BalanceMoveSuggestion[];
|
|
81
|
+
export declare function assertBalanceApplyEnabled(enabled: boolean): void;
|
|
82
|
+
//# sourceMappingURL=claude-account-balance.d.ts.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { LanesError } from "./errors.js";
|
|
2
|
+
export const CLAUDE_ACCOUNT_BALANCE_FLAG = "lanes.claude-account-balance";
|
|
3
|
+
export const BALANCE_LIVE_STATES = ["running", "queued", "awaiting_input"];
|
|
4
|
+
export const BALANCE_MOVABLE_STATES = ["interrupted", "done", "awaiting_input"];
|
|
5
|
+
function sameHost(a, b) {
|
|
6
|
+
return a.kind === b.kind && a.id === b.id;
|
|
7
|
+
}
|
|
8
|
+
function isBalanceLive(lane) {
|
|
9
|
+
return BALANCE_LIVE_STATES.includes(lane.state);
|
|
10
|
+
}
|
|
11
|
+
function countLaneStates(lanes, account) {
|
|
12
|
+
const rows = lanes.filter((lane) => lane.provider === "claude" && lane.account === account && isBalanceLive(lane));
|
|
13
|
+
const running = rows.filter((lane) => lane.state === "running").length;
|
|
14
|
+
const queued = rows.filter((lane) => lane.state === "queued").length;
|
|
15
|
+
const awaiting_input = rows.filter((lane) => lane.state === "awaiting_input").length;
|
|
16
|
+
return { running, queued, awaiting_input, live: running + queued + awaiting_input };
|
|
17
|
+
}
|
|
18
|
+
function accountHostLoad(host, accountId) {
|
|
19
|
+
const hostCapacity = host.capacity.providers.claude.accounts[accountId];
|
|
20
|
+
if (!hostCapacity) {
|
|
21
|
+
return {
|
|
22
|
+
host: host.capacity.host,
|
|
23
|
+
enabled: false,
|
|
24
|
+
logged_in: false,
|
|
25
|
+
live_lanes: host.laneListAvailable ? countLaneStates(host.lanes, accountId).live : null,
|
|
26
|
+
lane_counts: host.laneListAvailable
|
|
27
|
+
? countLaneStates(host.lanes, accountId)
|
|
28
|
+
: { running: null, queued: null, awaiting_input: null },
|
|
29
|
+
count_source: host.laneListAvailable ? "lane_list" : "unavailable",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (host.laneListAvailable) {
|
|
33
|
+
const counts = countLaneStates(host.lanes, accountId);
|
|
34
|
+
return {
|
|
35
|
+
host: host.capacity.host,
|
|
36
|
+
enabled: hostCapacity.enabled,
|
|
37
|
+
logged_in: hostCapacity.logged_in,
|
|
38
|
+
live_lanes: counts.live,
|
|
39
|
+
lane_counts: { running: counts.running, queued: counts.queued, awaiting_input: counts.awaiting_input },
|
|
40
|
+
count_source: "lane_list",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (hostCapacity.lane_counts !== undefined) {
|
|
44
|
+
return {
|
|
45
|
+
host: host.capacity.host,
|
|
46
|
+
enabled: hostCapacity.enabled,
|
|
47
|
+
logged_in: hostCapacity.logged_in,
|
|
48
|
+
live_lanes: hostCapacity.lane_counts.live,
|
|
49
|
+
lane_counts: {
|
|
50
|
+
running: hostCapacity.lane_counts.running,
|
|
51
|
+
queued: hostCapacity.lane_counts.queued,
|
|
52
|
+
awaiting_input: hostCapacity.lane_counts.awaiting_input,
|
|
53
|
+
},
|
|
54
|
+
count_source: "capacity",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
host: host.capacity.host,
|
|
59
|
+
enabled: hostCapacity.enabled,
|
|
60
|
+
logged_in: hostCapacity.logged_in,
|
|
61
|
+
live_lanes: hostCapacity.running + hostCapacity.queued,
|
|
62
|
+
lane_counts: { running: hostCapacity.running, queued: hostCapacity.queued, awaiting_input: null },
|
|
63
|
+
count_source: "capacity_partial",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function usageScore(usage) {
|
|
67
|
+
if (usage?.five_hour_percent === undefined || usage.weekly_percent === undefined)
|
|
68
|
+
return null;
|
|
69
|
+
return Math.max(usage.five_hour_percent, usage.weekly_percent);
|
|
70
|
+
}
|
|
71
|
+
function selectionSignal(loads) {
|
|
72
|
+
return loads.length > 0 && loads.every((load) => usageScore(load.usage) !== null)
|
|
73
|
+
? "usage"
|
|
74
|
+
: "lane_count";
|
|
75
|
+
}
|
|
76
|
+
function targetIneligibilityReason(accountId, globalEnabled, allowedAccounts, target) {
|
|
77
|
+
if (!globalEnabled || !target.enabled)
|
|
78
|
+
return "disabled";
|
|
79
|
+
if (allowedAccounts !== undefined && !allowedAccounts.includes(accountId))
|
|
80
|
+
return "not_allowed_by_lane";
|
|
81
|
+
if (target.logged_in === false)
|
|
82
|
+
return "not_logged_in";
|
|
83
|
+
if (target.logged_in === null)
|
|
84
|
+
return "login_status_unknown";
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
function buildLoads(input) {
|
|
88
|
+
const configAccounts = input.config.providers.claude.accounts;
|
|
89
|
+
const targetHost = input.target.capacity.host;
|
|
90
|
+
const skipped = [];
|
|
91
|
+
const loads = Object.keys(configAccounts).sort().map((account) => {
|
|
92
|
+
const hosts = input.hosts.map((host) => accountHostLoad(host, account));
|
|
93
|
+
const targetLoad = hosts.find((host) => sameHost(host.host, targetHost));
|
|
94
|
+
const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad);
|
|
95
|
+
if (ineligible !== undefined)
|
|
96
|
+
skipped.push({ account, host: targetHost, reason: ineligible });
|
|
97
|
+
const knownCounts = hosts.flatMap((host) => host.live_lanes === null ? [] : [host.live_lanes]);
|
|
98
|
+
return {
|
|
99
|
+
account,
|
|
100
|
+
target_enabled: targetLoad.enabled && configAccounts[account]?.enabled === true,
|
|
101
|
+
target_logged_in: targetLoad.logged_in,
|
|
102
|
+
eligible: ineligible === undefined,
|
|
103
|
+
...(ineligible !== undefined ? { ineligible_reason: ineligible } : {}),
|
|
104
|
+
live_lanes: knownCounts.reduce((sum, count) => sum + count, 0),
|
|
105
|
+
counts_complete: hosts.every((host) => host.live_lanes !== null && host.count_source !== "capacity_partial"),
|
|
106
|
+
usage: input.target.capacity.providers.claude.accounts[account]?.usage ?? null,
|
|
107
|
+
hosts,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
return { loads, skipped };
|
|
111
|
+
}
|
|
112
|
+
export function selectClaudeAccount(input) {
|
|
113
|
+
const target = input.hosts.find((host) => sameHost(host.capacity.host, input.targetHost));
|
|
114
|
+
if (!target) {
|
|
115
|
+
throw new LanesError("claude_account_balance_target_host_unavailable", `Cannot verify Claude accounts on target host ${input.targetHost.kind}:${input.targetHost.id}`, {
|
|
116
|
+
target_host: input.targetHost,
|
|
117
|
+
unavailable_hosts: input.unavailableHosts ?? [],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const { loads, skipped } = buildLoads({ ...input, target });
|
|
121
|
+
if (input.explicitAccount !== undefined) {
|
|
122
|
+
const selected = loads.find((load) => load.account === input.explicitAccount);
|
|
123
|
+
if (!selected || !selected.eligible) {
|
|
124
|
+
throw new LanesError("claude_account_balance_account_unavailable", `Explicit Claude account ${JSON.stringify(input.explicitAccount)} is unavailable on ${input.targetHost.kind}:${input.targetHost.id}`, {
|
|
125
|
+
account: input.explicitAccount,
|
|
126
|
+
target_host: input.targetHost,
|
|
127
|
+
reason: selected?.ineligible_reason ?? "unknown_account",
|
|
128
|
+
account_reasons: skipped,
|
|
129
|
+
unavailable_hosts: input.unavailableHosts ?? [],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
account: input.explicitAccount,
|
|
134
|
+
signal: "explicit",
|
|
135
|
+
reason: "explicit",
|
|
136
|
+
target_host: input.targetHost,
|
|
137
|
+
considered: loads,
|
|
138
|
+
skipped_accounts: skipped,
|
|
139
|
+
unavailable_hosts: [...(input.unavailableHosts ?? [])],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const eligible = loads.filter((load) => load.eligible);
|
|
143
|
+
if (eligible.length === 0) {
|
|
144
|
+
const unknownAccounts = loads
|
|
145
|
+
.filter((load) => load.ineligible_reason === "login_status_unknown")
|
|
146
|
+
.map((load) => load.account);
|
|
147
|
+
const unknownLoginDetail = unknownAccounts.length > 0
|
|
148
|
+
? `; login status is unknown for ${unknownAccounts.join(", ")}`
|
|
149
|
+
: "";
|
|
150
|
+
throw new LanesError("claude_account_balance_no_eligible_account", `No enabled, logged-in Claude account is available on ${input.targetHost.kind}:${input.targetHost.id}${unknownLoginDetail}`, {
|
|
151
|
+
provider: "claude",
|
|
152
|
+
target_host: input.targetHost,
|
|
153
|
+
account_reasons: loads.map((load) => ({
|
|
154
|
+
account: load.account,
|
|
155
|
+
reason: load.ineligible_reason ?? "unavailable",
|
|
156
|
+
})),
|
|
157
|
+
unavailable_hosts: input.unavailableHosts ?? [],
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const signal = selectionSignal(eligible);
|
|
161
|
+
eligible.sort((a, b) => {
|
|
162
|
+
if (signal === "usage") {
|
|
163
|
+
const byUsage = usageScore(a.usage) - usageScore(b.usage);
|
|
164
|
+
if (byUsage !== 0)
|
|
165
|
+
return byUsage;
|
|
166
|
+
}
|
|
167
|
+
return a.live_lanes - b.live_lanes || a.account.localeCompare(b.account);
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
account: eligible[0].account,
|
|
171
|
+
signal,
|
|
172
|
+
reason: "least_loaded",
|
|
173
|
+
target_host: input.targetHost,
|
|
174
|
+
considered: loads,
|
|
175
|
+
skipped_accounts: skipped,
|
|
176
|
+
unavailable_hosts: [...(input.unavailableHosts ?? [])],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function accountLoadsForReport(input) {
|
|
180
|
+
const first = input.hosts[0];
|
|
181
|
+
if (!first)
|
|
182
|
+
return [];
|
|
183
|
+
return buildLoads({ hosts: input.hosts, target: first, config: input.config }).loads.map((load) => ({
|
|
184
|
+
...load,
|
|
185
|
+
eligible: false,
|
|
186
|
+
ineligible_reason: undefined,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
export function planClaudeBalanceMoves(input) {
|
|
190
|
+
const loads = accountLoadsForReport(input);
|
|
191
|
+
const projected = new Map(loads.map((load) => [load.account, load.live_lanes]));
|
|
192
|
+
const lanes = input.hosts.flatMap((host) => host.lanes
|
|
193
|
+
.filter((lane) => lane.provider === "claude" && lane.account && (isBalanceLive(lane) || BALANCE_MOVABLE_STATES.includes(lane.state)))
|
|
194
|
+
.map((lane) => ({ lane, host: host.capacity.host })))
|
|
195
|
+
.sort((a, b) => a.lane.created_at.localeCompare(b.lane.created_at) || a.lane.lane_id.localeCompare(b.lane.lane_id));
|
|
196
|
+
const suggestions = [];
|
|
197
|
+
for (const { lane, host } of lanes) {
|
|
198
|
+
const source = lane.account;
|
|
199
|
+
const sourceLoad = projected.get(source) ?? 0;
|
|
200
|
+
const hostSnapshot = input.hosts.find((row) => sameHost(row.capacity.host, host));
|
|
201
|
+
if (!hostSnapshot)
|
|
202
|
+
continue;
|
|
203
|
+
const candidates = Object.keys(input.config.providers.claude.accounts)
|
|
204
|
+
.filter((account) => account !== source && input.config.providers.claude.accounts[account]?.enabled === true)
|
|
205
|
+
.map((account) => ({
|
|
206
|
+
account,
|
|
207
|
+
targetStatus: hostSnapshot.capacity.providers.claude.accounts[account],
|
|
208
|
+
load: projected.get(account) ?? 0,
|
|
209
|
+
}))
|
|
210
|
+
.filter((candidate) => candidate.targetStatus?.enabled === true && candidate.targetStatus.logged_in === true)
|
|
211
|
+
.sort((a, b) => a.load - b.load || a.account.localeCompare(b.account));
|
|
212
|
+
const sourceStatus = hostSnapshot.capacity.providers.claude.accounts[source];
|
|
213
|
+
const sourceUnavailable = sourceStatus?.enabled !== true || sourceStatus.logged_in !== true;
|
|
214
|
+
const target = candidates[0];
|
|
215
|
+
const laneIsLive = isBalanceLive(lane);
|
|
216
|
+
if (!target || (!sourceUnavailable && (!laneIsLive || target.load >= sourceLoad)))
|
|
217
|
+
continue;
|
|
218
|
+
const movable = BALANCE_MOVABLE_STATES.includes(lane.state);
|
|
219
|
+
const sessionId = lane.provider_session_id ?? lane.last_resume?.session_id;
|
|
220
|
+
const skipReason = lane.state === "running"
|
|
221
|
+
? "running_turn"
|
|
222
|
+
: !movable
|
|
223
|
+
? "state_not_movable"
|
|
224
|
+
: lane.pending_resume !== undefined
|
|
225
|
+
? "pending_resume"
|
|
226
|
+
: typeof sessionId !== "string" || sessionId.trim() === ""
|
|
227
|
+
? "no_provider_session"
|
|
228
|
+
: undefined;
|
|
229
|
+
suggestions.push({
|
|
230
|
+
lane_id: lane.lane_id,
|
|
231
|
+
host,
|
|
232
|
+
state: lane.state,
|
|
233
|
+
from: source,
|
|
234
|
+
to: target.account,
|
|
235
|
+
reason: sourceUnavailable ? "source_unavailable" : "account_load",
|
|
236
|
+
apply: skipReason === undefined,
|
|
237
|
+
...(skipReason !== undefined ? { skip_reason: skipReason } : {}),
|
|
238
|
+
});
|
|
239
|
+
if (skipReason === undefined) {
|
|
240
|
+
if (laneIsLive) {
|
|
241
|
+
projected.set(source, Math.max(0, sourceLoad - 1));
|
|
242
|
+
projected.set(target.account, target.load + 1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return suggestions;
|
|
247
|
+
}
|
|
248
|
+
export function assertBalanceApplyEnabled(enabled) {
|
|
249
|
+
if (!enabled) {
|
|
250
|
+
throw new LanesError("claude_account_balance_disabled", `Applying Claude account balance moves requires hq-flags gate ${CLAUDE_ACCOUNT_BALANCE_FLAG}`, { flag: CLAUDE_ACCOUNT_BALANCE_FLAG });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=claude-account-balance.js.map
|
|
@@ -5,6 +5,19 @@ export interface HostCapacityAccount {
|
|
|
5
5
|
max_running: number;
|
|
6
6
|
running: number;
|
|
7
7
|
queued: number;
|
|
8
|
+
/** Exact lane states used for Claude account balancing; absent on older Outposts. */
|
|
9
|
+
lane_counts?: {
|
|
10
|
+
running: number;
|
|
11
|
+
queued: number;
|
|
12
|
+
awaiting_input: number;
|
|
13
|
+
live: number;
|
|
14
|
+
};
|
|
15
|
+
/** Provider-reported usage, when a host can read a recent sample. */
|
|
16
|
+
usage?: {
|
|
17
|
+
five_hour_percent?: number;
|
|
18
|
+
weekly_percent?: number;
|
|
19
|
+
sampled_at?: string;
|
|
20
|
+
};
|
|
8
21
|
}
|
|
9
22
|
export interface HostCapacityProvider {
|
|
10
23
|
max_running: number;
|
|
@@ -30,6 +43,12 @@ export declare function buildHostCapacitySnapshot(input: {
|
|
|
30
43
|
lanes: readonly LaneRecord[];
|
|
31
44
|
config: LanesConfig;
|
|
32
45
|
existsSync: (path: string) => boolean;
|
|
46
|
+
probe?: (command: string, args: string[], env: NodeJS.ProcessEnv) => {
|
|
47
|
+
status: number | null;
|
|
48
|
+
stdout: string;
|
|
49
|
+
stderr: string;
|
|
50
|
+
};
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
33
52
|
now: Date;
|
|
34
53
|
}): HostCapacitySnapshot;
|
|
35
54
|
/** Reject malformed remote snapshots before they can influence host selection. */
|
|
@@ -21,13 +21,23 @@ export function buildHostCapacitySnapshot(input) {
|
|
|
21
21
|
for (const [id, account] of Object.entries(providerConfig.accounts)) {
|
|
22
22
|
const status = accountStatus(provider, id, input.config, {
|
|
23
23
|
existsSync: input.existsSync,
|
|
24
|
+
...(input.probe !== undefined ? { probe: input.probe } : {}),
|
|
25
|
+
...(input.env !== undefined ? { env: input.env } : {}),
|
|
24
26
|
});
|
|
27
|
+
const accountLanes = providerLanes.filter((lane) => lane.account === id);
|
|
28
|
+
const liveLanes = accountLanes.filter((lane) => lane.state === "running" || lane.state === "queued" || lane.state === "awaiting_input");
|
|
25
29
|
accounts[id] = {
|
|
26
30
|
enabled: account.enabled,
|
|
27
31
|
logged_in: status.logged_in,
|
|
28
32
|
max_running: accountMaxRunning(providerConfig, id),
|
|
29
33
|
running: running.filter((lane) => lane.account === id).length,
|
|
30
34
|
queued: queued.filter((lane) => lane.account === id).length,
|
|
35
|
+
lane_counts: {
|
|
36
|
+
running: liveLanes.filter((lane) => lane.state === "running").length,
|
|
37
|
+
queued: liveLanes.filter((lane) => lane.state === "queued").length,
|
|
38
|
+
awaiting_input: liveLanes.filter((lane) => lane.state === "awaiting_input").length,
|
|
39
|
+
live: liveLanes.length,
|
|
40
|
+
},
|
|
31
41
|
};
|
|
32
42
|
}
|
|
33
43
|
providers[provider] = {
|
|
@@ -101,6 +111,12 @@ export function parseHostCapacitySnapshot(raw) {
|
|
|
101
111
|
max_running: requireCount(account.max_running, `${provider}.${id}.max_running`, true),
|
|
102
112
|
running: requireCount(account.running, `${provider}.${id}.running`),
|
|
103
113
|
queued: requireCount(account.queued, `${provider}.${id}.queued`),
|
|
114
|
+
...(account.lane_counts !== undefined
|
|
115
|
+
? { lane_counts: parseLaneCounts(account.lane_counts, `${provider}.${id}.lane_counts`) }
|
|
116
|
+
: {}),
|
|
117
|
+
...(account.usage !== undefined
|
|
118
|
+
? { usage: parseUsage(account.usage, `${provider}.${id}.usage`) }
|
|
119
|
+
: {}),
|
|
104
120
|
};
|
|
105
121
|
}
|
|
106
122
|
providers[provider] = {
|
|
@@ -118,6 +134,58 @@ export function parseHostCapacitySnapshot(raw) {
|
|
|
118
134
|
providers,
|
|
119
135
|
};
|
|
120
136
|
}
|
|
137
|
+
function parseLaneCounts(value, field) {
|
|
138
|
+
const counts = objectValue(value);
|
|
139
|
+
if (counts === undefined) {
|
|
140
|
+
throw new LanesError("host_capacity_invalid", `Invalid host capacity field ${field}`, { field });
|
|
141
|
+
}
|
|
142
|
+
const running = requireCount(counts.running, `${field}.running`);
|
|
143
|
+
const queued = requireCount(counts.queued, `${field}.queued`);
|
|
144
|
+
const awaiting_input = requireCount(counts.awaiting_input, `${field}.awaiting_input`);
|
|
145
|
+
const live = requireCount(counts.live, `${field}.live`);
|
|
146
|
+
if (running + queued + awaiting_input !== live) {
|
|
147
|
+
throw new LanesError("host_capacity_invalid", `Inconsistent host capacity field ${field}`, {
|
|
148
|
+
field,
|
|
149
|
+
running,
|
|
150
|
+
queued,
|
|
151
|
+
awaiting_input,
|
|
152
|
+
live,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return { running, queued, awaiting_input, live };
|
|
156
|
+
}
|
|
157
|
+
function parseUsage(value, field) {
|
|
158
|
+
const usage = objectValue(value);
|
|
159
|
+
if (usage === undefined) {
|
|
160
|
+
throw new LanesError("host_capacity_invalid", `Invalid host capacity field ${field}`, { field });
|
|
161
|
+
}
|
|
162
|
+
const parsed = {};
|
|
163
|
+
for (const key of ["five_hour_percent", "weekly_percent"]) {
|
|
164
|
+
const percentage = usage[key];
|
|
165
|
+
if (percentage !== undefined) {
|
|
166
|
+
if (typeof percentage !== "number" || !Number.isFinite(percentage) || percentage < 0 || percentage > 100) {
|
|
167
|
+
throw new LanesError("host_capacity_invalid", `Invalid host capacity field ${field}.${key}`, {
|
|
168
|
+
field: `${field}.${key}`,
|
|
169
|
+
value: percentage ?? null,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
parsed[key] = percentage;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (Object.keys(parsed).length === 0) {
|
|
176
|
+
throw new LanesError("host_capacity_invalid", `Empty host capacity field ${field}`, { field });
|
|
177
|
+
}
|
|
178
|
+
if (usage.sampled_at !== undefined) {
|
|
179
|
+
if (typeof usage.sampled_at !== "string" || !Number.isFinite(Date.parse(usage.sampled_at))) {
|
|
180
|
+
throw new LanesError("host_capacity_invalid", `Invalid host capacity field ${field}.sampled_at`, {
|
|
181
|
+
field: `${field}.sampled_at`,
|
|
182
|
+
value: usage.sampled_at ?? null,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
parsed.sampled_at = usage.sampled_at;
|
|
186
|
+
}
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
121
189
|
/**
|
|
122
190
|
* Choose the least-loaded eligible host/account from advisory snapshots. The
|
|
123
191
|
* selected host still runs the shared admission check under its own lock.
|
|
@@ -10,7 +10,7 @@ export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
|
|
|
10
10
|
export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
|
|
11
11
|
export type { HostCapacityAccount, HostCapacityProvider, HostCapacitySnapshot, HostPlacementChoice, } from "./host-capacity.js";
|
|
12
12
|
export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
|
|
13
|
-
export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
|
|
13
|
+
export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
|
|
14
14
|
export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
|
|
15
15
|
export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
|
|
16
16
|
export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
|
|
@@ -61,12 +61,21 @@ export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPa
|
|
|
61
61
|
export declare function buildOutpostPickupCommand(payloadUrl: string): string;
|
|
62
62
|
/** Bounded, read-only capacity query executed by the target Outpost CLI. */
|
|
63
63
|
export declare function buildOutpostCapacityCommand(): string;
|
|
64
|
+
/** Read the lane records owned by an Outpost for exact per-account state counts. */
|
|
65
|
+
export declare function buildOutpostLaneListCommand(): string;
|
|
64
66
|
/** Commit a queued lane move on the host that currently owns its state. */
|
|
65
67
|
export declare function buildOutpostMoveTargetCommand(input: {
|
|
66
68
|
laneId: string;
|
|
67
69
|
host: LaneHost;
|
|
68
70
|
account: string;
|
|
69
71
|
}): string;
|
|
72
|
+
/** Move an owned Claude session between accounts while leaving its turn parked. */
|
|
73
|
+
export declare function buildOutpostBalanceMoveCommand(input: {
|
|
74
|
+
laneId: string;
|
|
75
|
+
account: string;
|
|
76
|
+
fromAccount: string;
|
|
77
|
+
expectedState: LaneRecord["state"];
|
|
78
|
+
}): string;
|
|
70
79
|
/** Idempotently backfill the marker on the selected Outpost. */
|
|
71
80
|
export declare function buildOutpostIdentityBackfillCommand(expectedId: string): string;
|
|
72
81
|
/** Build the same `hq outposts exec` remote command used by lane pickup. */
|
|
@@ -46,6 +46,10 @@ export function buildOutpostPickupCommand(payloadUrl) {
|
|
|
46
46
|
export function buildOutpostCapacityCommand() {
|
|
47
47
|
return withRemoteHqDir(joinCommandParts(["hq", "lanes", "capacity", "--json"]));
|
|
48
48
|
}
|
|
49
|
+
/** Read the lane records owned by an Outpost for exact per-account state counts. */
|
|
50
|
+
export function buildOutpostLaneListCommand() {
|
|
51
|
+
return withRemoteHqDir(joinCommandParts(["hq", "lanes", "list", "--json"]));
|
|
52
|
+
}
|
|
49
53
|
/** Commit a queued lane move on the host that currently owns its state. */
|
|
50
54
|
export function buildOutpostMoveTargetCommand(input) {
|
|
51
55
|
return withRemoteHqDir(joinCommandParts([
|
|
@@ -61,6 +65,21 @@ export function buildOutpostMoveTargetCommand(input) {
|
|
|
61
65
|
input.account,
|
|
62
66
|
]));
|
|
63
67
|
}
|
|
68
|
+
/** Move an owned Claude session between accounts while leaving its turn parked. */
|
|
69
|
+
export function buildOutpostBalanceMoveCommand(input) {
|
|
70
|
+
return withRemoteHqDir(joinCommandParts([
|
|
71
|
+
"hq",
|
|
72
|
+
"lanes",
|
|
73
|
+
"balance-move-target",
|
|
74
|
+
input.laneId,
|
|
75
|
+
"--account",
|
|
76
|
+
input.account,
|
|
77
|
+
"--from-account",
|
|
78
|
+
input.fromAccount,
|
|
79
|
+
"--expected-state",
|
|
80
|
+
input.expectedState,
|
|
81
|
+
]));
|
|
82
|
+
}
|
|
64
83
|
/** Idempotently backfill the marker on the selected Outpost. */
|
|
65
84
|
export function buildOutpostIdentityBackfillCommand(expectedId) {
|
|
66
85
|
return withRemoteHqDir(joinCommandParts([
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -473,6 +473,9 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
473
473
|
path: filePath,
|
|
474
474
|
});
|
|
475
475
|
}
|
|
476
|
+
if (raw.account_selection !== undefined) {
|
|
477
|
+
parseLaneAccountSelection(raw.account_selection, filePath);
|
|
478
|
+
}
|
|
476
479
|
if (raw.placement_default !== undefined) {
|
|
477
480
|
const placement = raw.placement_default;
|
|
478
481
|
if (!isPlainObject(placement) ||
|
|
@@ -494,6 +497,89 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
494
497
|
// a parse/rewrite cycle (spec §5.1 field-preservation).
|
|
495
498
|
return raw;
|
|
496
499
|
}
|
|
500
|
+
function parseLaneAccountSelection(value, filePath) {
|
|
501
|
+
const failure = (field) => new LaneSchemaError(`Invalid account_selection.${field}`, {
|
|
502
|
+
path: filePath,
|
|
503
|
+
field: `account_selection.${field}`,
|
|
504
|
+
});
|
|
505
|
+
if (!isPlainObject(value))
|
|
506
|
+
throw failure("must be an object");
|
|
507
|
+
const selection = value;
|
|
508
|
+
if (typeof selection.account !== "string" || selection.account.trim() === "")
|
|
509
|
+
throw failure("account");
|
|
510
|
+
if (selection.signal !== "usage" && selection.signal !== "lane_count" && selection.signal !== "explicit")
|
|
511
|
+
throw failure("signal");
|
|
512
|
+
if (selection.reason !== "least_loaded" && selection.reason !== "explicit")
|
|
513
|
+
throw failure("reason");
|
|
514
|
+
if (typeof selection.selected_at !== "string" || !Number.isFinite(Date.parse(selection.selected_at)))
|
|
515
|
+
throw failure("selected_at");
|
|
516
|
+
const parseHost = (host, field) => {
|
|
517
|
+
if (!isPlainObject(host) || !isHostKind(host.kind) || typeof host.id !== "string" || host.id.trim() === "")
|
|
518
|
+
throw failure(field);
|
|
519
|
+
};
|
|
520
|
+
parseHost(selection.target_host, "target_host");
|
|
521
|
+
if (!Array.isArray(selection.considered) || !Array.isArray(selection.skipped_accounts) || !Array.isArray(selection.unavailable_hosts)) {
|
|
522
|
+
throw failure("arrays");
|
|
523
|
+
}
|
|
524
|
+
for (const [index, rowValue] of selection.considered.entries()) {
|
|
525
|
+
if (!isPlainObject(rowValue))
|
|
526
|
+
throw failure(`considered.${index}`);
|
|
527
|
+
const row = rowValue;
|
|
528
|
+
if (typeof row.account !== "string" || row.account.trim() === "" || typeof row.eligible !== "boolean" || typeof row.live_lanes !== "number" || !Number.isInteger(row.live_lanes) || row.live_lanes < 0 || typeof row.counts_complete !== "boolean" || !Array.isArray(row.hosts))
|
|
529
|
+
throw failure(`considered.${index}`);
|
|
530
|
+
if (row.ineligible_reason !== undefined && typeof row.ineligible_reason !== "string")
|
|
531
|
+
throw failure(`considered.${index}.ineligible_reason`);
|
|
532
|
+
if (row.usage !== null) {
|
|
533
|
+
if (!isPlainObject(row.usage))
|
|
534
|
+
throw failure(`considered.${index}.usage`);
|
|
535
|
+
for (const key of ["five_hour_percent", "weekly_percent"]) {
|
|
536
|
+
const percent = row.usage[key];
|
|
537
|
+
if (percent !== undefined && (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100))
|
|
538
|
+
throw failure(`considered.${index}.usage.${key}`);
|
|
539
|
+
}
|
|
540
|
+
if (row.usage.sampled_at !== undefined && (typeof row.usage.sampled_at !== "string" || !Number.isFinite(Date.parse(row.usage.sampled_at))))
|
|
541
|
+
throw failure(`considered.${index}.usage.sampled_at`);
|
|
542
|
+
}
|
|
543
|
+
for (const [hostIndex, hostValue] of row.hosts.entries()) {
|
|
544
|
+
if (!isPlainObject(hostValue))
|
|
545
|
+
throw failure(`considered.${index}.hosts.${hostIndex}`);
|
|
546
|
+
const host = hostValue;
|
|
547
|
+
parseHost(host.host, `considered.${index}.hosts.${hostIndex}.host`);
|
|
548
|
+
if (typeof host.enabled !== "boolean" || !(host.logged_in === null || typeof host.logged_in === "boolean"))
|
|
549
|
+
throw failure(`considered.${index}.hosts.${hostIndex}.status`);
|
|
550
|
+
if (!(host.live_lanes === null || (typeof host.live_lanes === "number" && Number.isInteger(host.live_lanes) && host.live_lanes >= 0)))
|
|
551
|
+
throw failure(`considered.${index}.hosts.${hostIndex}.live_lanes`);
|
|
552
|
+
if (!isPlainObject(host.lane_counts))
|
|
553
|
+
throw failure(`considered.${index}.hosts.${hostIndex}.lane_counts`);
|
|
554
|
+
for (const key of ["running", "queued", "awaiting_input"]) {
|
|
555
|
+
const count = host.lane_counts[key];
|
|
556
|
+
if (!(count === null || (typeof count === "number" && Number.isInteger(count) && count >= 0)))
|
|
557
|
+
throw failure(`considered.${index}.hosts.${hostIndex}.lane_counts.${key}`);
|
|
558
|
+
}
|
|
559
|
+
if (!["lane_list", "capacity", "capacity_partial", "unavailable"].includes(String(host.count_source)))
|
|
560
|
+
throw failure(`considered.${index}.hosts.${hostIndex}.count_source`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
for (const [index, rowValue] of selection.skipped_accounts.entries()) {
|
|
564
|
+
if (!isPlainObject(rowValue))
|
|
565
|
+
throw failure(`skipped_accounts.${index}`);
|
|
566
|
+
const row = rowValue;
|
|
567
|
+
if (typeof row.account !== "string" || row.account.trim() === "" || typeof row.reason !== "string" || row.reason.trim() === "")
|
|
568
|
+
throw failure(`skipped_accounts.${index}`);
|
|
569
|
+
parseHost(row.host, `skipped_accounts.${index}.host`);
|
|
570
|
+
}
|
|
571
|
+
for (const [index, rowValue] of selection.unavailable_hosts.entries()) {
|
|
572
|
+
if (!isPlainObject(rowValue))
|
|
573
|
+
throw failure(`unavailable_hosts.${index}`);
|
|
574
|
+
const row = rowValue;
|
|
575
|
+
if (typeof row.reason !== "string" || row.reason.trim() === "")
|
|
576
|
+
throw failure(`unavailable_hosts.${index}`);
|
|
577
|
+
if (isPlainObject(row.host) && row.host.kind === "unknown" && row.host.id === "unknown")
|
|
578
|
+
continue;
|
|
579
|
+
parseHost(row.host, `unavailable_hosts.${index}.host`);
|
|
580
|
+
}
|
|
581
|
+
return selection;
|
|
582
|
+
}
|
|
497
583
|
export function parseQuestionRecord(raw, filePath) {
|
|
498
584
|
if (!isPlainObject(raw)) {
|
|
499
585
|
throw new LaneSchemaError("Question record must be a JSON object", {
|