@indigoai-us/hq-cli 5.186.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 +21 -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/commands/mesh.js +28 -7
- 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/dist/lib/mesh/live/report-summary.js +13 -0
- package/dist/lib/mesh/project-board.d.ts +9 -0
- package/dist/lib/mesh/project-board.js +9 -0
- package/package.json +1 -1
package/dist/commands/mesh.js
CHANGED
|
@@ -14,7 +14,7 @@ import chalk from "chalk";
|
|
|
14
14
|
import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
|
|
15
15
|
import * as readline from "node:readline/promises";
|
|
16
16
|
import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
17
|
-
import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, formatStoryStatusChanges, mergeProjectStories, } from "../lib/mesh/project-board.js";
|
|
17
|
+
import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, formatStoryStatusChanges, mergeProjectStories, prdSyncEventsThatChangeBoard, } from "../lib/mesh/project-board.js";
|
|
18
18
|
import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
|
|
19
19
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
20
20
|
import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
@@ -603,7 +603,25 @@ async function runPrdSync(opts) {
|
|
|
603
603
|
...(bound?.projectId ? { project: bound.projectId } : {}),
|
|
604
604
|
touchedPaths: [resolvePath(file)],
|
|
605
605
|
};
|
|
606
|
+
let toEmit = plan.events;
|
|
606
607
|
if (bound?.companyUid && bound.projectId) {
|
|
608
|
+
try {
|
|
609
|
+
const live = await getProjectView(token, bound.companyUid, bound.projectId);
|
|
610
|
+
toEmit = prdSyncEventsThatChangeBoard(plan.events, live.stories);
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
// Board unread — emit the snapshot diff; the server drops same-status.
|
|
614
|
+
}
|
|
615
|
+
if (toEmit.length === 0) {
|
|
616
|
+
plan.commit();
|
|
617
|
+
if (opts.json) {
|
|
618
|
+
console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: 0 }));
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
console.log("Work mesh: prd-sync unchanged");
|
|
622
|
+
}
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
607
625
|
try {
|
|
608
626
|
const source = parseProjectPlan(readFileSync(file, "utf8"), file);
|
|
609
627
|
await ensureMissingBoardStories({
|
|
@@ -617,7 +635,7 @@ async function runPrdSync(opts) {
|
|
|
617
635
|
description: source.description ?? "",
|
|
618
636
|
stories: source.stories,
|
|
619
637
|
repos: source.repos ?? [],
|
|
620
|
-
requiredIds:
|
|
638
|
+
requiredIds: toEmit.map((event) => event.taskId),
|
|
621
639
|
});
|
|
622
640
|
}
|
|
623
641
|
catch (err) {
|
|
@@ -625,9 +643,12 @@ async function runPrdSync(opts) {
|
|
|
625
643
|
console.error(`work-mesh: prd-sync ensure failed; emitting anyway (${reason.slice(0, 160)})`);
|
|
626
644
|
}
|
|
627
645
|
}
|
|
646
|
+
const alreadyOnBoard = plan.events
|
|
647
|
+
.filter((event) => !toEmit.some((row) => row.taskId === event.taskId))
|
|
648
|
+
.map((event) => event.taskId);
|
|
628
649
|
const meshRoot = workMeshHomeRoot();
|
|
629
|
-
const seqFirst = reserveSessionSeq(sessionId,
|
|
630
|
-
const events =
|
|
650
|
+
const seqFirst = reserveSessionSeq(sessionId, toEmit.length);
|
|
651
|
+
const events = toEmit.map((event, index) => buildMeshEmitEvent({
|
|
631
652
|
eventId: generateUlid(Date.now() + index),
|
|
632
653
|
kind: "task_status",
|
|
633
654
|
sessionId,
|
|
@@ -659,7 +680,7 @@ async function runPrdSync(opts) {
|
|
|
659
680
|
blocked.add(id);
|
|
660
681
|
}
|
|
661
682
|
const advanced = events.flatMap((event) => (event.eventId && event.taskId && !blocked.has(event.eventId) ? [event.taskId] : []));
|
|
662
|
-
plan.commit(advanced);
|
|
683
|
+
plan.commit([...alreadyOnBoard, ...advanced]);
|
|
663
684
|
if (plan.taskId && advanced.includes(plan.taskId)) {
|
|
664
685
|
updateSessionState(sessionId, root, (current) => {
|
|
665
686
|
if (!current)
|
|
@@ -673,10 +694,10 @@ async function runPrdSync(opts) {
|
|
|
673
694
|
});
|
|
674
695
|
}
|
|
675
696
|
if (opts.json) {
|
|
676
|
-
console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed:
|
|
697
|
+
console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: toEmit.length, taskId: plan.taskId }));
|
|
677
698
|
return;
|
|
678
699
|
}
|
|
679
|
-
console.log(`Work mesh: prd-sync emitted ${
|
|
700
|
+
console.log(`Work mesh: prd-sync emitted ${toEmit.length} task_status`);
|
|
680
701
|
}
|
|
681
702
|
function noteCallingSessionBound(projectId, company) {
|
|
682
703
|
try {
|
|
@@ -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([
|