@opsee/cli 0.11.13 → 0.11.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/package.json +2 -2
- package/src/args.ts +167 -15
- package/src/cli.ts +62 -6
- package/src/commands/account-usage.ts +106 -0
- package/src/commands/account.ts +134 -1
- package/src/commands/claude-launcher.ts +183 -0
- package/src/commands/foreman-up.ts +184 -4
- package/src/commands/foreman.ts +133 -11
- package/src/foreman/account.ts +106 -13
- package/src/foreman/claude-worker-adapter.ts +100 -1
- package/src/foreman/config-skeleton.ts +167 -0
- package/src/foreman/core/run.ts +157 -7
- package/src/foreman/core/scheduler.ts +139 -0
- package/src/foreman/core/text.ts +29 -0
- package/src/foreman/credential-store.ts +247 -0
- package/src/foreman/usage-activity.ts +102 -0
- package/src/foreman/usage-format.ts +107 -0
- package/src/foreman/usage-poller.ts +489 -0
- package/src/foreman/usage-store.ts +213 -0
- package/src/foreman/usage.ts +257 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/worker-adapter.ts +12 -0
package/src/foreman/core/run.ts
CHANGED
|
@@ -83,11 +83,16 @@ import {
|
|
|
83
83
|
pausedUntil,
|
|
84
84
|
pauseUntilFrom,
|
|
85
85
|
quarantineAccount,
|
|
86
|
+
thresholdFor,
|
|
86
87
|
type Account,
|
|
87
88
|
} from "../account.js";
|
|
88
89
|
import type { AccountStore } from "../account-store.js";
|
|
89
90
|
import type { CompletionReport } from "../completion-report.js";
|
|
90
91
|
import { blockerIdsOf, DISPATCH_LABEL, type TaskWithContext, type TrackerAdapter, type TrackerTask } from "../tracker-adapter.js";
|
|
92
|
+
import { errorMessage } from "../worker-process.js";
|
|
93
|
+
import { thresholdHold, type AccountUsage, type ThresholdHold } from "../usage.js";
|
|
94
|
+
import { usageByAccount, type UsageStore } from "../usage-store.js";
|
|
95
|
+
import { recordTurn } from "../usage-activity.js";
|
|
91
96
|
import type { Vendor } from "../vendor.js";
|
|
92
97
|
import type { Verdict } from "../verdict.js";
|
|
93
98
|
import type { AdapterEvent, TurnFailureReason, TurnHandle, WorkerAdapter } from "../worker-adapter.js";
|
|
@@ -103,7 +108,7 @@ import { describePin, pinAllows, pinOf, pinToken, unplaceable, type Pin, type Un
|
|
|
103
108
|
import { otherForemanWorkersOn, type DrainResult, type ProcessTableApi, type WorkerRow } from "./process-table.js";
|
|
104
109
|
import { reconcile } from "./reconcile.js";
|
|
105
110
|
import { failureComment, reportComment, reportToMemory } from "./report.js";
|
|
106
|
-
import { orderReadyTasks, priorityScale, Slots, type SlotHold } from "./scheduler.js";
|
|
111
|
+
import { DEFAULT_RANKING_POLICY, orderReadyTasks, priorityScale, rankLanes, Slots, type RankingPolicy, type RankingState, type SlotHold } from "./scheduler.js";
|
|
107
112
|
import { runSummaryComment } from "./summary.js";
|
|
108
113
|
import { count, printableOneLine } from "./text.js";
|
|
109
114
|
import { driveTriageTurn, isTriageSummary, TRIAGE_MARKER } from "./triage.js";
|
|
@@ -304,6 +309,19 @@ export interface RunDeps {
|
|
|
304
309
|
/** How long an Account is Paused when the vendor named no reset; `DEFAULT_PAUSE_MS` when unset,
|
|
305
310
|
* and never below `MIN_PAUSE_MS`, since a window of zero is no pause at all. */
|
|
306
311
|
pauseMs?: number;
|
|
312
|
+
/**
|
|
313
|
+
* Where each Account's rate-limit windows are kept (usage-store.ts), so the Run can prefer the
|
|
314
|
+
* Account with room and stop offering work to one that is nearly spent (the multi-account
|
|
315
|
+
* headroom design). Read every tick, and written whenever a Worker reports usage in band.
|
|
316
|
+
*
|
|
317
|
+
* Without one the Run behaves exactly as it did before usage existed: Lanes in registration
|
|
318
|
+
* order, and a rate limit discovered by being refused. Usage is an optimisation and never a
|
|
319
|
+
* dependency, which is also why every failure inside it degrades to that.
|
|
320
|
+
*/
|
|
321
|
+
usage?: UsageStore;
|
|
322
|
+
/** How Lanes are ordered, and how much the order is smoothed (`DEFAULT_RANKING_POLICY`, which is
|
|
323
|
+
* registration order: upgrading must not silently move anyone's Tasks). */
|
|
324
|
+
ranking?: RankingPolicy;
|
|
307
325
|
workspaces: Pick<WorkspaceManager, "create">;
|
|
308
326
|
/** Installs dependencies in a new Workspace; resolves undefined when there is nothing to install.
|
|
309
327
|
* Defaults to the Run Recipe's `commands.install`, else the lockfile inference in install.ts. */
|
|
@@ -728,6 +746,37 @@ export async function runForeman(deps: RunDeps, options: RunOptions): Promise<Ru
|
|
|
728
746
|
return quarantined;
|
|
729
747
|
};
|
|
730
748
|
|
|
749
|
+
/**
|
|
750
|
+
* What is known about each Lane's usage right now. Re-read from the store every tick for the same
|
|
751
|
+
* reason `pausedNow` re-reads the Account store: a Worker on another Lane — or another Foreman,
|
|
752
|
+
* or the daemon's poller — may have observed something since this tick began.
|
|
753
|
+
*/
|
|
754
|
+
const usageNow = (): Map<string, AccountUsage> => usageByAccount(deps.usage);
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Which Lanes are held back at their threshold, and until when (`thresholdHold`).
|
|
758
|
+
*
|
|
759
|
+
* Derived from usage and policy every tick, never stored: raising `--threshold` lifts a hold with
|
|
760
|
+
* nothing to clear, a window resetting lifts one by itself, and a hold cannot outlive the fact
|
|
761
|
+
* behind it. That is the difference between this and Paused, which *is* stored, because a refusal
|
|
762
|
+
* already happened and stays true however long the Foreman is away.
|
|
763
|
+
*/
|
|
764
|
+
const heldNow = (known: ReadonlyMap<string, AccountUsage>, at: number): Map<string, ThresholdHold> => {
|
|
765
|
+
const held = new Map<string, ThresholdHold>();
|
|
766
|
+
for (const lane of lanes) {
|
|
767
|
+
const hold = thresholdHold(known.get(lane.account.name), thresholdFor(lane.account), at);
|
|
768
|
+
if (hold) held.set(lane.account.name, hold);
|
|
769
|
+
}
|
|
770
|
+
return held;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/** The Lane order carried between ticks, so hysteresis and the cooldown have something to smooth.
|
|
774
|
+
* Per Run and not on disk: a fresh Run should rank on what is true now rather than inherit a
|
|
775
|
+
* leader chosen hours ago by a process that has since exited. */
|
|
776
|
+
let rankingState: RankingState = {};
|
|
777
|
+
/** Holds already reported, by the breach reported, so the log says it once per breach rather than
|
|
778
|
+
* once per tick — and says it again when the breach moves on. */
|
|
779
|
+
const heldLogged = new Map<string, string>();
|
|
731
780
|
/** Lanes already reported as quarantined, so the log says it once rather than once per tick. */
|
|
732
781
|
const quarantineLogged = new Set<string>();
|
|
733
782
|
|
|
@@ -1051,6 +1100,33 @@ export async function runForeman(deps: RunDeps, options: RunOptions): Promise<Ru
|
|
|
1051
1100
|
// `pinnableAccountsFor` (commands/foreman.ts) can only ask before the Run starts, and a
|
|
1052
1101
|
// Lane that was latent then may be a second Foreman's by the time it is first used.
|
|
1053
1102
|
const contended = contendedNow(now());
|
|
1103
|
+
const known = usageNow();
|
|
1104
|
+
const held = heldNow(known, now());
|
|
1105
|
+
// Ranked once for the tick, over *every* open Lane (core/scheduler.ts `rankLanes`):
|
|
1106
|
+
// registration order by default, most headroom first under `best`, soonest reset first
|
|
1107
|
+
// under `consume-first`.
|
|
1108
|
+
//
|
|
1109
|
+
// Once per tick and not once per Task, because the ranking is also what carries hysteresis
|
|
1110
|
+
// and the cooldown. Ranking each Task's own eligible subset would write that subset into
|
|
1111
|
+
// `rankingState`: a Task pinned to one Account would store a one-Lane order, and for the
|
|
1112
|
+
// whole cooldown every other Lane would sort behind it as a newcomer — sending unpinned
|
|
1113
|
+
// work to the pinned Account. Each Task then orders its own Lanes by this one ranking.
|
|
1114
|
+
const ranked = rankLanes(openLanes, known, deps.ranking ?? DEFAULT_RANKING_POLICY, rankingState, now());
|
|
1115
|
+
rankingState = ranked.state;
|
|
1116
|
+
const laneRank = new Map(ranked.lanes.map((lane, at) => [lane.account.name, at]));
|
|
1117
|
+
/** This Task's Lanes in the tick's order. A pin-only Lane is not in the ranking — it is not
|
|
1118
|
+
* an open Lane — so it sorts after the ranked ones, in the order the pin offered it. */
|
|
1119
|
+
const inRankOrder = <T extends { account: { name: string } }>(lanes: T[]): T[] =>
|
|
1120
|
+
[...lanes].sort((a, b) => (laneRank.get(a.account.name) ?? laneRank.size) - (laneRank.get(b.account.name) ?? laneRank.size));
|
|
1121
|
+
for (const [name, hold] of held) {
|
|
1122
|
+
if (heldLogged.get(name) === hold.reason) continue;
|
|
1123
|
+
heldLogged.set(name, hold.reason);
|
|
1124
|
+
log(
|
|
1125
|
+
`account: "${name}" is held back until ${hold.until.toISOString()} (${hold.reason}): it is at or past its threshold, so no new Worker ` +
|
|
1126
|
+
`starts on it until that window resets. Nothing was refused — "opsee foreman account set ${name} --threshold <pct>" spends the rest.`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
for (const name of [...heldLogged.keys()]) if (!held.has(name)) heldLogged.delete(name);
|
|
1054
1130
|
let taken = 0;
|
|
1055
1131
|
for (const task of candidates) {
|
|
1056
1132
|
// Held back after a retryable failure, so its next attempt is not taken as fast as the
|
|
@@ -1126,14 +1202,27 @@ export async function runForeman(deps: RunDeps, options: RunOptions): Promise<Ru
|
|
|
1126
1202
|
}
|
|
1127
1203
|
continue;
|
|
1128
1204
|
}
|
|
1129
|
-
|
|
1205
|
+
// Held is asked after Paused and before contention, for the reason quarantine is asked
|
|
1206
|
+
// before Paused: of the facts that take a Lane out of rotation this is the one the Foreman
|
|
1207
|
+
// chose for itself, so it should not stand in front of one the vendor stated.
|
|
1208
|
+
const notHeld = notPaused.filter((l) => !held.has(l.account.name));
|
|
1209
|
+
if (notHeld.length === 0) {
|
|
1210
|
+
// Every Lane this Task may use is held at its threshold: it waits for the soonest of
|
|
1211
|
+
// their windows to reset, exactly as it waits out a pause, and needs nobody to act.
|
|
1212
|
+
for (const lane of notPaused) {
|
|
1213
|
+
const until = held.get(lane.account.name)!.until;
|
|
1214
|
+
if (!waitingOnReset || until < waitingOnReset) waitingOnReset = until;
|
|
1215
|
+
}
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
const open = notHeld.filter((l) => !contended.has(l.account.name));
|
|
1130
1219
|
if (open.length === 0) {
|
|
1131
1220
|
// Only Lanes another Foreman has Workers on are left: opening Slots of our own there
|
|
1132
1221
|
// would double one vendor identity's concurrency (ADR-0013), so the Task waits.
|
|
1133
1222
|
laneContended = true;
|
|
1134
1223
|
continue;
|
|
1135
1224
|
}
|
|
1136
|
-
const lane = open.find((l) => l.slots.free > 0);
|
|
1225
|
+
const lane = inRankOrder(open).find((l) => l.slots.free > 0);
|
|
1137
1226
|
if (!lane) {
|
|
1138
1227
|
slotLimited = true;
|
|
1139
1228
|
continue;
|
|
@@ -1146,13 +1235,31 @@ export async function runForeman(deps: RunDeps, options: RunOptions): Promise<Ru
|
|
|
1146
1235
|
// Why the Run's own Account was passed over. There are two answers now, and the wrong
|
|
1147
1236
|
// one is worse than none: a quarantine has no reset, so the pause wording would print
|
|
1148
1237
|
// "Paused until ?" for a state that is not a pause and never lifts (OPS-288).
|
|
1149
|
-
const
|
|
1150
|
-
|
|
1151
|
-
|
|
1238
|
+
const first = lanes[0].account.name;
|
|
1239
|
+
const hold = held.get(first);
|
|
1240
|
+
// A fourth answer now: the strategy simply ranked another Lane higher. That is not a
|
|
1241
|
+
// Failover — nothing is wrong with the Run's own Account — and calling it one would
|
|
1242
|
+
// send a reader hunting for a limit that was never hit.
|
|
1243
|
+
const byRanking = !quarantined.has(first) && !paused.has(first) && !hold && !contended.has(first) && lanes[0].slots.free > 0;
|
|
1244
|
+
// Every reason the Run's own Account can be passed over, in the order they are asked
|
|
1245
|
+
// above. The last one is the ordinary case and was missing: an Account that is perfectly
|
|
1246
|
+
// healthy and simply has every Slot busy. Without it a slot-limited Lane was reported as
|
|
1247
|
+
// another Foreman's, which sends a reader looking for a second Foreman that is not there.
|
|
1248
|
+
const why = quarantined.has(first)
|
|
1249
|
+
? `"${first}" is quarantined (${printableOneLine(quarantined.get(first) ?? "")})`
|
|
1250
|
+
: hold
|
|
1251
|
+
? `"${first}" is held back until ${hold.until.toISOString()} (${hold.reason})`
|
|
1252
|
+
: paused.has(first)
|
|
1253
|
+
? `"${first}" is Paused until ${paused.get(first)?.toISOString() ?? "?"}`
|
|
1254
|
+
: contended.has(first)
|
|
1255
|
+
? `"${first}" has Workers under another Foreman`
|
|
1256
|
+
: `"${first}" has no free Slot`;
|
|
1152
1257
|
log(
|
|
1153
1258
|
pin && pinAllows(pin, lane.account)
|
|
1154
1259
|
? `run: ${task.identifier} runs on Account "${lane.account.name}" (${lane.account.vendor}): it is ${describePin(pin)}`
|
|
1155
|
-
:
|
|
1260
|
+
: byRanking
|
|
1261
|
+
? `run: ${task.identifier} runs on Account "${lane.account.name}": the "${deps.ranking?.strategy ?? DEFAULT_RANKING_POLICY.strategy}" strategy ranks it ahead of "${first}"`
|
|
1262
|
+
: `run: ${task.identifier} fails over to Account "${lane.account.name}": ${why}`,
|
|
1156
1263
|
);
|
|
1157
1264
|
}
|
|
1158
1265
|
fill(task, lane, lane.slots.acquire());
|
|
@@ -2056,10 +2163,26 @@ async function followTurn(deps: RunDeps, task: TrackerTask, handle: TurnHandle,
|
|
|
2056
2163
|
sink.rateLimit = { resetAt: event.resetAt, message: event.message };
|
|
2057
2164
|
log(`worker: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
|
|
2058
2165
|
break;
|
|
2166
|
+
case "usage": {
|
|
2167
|
+
// The vendor's own numbers, recorded against the Lane's Account: `deps` here is the Lane's
|
|
2168
|
+
// `Scheduling`, so `deps.account` is the identity this turn really ran under rather than
|
|
2169
|
+
// the Run's first Account (the same reason `pauseOnRateLimit` reads it from there).
|
|
2170
|
+
const said = Object.entries(event.windows)
|
|
2171
|
+
.map(([name, w]) => `${name} ${w.usedPct}%`)
|
|
2172
|
+
.join(", ");
|
|
2173
|
+
log(`worker: usage on Account "${deps.account.name}": ${said}`);
|
|
2174
|
+
recordUsage(deps, () => deps.usage?.observe(deps.account.name, event.windows, now()));
|
|
2175
|
+
break;
|
|
2176
|
+
}
|
|
2059
2177
|
case "stalled":
|
|
2060
2178
|
log(`worker: stalled, no output for ${event.silentMs}ms`);
|
|
2061
2179
|
break;
|
|
2062
2180
|
case "completed":
|
|
2181
|
+
// The Account's activity record (usage-activity.ts): what this Foreman has itself spent.
|
|
2182
|
+
// `costUsd` has ridden this event since the adapter was written and was consumed by nothing.
|
|
2183
|
+
recordUsage(deps, () => deps.usage && recordTurn(deps.usage, deps.account.name, now(), event.costUsd));
|
|
2184
|
+
terminal = event;
|
|
2185
|
+
break;
|
|
2063
2186
|
case "failed":
|
|
2064
2187
|
terminal = event;
|
|
2065
2188
|
break;
|
|
@@ -2069,6 +2192,33 @@ async function followTurn(deps: RunDeps, task: TrackerTask, handle: TurnHandle,
|
|
|
2069
2192
|
return terminal;
|
|
2070
2193
|
}
|
|
2071
2194
|
|
|
2195
|
+
/** Runs that have already reported a usage-store failure, so the Run says it once rather than once
|
|
2196
|
+
* a turn. Keyed by the deps object the Run was built with, which is one per Run. */
|
|
2197
|
+
const usageWriteWarned = new WeakSet<object>();
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* Writes usage, and swallows any failure.
|
|
2201
|
+
*
|
|
2202
|
+
* `FileUsageStore` rethrows what it cannot write — a full disk, a read-only home, a directory owned
|
|
2203
|
+
* by somebody else — and this is called from inside `followTurn`'s event loop, where a throw would
|
|
2204
|
+
* escape before the turn's terminal event is recorded. A completed turn would then be settled as a
|
|
2205
|
+
* failed dispatch: its Hand-off, its report and its Run Record lost because the telemetry beside it
|
|
2206
|
+
* could not be written. Usage is an optimisation and never a dependency (the multi-account headroom
|
|
2207
|
+
* design, §7), and this is the seam where that is enforced rather than merely asserted.
|
|
2208
|
+
*/
|
|
2209
|
+
function recordUsage(deps: RunDeps, write: () => void): void {
|
|
2210
|
+
try {
|
|
2211
|
+
write();
|
|
2212
|
+
} catch (error) {
|
|
2213
|
+
if (usageWriteWarned.has(deps)) return;
|
|
2214
|
+
usageWriteWarned.add(deps);
|
|
2215
|
+
deps.log(
|
|
2216
|
+
`usage: could not be recorded for Account "${deps.account.name}" (${errorMessage(error)}); this Run carries on without it, ` +
|
|
2217
|
+
`and dispatch falls back to reacting to rate limits as they happen. Said once for this Run.`,
|
|
2218
|
+
);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2072
2222
|
/** Where a `done` turn stands after its Hand-off (and, when it opened, its Gates). */
|
|
2073
2223
|
interface DoneState {
|
|
2074
2224
|
terminal: CompletedEvent;
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* implemented. `reservesVerifierSlot` says which of the two an Account is.
|
|
31
31
|
*/
|
|
32
32
|
import type { TrackerTask } from "../tracker-adapter.js";
|
|
33
|
+
import { headroom, type AccountUsage, type Headroom } from "../usage.js";
|
|
33
34
|
|
|
34
35
|
/** What `orderReadyTasks` needs of a Task: the two sort keys and the id that breaks their ties. */
|
|
35
36
|
export type Orderable = Pick<TrackerTask, "id" | "priorityLevel" | "createdAt">;
|
|
@@ -242,3 +243,141 @@ export class Slots {
|
|
|
242
243
|
for (const resolve of waiting) resolve();
|
|
243
244
|
}
|
|
244
245
|
}
|
|
246
|
+
|
|
247
|
+
/* ------------------------------------------------------------------------------------------------
|
|
248
|
+
* Lane ranking (the multi-account headroom design, §4).
|
|
249
|
+
*
|
|
250
|
+
* Which of the open Lanes a Ready Task is offered first. Until this existed the answer was the
|
|
251
|
+
* order the Accounts were registered in — `open.find((l) => l.slots.free > 0)` in core/run.ts —
|
|
252
|
+
* which drains Account #1 into a refusal before Account #2 is asked. Pure like the rest of this
|
|
253
|
+
* file: a function of the Lanes, what is known about their usage, a policy and a clock.
|
|
254
|
+
* ---------------------------------------------------------------------------------------------- */
|
|
255
|
+
|
|
256
|
+
/** How Lanes are ordered. */
|
|
257
|
+
export type DispatchStrategy =
|
|
258
|
+
/** Registration order: exactly the behaviour before this existed, and the default for one
|
|
259
|
+
* release so that upgrading cannot silently change where anyone's Tasks run. */
|
|
260
|
+
| "order"
|
|
261
|
+
/** Most headroom first. What an overnight Run across several Accounts wants. */
|
|
262
|
+
| "best"
|
|
263
|
+
/** Soonest-resetting Account first, so one Account is drained before the next is touched. This
|
|
264
|
+
* is "serial rotation" as a policy: one Account effectively active at a time, without a second
|
|
265
|
+
* architecture beside the Lane model. */
|
|
266
|
+
| "consume-first";
|
|
267
|
+
|
|
268
|
+
export interface RankingPolicy {
|
|
269
|
+
strategy: DispatchStrategy;
|
|
270
|
+
/** Points of headroom a challenger must beat the incumbent leader by before the leader changes.
|
|
271
|
+
* Without it two Accounts at 61% and 60% swap places on every dispatch. */
|
|
272
|
+
hysteresisPct: number;
|
|
273
|
+
/** The shortest interval between re-rankings; inside it the previous order is reused. */
|
|
274
|
+
cooldownMs: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const DEFAULT_RANKING_POLICY: RankingPolicy = {
|
|
278
|
+
strategy: "order",
|
|
279
|
+
hysteresisPct: 10,
|
|
280
|
+
cooldownMs: 5 * 60_000,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* What one ranking leaves behind for the next: the order it produced and when it was taken.
|
|
285
|
+
*
|
|
286
|
+
* Held by the Run rather than on disk. Hysteresis and cooldown are within-Run smoothing — they stop
|
|
287
|
+
* a tick sequence from oscillating — and a fresh Run should rank on what is true now rather than
|
|
288
|
+
* inherit a leader chosen hours ago by a process that has since exited.
|
|
289
|
+
*/
|
|
290
|
+
export interface RankingState {
|
|
291
|
+
ranking?: string[];
|
|
292
|
+
at?: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The least a Lane must be for ranking: its Account's name, which is how usage is keyed. */
|
|
296
|
+
export interface RankableLane {
|
|
297
|
+
account: { name: string };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The Lanes in the order a Ready Task should try them, and the state the next call needs.
|
|
302
|
+
*
|
|
303
|
+
* Ranks and never filters: every Lane handed in comes back exactly once. Whether a Lane may be used
|
|
304
|
+
* at all — quarantined, Paused, another Foreman's, out of Slots — is decided in core/run.ts before
|
|
305
|
+
* and after this, and deliberately not here, so that this stays a question about preference and
|
|
306
|
+
* that stays a question about eligibility.
|
|
307
|
+
*/
|
|
308
|
+
export function rankLanes<T extends RankableLane>(
|
|
309
|
+
lanes: readonly T[],
|
|
310
|
+
usage: ReadonlyMap<string, AccountUsage>,
|
|
311
|
+
policy: RankingPolicy,
|
|
312
|
+
state: RankingState,
|
|
313
|
+
now: number,
|
|
314
|
+
): { lanes: T[]; state: RankingState } {
|
|
315
|
+
// Registration order has nothing to smooth and no scores to compare, so it skips both devices
|
|
316
|
+
// below rather than paying for a ranking that cannot change.
|
|
317
|
+
if (policy.strategy === "order") {
|
|
318
|
+
return { lanes: [...lanes], state: { ranking: lanes.map((l) => l.account.name), at: now } };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const scored = lanes.map((lane, index) => ({ lane, index, headroom: headroom(usage.get(lane.account.name), now) }));
|
|
322
|
+
|
|
323
|
+
// Inside the cooldown the previous order stands. `at` is carried through unchanged rather than
|
|
324
|
+
// restarted, so a cooldown is an interval between rankings and not something a reader can extend
|
|
325
|
+
// indefinitely by asking often.
|
|
326
|
+
if (state.at !== undefined && state.ranking && now - state.at < policy.cooldownMs) {
|
|
327
|
+
const previous = state.ranking;
|
|
328
|
+
// Both orders are computed once, outside the comparator. Inside it, the newcomer case re-sorted
|
|
329
|
+
// the whole set and scanned it on every comparison — fine at two Accounts, the wrong shape at
|
|
330
|
+
// any number.
|
|
331
|
+
const settled = new Map(previous.map((name, at) => [name, at] as const));
|
|
332
|
+
const computed = new Map(
|
|
333
|
+
[...scored]
|
|
334
|
+
.sort((a, b) => compare(a, b, policy.strategy) || a.index - b.index)
|
|
335
|
+
.map((s, at) => [s.lane.account.name, at] as const),
|
|
336
|
+
);
|
|
337
|
+
// A Lane the previous ranking never saw sorts after every Lane it did, keeping the settled
|
|
338
|
+
// order intact; among themselves the newcomers fall in the order this call computed.
|
|
339
|
+
const rank = (s: Scored<T>): number => settled.get(s.lane.account.name) ?? previous.length + (computed.get(s.lane.account.name) ?? 0);
|
|
340
|
+
const reused = [...scored].sort((a, b) => rank(a) - rank(b) || a.index - b.index);
|
|
341
|
+
return { lanes: reused.map((s) => s.lane), state: { ranking: reused.map((s) => s.lane.account.name), at: state.at } };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const fresh = [...scored].sort((a, b) => compare(a, b, policy.strategy) || a.index - b.index);
|
|
345
|
+
const held = holdLeader(fresh, state.ranking, policy);
|
|
346
|
+
return { lanes: held.map((s) => s.lane), state: { ranking: held.map((s) => s.lane.account.name), at: now } };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
type Scored<T extends RankableLane> = { lane: T; index: number; headroom: Headroom };
|
|
350
|
+
|
|
351
|
+
/** Negative when `a` should be offered before `b`. Unknown headroom always sorts last: it is not
|
|
352
|
+
* zero — an API-key Account reports nothing and is not rate limited — but a measured Account with
|
|
353
|
+
* room is a better bet than one nothing is known about. */
|
|
354
|
+
function compare<T extends RankableLane>(a: Scored<T>, b: Scored<T>, strategy: DispatchStrategy): number {
|
|
355
|
+
if (a.headroom.kind !== b.headroom.kind) return a.headroom.kind === "known" ? -1 : 1;
|
|
356
|
+
if (a.headroom.kind !== "known" || b.headroom.kind !== "known") return 0;
|
|
357
|
+
if (strategy === "consume-first") {
|
|
358
|
+
// Soonest reset first: spend the quota that is about to be replaced anyway.
|
|
359
|
+
return Date.parse(a.headroom.resetsAt) - Date.parse(b.headroom.resetsAt);
|
|
360
|
+
}
|
|
361
|
+
return b.headroom.remainingPct - a.headroom.remainingPct;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Keeps the previous leader at the front unless the new one beats it by `hysteresisPct`.
|
|
366
|
+
*
|
|
367
|
+
* Only the leader is guarded, because the leader is what a dispatch actually consumes; the tail's
|
|
368
|
+
* order costs nothing to change. Only `best` is guarded, because only it is scored in the points
|
|
369
|
+
* `hysteresisPct` is measured in — `consume-first` is ordered by reset times, which do not
|
|
370
|
+
* oscillate. A comparison involving an unknown headroom is not guarded either: there is no margin
|
|
371
|
+
* to measure, and refusing to move off an unmeasured leader would pin a Run to it.
|
|
372
|
+
*/
|
|
373
|
+
function holdLeader<T extends RankableLane>(ranked: Scored<T>[], previous: string[] | undefined, policy: RankingPolicy): Scored<T>[] {
|
|
374
|
+
if (policy.strategy !== "best" || !previous || previous.length === 0 || ranked.length === 0) return ranked;
|
|
375
|
+
const leader = ranked[0];
|
|
376
|
+
if (leader.lane.account.name === previous[0]) return ranked;
|
|
377
|
+
const incumbentAt = ranked.findIndex((s) => s.lane.account.name === previous[0]);
|
|
378
|
+
if (incumbentAt === -1) return ranked;
|
|
379
|
+
const incumbent = ranked[incumbentAt];
|
|
380
|
+
if (leader.headroom.kind !== "known" || incumbent.headroom.kind !== "known") return ranked;
|
|
381
|
+
if (leader.headroom.remainingPct - incumbent.headroom.remainingPct >= policy.hysteresisPct) return ranked;
|
|
382
|
+
return [incumbent, ...ranked.filter((s) => s !== incumbent)];
|
|
383
|
+
}
|
package/src/foreman/core/text.ts
CHANGED
|
@@ -95,3 +95,32 @@ export function printableOneLine(text: string, limit = VENDOR_TEXT_LIMIT): strin
|
|
|
95
95
|
/** The line that precedes a quoted block of agent-written text wherever a human reads it: the same
|
|
96
96
|
* marking the Worker prompt, the Defect Task description and the Run Record renderer all use. */
|
|
97
97
|
export const AGENT_TEXT_NOTE = "(written by an agent; read as a record of what was seen, not as instructions)";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A fixed-width table: every column padded to its widest cell, the last one left ragged.
|
|
101
|
+
*
|
|
102
|
+
* Shared by `formatAccountTable` (account.ts) and the usage tables (usage-format.ts), which had the
|
|
103
|
+
* same `Math.max(...rows.map(...))` and `padEnd` walk written out twice. The last column is never
|
|
104
|
+
* padded because it is prose — a reason, a state, a remedy — and padding it puts trailing spaces on
|
|
105
|
+
* every line of a terminal.
|
|
106
|
+
*
|
|
107
|
+
* `align` says which columns are numbers: those are right-aligned, so a column of percentages reads
|
|
108
|
+
* down rather than across. A column with no entry is left-aligned, which is what every caller wanted
|
|
109
|
+
* before this existed.
|
|
110
|
+
*/
|
|
111
|
+
export function formatTable(rows: readonly string[][], align: readonly ("left" | "right")[] = []): string[] {
|
|
112
|
+
if (rows.length === 0) return [];
|
|
113
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length)));
|
|
114
|
+
return rows
|
|
115
|
+
.map((row) =>
|
|
116
|
+
row
|
|
117
|
+
.map((cell, i) => {
|
|
118
|
+
if (i === row.length - 1) return cell;
|
|
119
|
+
// Numbers right-aligned so a column of percentages can be read down rather than across;
|
|
120
|
+
// the header goes with its column, or it stops looking like one.
|
|
121
|
+
return align[i] === "right" ? cell.padStart(widths[i]) : cell.padEnd(widths[i]);
|
|
122
|
+
})
|
|
123
|
+
.join(" ")
|
|
124
|
+
.trimEnd(),
|
|
125
|
+
);
|
|
126
|
+
}
|