@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.
@@ -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
- const open = notPaused.filter((l) => !contended.has(l.account.name));
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 why = quarantined.has(lanes[0].account.name)
1150
- ? `"${lanes[0].account.name}" is quarantined (${printableOneLine(quarantined.get(lanes[0].account.name) ?? "")})`
1151
- : `"${lanes[0].account.name}" is Paused until ${paused.get(lanes[0].account.name)?.toISOString() ?? "?"}`;
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
- : `run: ${task.identifier} fails over to Account "${lane.account.name}": ${why}`,
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
+ }
@@ -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
+ }