@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.
@@ -19,18 +19,22 @@
19
19
  */
20
20
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
21
21
  import { dirname } from "node:path";
22
+ import type { Account } from "../foreman/account.js";
22
23
  import type { AccountStore } from "../foreman/account-store.js";
23
24
  import { OutboxTracker } from "../foreman/core/outbox-tracker.js";
24
25
  import type { RunRequestRow } from "../foreman/core/process-table.js";
25
26
  import { ForemanError, PAUSE_POLL_MS, reconcileTick, runForeman } from "../foreman/core/run.js";
26
- import { count } from "../foreman/core/text.js";
27
+ import { count, printableOneLine } from "../foreman/core/text.js";
27
28
  import { sleepWarning } from "../foreman/host.js";
28
29
  import { EXIT_ANOTHER_FOREMAN } from "../foreman/service-unit.js";
29
30
  import { errorMessage } from "../foreman/worker-process.js";
30
31
 
31
32
  /** The daemon's exit when another Foreman holds the pid file (defined beside the units that read it). */
32
33
  export { EXIT_ANOTHER_FOREMAN };
33
- import { checkLearningsFile, chooseAccount, liveDaemonPid, reconciled, runDepsFor, summarize, type ForemanLocal, type SharedDeps } from "./foreman.js";
34
+ import { checkLearningsFile, chooseAccount, liveDaemonPid, reconciled, runDepsFor, summarize, type ForemanLocal, type RankingArgs, type SharedDeps } from "./foreman.js";
35
+ import { allBackedOffUntil, AnthropicUsageSource, chooseToPoll, pollable, pollUsage, type UsageSource } from "../foreman/usage-poller.js";
36
+ import { describeUntil } from "../foreman/usage.js";
37
+ import { usageByAccount, type UsageStore } from "../foreman/usage-store.js";
34
38
 
35
39
  /** How long the daemon idles between ticks when no Run is requested; the same interval a
36
40
  * foreground Run waits through a pause at. */
@@ -48,6 +52,15 @@ export interface ForemanUpDeps extends SharedDeps {
48
52
  tickMs?: number;
49
53
  sleep?: (ms: number) => Promise<void>;
50
54
  pid?: number;
55
+ /** The usage source the tick polls through; the real Anthropic one by default, a fake in tests.
56
+ * Nothing but this reads a credential (ADR-0013 as amended, and usage-poller.ts). */
57
+ usageSource?: UsageSource;
58
+ /** How often usage is refreshed while a Run is in flight; `USAGE_POLL_INTERVAL_MS` when unset. */
59
+ usagePollMs?: number;
60
+ /** How the daemon's Runs order the Lanes a Ready Task may use (`RankingArgs`). The daemon is
61
+ * where this matters most: its Runs are the ones going at 3am with nobody to notice an Account
62
+ * being drained into a refusal. */
63
+ ranking?: RankingArgs;
51
64
  }
52
65
 
53
66
  export async function runForemanUp(deps: ForemanUpDeps): Promise<number> {
@@ -77,6 +90,27 @@ export async function runForemanUp(deps: ForemanUpDeps): Promise<number> {
77
90
  out("Waiting for Runs: opsee foreman run <initiativeId> [--account <name>] [--once] [--task <id>] from another terminal");
78
91
  if (await deps.host.isLaptop()) for (const line of sleepWarning(deps.host.platform)) out(line);
79
92
 
93
+ // Across a Run, not only between them. `serveRun` below blocks for as long as the Run lasts, and
94
+ // a Run is exactly when knowing an idle Account's Headroom matters: those are the Accounts that
95
+ // dispatch is choosing between. Unref'd so it can never hold the daemon open, and guarded so a
96
+ // slow poll cannot overlap itself.
97
+ let polling = false;
98
+ /** One at a time, whoever asked. The guard used to defend the timer from itself while the loop
99
+ * below called the same function on its own tick: both read the store before either wrote, and
100
+ * `chooseToPoll` is deterministic, so they picked the same Accounts and asked about them twice. */
101
+ const pollOnce = () => {
102
+ if (polling) return;
103
+ polling = true;
104
+ void pollUsageTick(deps).finally(() => {
105
+ polling = false;
106
+ });
107
+ };
108
+ // Once at start, so a daemon that has just come up does not wait a whole interval to learn
109
+ // anything, and then on the timer alone.
110
+ pollOnce();
111
+ const usageTimer = setInterval(pollOnce, deps.usagePollMs ?? USAGE_POLL_INTERVAL_MS);
112
+ usageTimer.unref?.();
113
+
80
114
  try {
81
115
  while (!stopping) {
82
116
  await idleReconcile(deps, repoRoot);
@@ -89,12 +123,152 @@ export async function runForemanUp(deps: ForemanUpDeps): Promise<number> {
89
123
  await Promise.race([sleep(tickMs), deps.untilStop]);
90
124
  }
91
125
  } finally {
126
+ clearInterval(usageTimer);
92
127
  rmSync(local.pidFile, { force: true });
93
128
  }
94
129
  out("Foreman down");
95
130
  return 0;
96
131
  }
97
132
 
133
+ /** How often the daemon refreshes usage while it is otherwise busy. `chooseToPoll` decides whether
134
+ * there is anything worth asking, so a tick that finds nothing due costs one store read. */
135
+ export const USAGE_POLL_INTERVAL_MS = 60_000;
136
+
137
+ /**
138
+ * What the last pass said, per daemon and per kind of thing there is to say.
139
+ *
140
+ * Keyed by the deps object, which is one per daemon, the same way `core/run.ts` remembers the
141
+ * threshold holds it has already reported. Without it a poller that cannot reach the endpoint says
142
+ * so on every pass for as long as that lasts: one real overnight Run produced a hundred and forty
143
+ * identical lines, which is not a log, it is a wall.
144
+ *
145
+ * Per kind, not one slot for the whole daemon, because the kinds interleave: a pass that polls and
146
+ * fails and a pass that finds nothing due alternate all night, and a single slot would find each
147
+ * one different from the last and say both, every cycle.
148
+ */
149
+ const lastSaid = new WeakMap<object, Map<SaidKind, string>>();
150
+
151
+ type SaidKind = "failure" | "idle" | "threw";
152
+
153
+ /**
154
+ * Says `line` when `state` differs from what this kind last reported, and remembers `state` either
155
+ * way.
156
+ *
157
+ * The separation of the two arguments is the whole point, and it was learned the hard way: the
158
+ * first version of this compared the rendered line, which carries a relative time. At the daemon's
159
+ * one-minute tick "the first lifts in 58m" becomes "in 57m" and never matches itself, so the fix
160
+ * for a hundred and forty lines produced four hundred and eighty. **`state` must describe which
161
+ * Accounts and why, and never when.** A time that is only rendered is a time that cannot flood.
162
+ *
163
+ * `line` may be undefined with a `state` that is defined: that is how a kind records that its
164
+ * trouble is over without announcing it, so the next occurrence is news again rather than a repeat.
165
+ */
166
+ function say(deps: ForemanUpDeps, kind: SaidKind, state: string, line: () => string | undefined): void {
167
+ let said = lastSaid.get(deps);
168
+ if (!said) lastSaid.set(deps, (said = new Map()));
169
+ if (said.get(kind) === state) return;
170
+ said.set(kind, state);
171
+ const text = line();
172
+ if (text !== undefined) deps.out(text);
173
+ }
174
+
175
+ /**
176
+ * Refreshes what is known about a couple of Accounts' rate-limit windows (usage-poller.ts).
177
+ *
178
+ * In the daemon because this is the process that is up when nothing is running: in-band usage only
179
+ * arrives for an Account that has a Worker going, so without this an idle Account is never measured
180
+ * and `best` has nothing to rank it by. `chooseToPoll` decides which are worth a request — never
181
+ * more than `POLL_BATCH` at a time, and never an Account that has nothing to report.
182
+ *
183
+ * Every failure here is swallowed on purpose. Usage is an optimisation: a daemon that stopped
184
+ * Reconciling because a usage request timed out would have traded the thing that matters for the
185
+ * thing that helps.
186
+ *
187
+ * Exported for the test only: what this says, and how often, is the whole of what needed fixing.
188
+ */
189
+ export async function pollUsageTick(deps: ForemanUpDeps): Promise<void> {
190
+ const store = deps.usage;
191
+ if (!store) return;
192
+ try {
193
+ const source = deps.usageSource ?? new AnthropicUsageSource();
194
+ const registered = deps.store.load();
195
+ const now = Date.now();
196
+ const accounts = chooseToPoll(registered, usageByAccount(store), now);
197
+ if (accounts.length === 0) {
198
+ // Not "nothing happened": the one empty pass worth a word is the one the poller cannot leave
199
+ // by itself, which from outside is indistinguishable from working normally.
200
+ const lifts = allBackedOffUntil(registered, usageByAccount(store), now);
201
+ const stuck = lifts === undefined ? [] : registered.filter(pollable).map((a) => a.name);
202
+ say(deps, "idle", stuck.join(","), () => (lifts === undefined ? undefined : idleLine(lifts, now)));
203
+ return;
204
+ }
205
+ await pollUsage({ store, source, accounts });
206
+ // Read back after the requests rather than before: the failures being described were written
207
+ // during them, and a `now` from before would date them by however long the endpoint took.
208
+ const trouble = troubled(store, registered, Date.now());
209
+ say(
210
+ deps,
211
+ "failure",
212
+ trouble.map((t) => `${t.name}=${t.reason}`).join(";"),
213
+ () => (trouble.length === 0 ? undefined : failureLine(trouble)),
214
+ );
215
+ } catch (error) {
216
+ say(deps, "threw", errorMessage(error), () => `usage: this pass failed, retrying next tick: ${errorMessage(error)}`);
217
+ }
218
+ }
219
+
220
+ /** An Account the poller could not measure: what refused it, and when it will be asked again. */
221
+ interface Troubled {
222
+ name: string;
223
+ reason: string;
224
+ again: string | undefined;
225
+ }
226
+
227
+ /**
228
+ * Every pollable Account whose last attempt failed, by name.
229
+ *
230
+ * The whole fleet rather than the batch just asked, so what is said describes a state rather than
231
+ * an event: an Account that failed two passes ago and has not been retried since is still failing,
232
+ * and dropping it would make the line change — and so be said again — every time the batch rotates.
233
+ *
234
+ * A failure with no reason recorded still counts. The reason is redacted before it is stored and
235
+ * redaction can empty it, and an Account silently missing from the line is worse than one whose
236
+ * cause is unknown.
237
+ */
238
+ function troubled(store: UsageStore, registered: readonly Account[], now: number): Troubled[] {
239
+ const trouble: Troubled[] = [];
240
+ for (const account of registered) {
241
+ if (!pollable(account)) continue;
242
+ const record = store.loadOne(account.name);
243
+ if (!record?.pollFailedAt) continue;
244
+ const until = record.pollBackoffUntil ? Date.parse(record.pollBackoffUntil) : Number.NaN;
245
+ trouble.push({
246
+ name: account.name,
247
+ reason: printableOneLine(record.pollFailure ?? "") || "no reason recorded",
248
+ again: Number.isFinite(until) && until > now ? describeUntil(record.pollBackoffUntil!, now) : undefined,
249
+ });
250
+ }
251
+ return trouble;
252
+ }
253
+
254
+ /**
255
+ * What went wrong, per Account, and when each will be asked again.
256
+ *
257
+ * The line it replaces was `measured 0 of 1 Accounts`, which answers neither question a reader has.
258
+ * A count is only useful when the interesting part is how many; here the interesting part is which,
259
+ * why, and whether it is going to sort itself out.
260
+ */
261
+ function failureLine(trouble: readonly Troubled[]): string {
262
+ const said = trouble.map((t) => `${t.name} (${t.reason}${t.again ? `, asking again ${t.again}` : ""})`);
263
+ return `usage: could not measure ${said.join("; ")}. What is already known is shown by "opsee foreman account usage".`;
264
+ }
265
+
266
+ /** Said once when every Account the poller speaks to is inside a rate-limit window the endpoint
267
+ * imposed, so that a reader knows the silence that follows is a wall and not a working daemon. */
268
+ function idleLine(lifts: number, now: number): string {
269
+ return `usage: every Account is inside a rate-limit window the endpoint imposed, so nothing more can be measured; the first lifts ${describeUntil(new Date(lifts).toISOString(), now)}. Runs are unaffected — dispatch falls back to reacting to limits as they arrive.`;
270
+ }
271
+
98
272
  /** The idle tick: every (Initiative, Account) the Process Table has rows for gets a Reconcile, and
99
273
  * whatever the outbox holds is offered to the backend. One group failing never stops the others. */
100
274
  async function idleReconcile(deps: ForemanUpDeps, repoRoot: string): Promise<void> {
@@ -135,8 +309,14 @@ async function serveRun(deps: ForemanUpDeps, repoRoot: string, request: RunReque
135
309
  }
136
310
  out(`${label}: starting`);
137
311
  try {
138
- const account = chooseAccount(deps.store.load(), request.account);
139
- const outcome = await (deps.run ?? runForeman)(await runDepsFor(deps, account, repoRoot, local, { maxTurns: request.maxTurns, stallTimeoutMs: request.stallTimeoutMs }), {
312
+ // `mustChoose`: there is no operator here to answer "which Account". A queued Run was accepted
313
+ // with exit 0 in somebody's terminal, and refusing it now would mark it failed on this one.
314
+ const account = chooseAccount(deps.store.load(), request.account, {
315
+ strategy: deps.ranking?.strategy,
316
+ usage: usageByAccount(deps.usage),
317
+ mustChoose: true,
318
+ });
319
+ const outcome = await (deps.run ?? runForeman)(await runDepsFor(deps, account, repoRoot, local, { maxTurns: request.maxTurns, stallTimeoutMs: request.stallTimeoutMs, ranking: deps.ranking }), {
140
320
  initiativeId: request.initiativeId,
141
321
  once: request.once,
142
322
  taskId: request.taskId,
@@ -1,8 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { uptime } from "node:os";
4
- import { AccountError, type Account } from "../foreman/account.js";
4
+ import { AccountError, isUsableNow, type Account } from "../foreman/account.js";
5
5
  import type { AccountStore } from "../foreman/account-store.js";
6
+ import { usageByAccount, type UsageStore } from "../foreman/usage-store.js";
7
+ import type { AccountUsage } from "../foreman/usage.js";
8
+ import { DEFAULT_RANKING_POLICY, rankLanes, type DispatchStrategy, type RankingPolicy } from "../foreman/core/scheduler.js";
6
9
  import { defectFilerWith } from "../foreman/core/defects.js";
7
10
  import { baseGateCommands, gatesWith, noGatesWarning } from "../foreman/core/gates.js";
8
11
  import { handOffWith } from "../foreman/core/handoff.js";
@@ -72,6 +75,10 @@ export interface SharedDeps {
72
75
  * commands have one; it is optional here so a test can build a Run's deps without a store, which
73
76
  * then Pauses nothing and Fails over nowhere. */
74
77
  store?: AccountStore;
78
+ /** Where each Account's rate-limit windows are kept (usage-store.ts). Both commands have one;
79
+ * optional here so a test can build a Run's deps without it, which then ranks Lanes in
80
+ * registration order and holds nothing back — exactly the behaviour before usage existed. */
81
+ usage?: UsageStore;
75
82
  /** Test seams; the real Workspace manager, code host, Hand-off and loop by default. Every git
76
83
  * call the commands make goes through `git`, the guarded runner (ADR-0003). */
77
84
  workspacesFor?: (repoRoot: string, log: (line: string) => void, git: GitRunner) => RunDeps["workspaces"];
@@ -92,7 +99,19 @@ export interface SharedDeps {
92
99
  }
93
100
 
94
101
  /** Options of `opsee foreman run`. */
95
- export interface ForemanRunArgs {
102
+ /**
103
+ * How a Run orders the Lanes a Ready Task may use (core/scheduler.ts `rankLanes`), as the command
104
+ * line states it. Shared by `foreman run` and `foreman up` because the daemon's Runs want it just
105
+ * as much as a foreground one — more, since they are the ones running at 3am.
106
+ */
107
+ export interface RankingArgs {
108
+ /** "order" (the default), "best", or "consume-first"; refused by the parser otherwise. */
109
+ strategy?: DispatchStrategy;
110
+ hysteresisPct?: number;
111
+ cooldownMs?: number;
112
+ }
113
+
114
+ export interface ForemanRunArgs extends RankingArgs {
96
115
  initiativeId: number;
97
116
  account?: string;
98
117
  once: boolean;
@@ -101,6 +120,18 @@ export interface ForemanRunArgs {
101
120
  stallTimeoutMs?: number;
102
121
  }
103
122
 
123
+ /** Options of `opsee foreman up`: the daemon takes the dispatch policy and nothing else. */
124
+ export type ForemanUpArgs = RankingArgs;
125
+
126
+ /** The policy a Run uses, from what the command line said and the defaults for the rest. */
127
+ export function rankingPolicyFrom(args: RankingArgs): RankingPolicy {
128
+ return {
129
+ strategy: args.strategy ?? DEFAULT_RANKING_POLICY.strategy,
130
+ hysteresisPct: args.hysteresisPct ?? DEFAULT_RANKING_POLICY.hysteresisPct,
131
+ cooldownMs: args.cooldownMs ?? DEFAULT_RANKING_POLICY.cooldownMs,
132
+ };
133
+ }
134
+
104
135
  export interface ForemanRunDeps extends SharedDeps {
105
136
  store: AccountStore;
106
137
  /** The repository the Run works on: the checkout the command runs in. */
@@ -175,7 +206,43 @@ export function refuseQuarantinedRun(account: Account): void {
175
206
  }
176
207
 
177
208
  /** Picks the Account a Run uses: the named one, or the only one registered. */
178
- export function chooseAccount(accounts: Account[], name: string | undefined): Account {
209
+ /** What `chooseAccount` may use to make the choice itself, when nobody named an Account. */
210
+ export interface AccountChoice {
211
+ /** The Run's dispatch strategy. `best` and `consume-first` are answers to "which Account"; under
212
+ * `order` nothing has said, so an ambiguous fleet is refused unless nobody can be asked. */
213
+ strategy?: DispatchStrategy;
214
+ usage?: ReadonlyMap<string, AccountUsage>;
215
+ /**
216
+ * There is no human to ask, so an Account must come back whatever the strategy.
217
+ *
218
+ * What a daemon serving a queued Run passes. The refusal exists so an operator standing at a
219
+ * terminal can say which Account they meant; throwing where nobody is standing turns a Run that
220
+ * was accepted with exit 0 into one marked failed minutes later on somebody else's terminal.
221
+ */
222
+ mustChoose?: boolean;
223
+ /** Which Accounts another Foreman already has Workers on (the Process Table), where the caller
224
+ * can say. Choosing one of those is choosing a refusal a moment later. */
225
+ busy?: (name: string) => boolean;
226
+ /** The rest of the refusal's sentence, for callers that have another way out to offer. Passed
227
+ * rather than assumed so a command cannot advertise a flag its own parser rejects. */
228
+ hint?: string;
229
+ /** Test seam; `Date.now` otherwise, as everywhere else in this package. */
230
+ now?: number;
231
+ }
232
+
233
+ /**
234
+ * The Account a Run starts on: Lane 0.
235
+ *
236
+ * A name always wins — a human who said which Account they meant is not overruled. Otherwise, with
237
+ * several registered, the strategy decides where it can: `best` and `consume-first` are already
238
+ * statements about which Account to prefer, so making the operator repeat that by hand was ceremony
239
+ * over a choice the policy had made. Under `order` nothing has said, and the refusal stands.
240
+ *
241
+ * What this picks matters less than it looks: every sibling of the same vendor becomes a Failover
242
+ * Lane regardless (`failoverAccountsFor`), and `rankLanes` reorders them before any Task is placed.
243
+ * Lane 0 is where the list starts, not where the work goes.
244
+ */
245
+ export function chooseAccount(accounts: Account[], name: string | undefined, choice: AccountChoice = {}): Account {
179
246
  if (accounts.length === 0) {
180
247
  throw new AccountError("No Account registered; register one with: opsee foreman account add --vendor claude --config-dir <dir>");
181
248
  }
@@ -184,10 +251,34 @@ export function chooseAccount(accounts: Account[], name: string | undefined): Ac
184
251
  if (!found) throw new AccountError(`No Account named "${name}" (have: ${accounts.map((a) => a.name).join(", ")})`);
185
252
  return found;
186
253
  }
187
- if (accounts.length > 1) {
188
- throw new AccountError(`Several Accounts are registered; pass --account <name> (have: ${accounts.map((a) => a.name).join(", ")})`);
254
+ if (accounts.length === 1) return accounts[0];
255
+
256
+ const ranked = choice.strategy === "best" || choice.strategy === "consume-first";
257
+ if (!ranked && !choice.mustChoose) {
258
+ throw new AccountError(
259
+ `Several Accounts are registered, and nothing has said which to start on (have: ${accounts.map((a) => a.name).join(", ")}). ` +
260
+ `Name one with --account <name>${choice.hint ? `, ${choice.hint}` : ""}.`,
261
+ );
189
262
  }
190
- return accounts[0];
263
+
264
+ const now = choice.now ?? Date.now();
265
+ // An Account that cannot take work would start the Run on a Lane with no Slots, or on one another
266
+ // Foreman is already running — which `refuseSecondRunOnAccount` then hard-fails a moment later,
267
+ // where an idle sibling would have worked. Passed over unless they all are, in which case the Run
268
+ // still starts and reports the state properly (`refuseQuarantinedRun`, core/run.ts).
269
+ const usable = accounts.filter((a) => isUsableNow(a, now, choice.busy));
270
+ const among = usable.length > 0 ? usable : accounts;
271
+ // `order` is registration order, so under it — or under no strategy at all, which is what a
272
+ // daemon serving a queued Run has — the first registered Account is the answer, not a refusal.
273
+ const strategy: DispatchStrategy = ranked ? choice.strategy! : "order";
274
+ const { lanes } = rankLanes(
275
+ among.map((account) => ({ account })),
276
+ choice.usage ?? new Map(),
277
+ { ...DEFAULT_RANKING_POLICY, strategy },
278
+ {},
279
+ now,
280
+ );
281
+ return lanes[0].account;
191
282
  }
192
283
 
193
284
  /** The repository's learnings file, or the default with the reason the configured one is refused
@@ -210,7 +301,7 @@ export async function runDepsFor(
210
301
  account: Account,
211
302
  repoRoot: string,
212
303
  local: ForemanLocal | undefined,
213
- options: { maxTurns?: number; stallTimeoutMs?: number; failover?: boolean },
304
+ options: { maxTurns?: number; stallTimeoutMs?: number; failover?: boolean; ranking?: RankingArgs },
214
305
  ): Promise<RunDeps> {
215
306
  const outbox = local ? new OutboxTracker(shared.tracker, local.table, shared.out) : undefined;
216
307
  const git = guardedGit(runGit, repoRoot);
@@ -279,6 +370,10 @@ export async function runDepsFor(
279
370
  failover,
280
371
  pinnable,
281
372
  accounts: shared.store,
373
+ usage: shared.usage,
374
+ // The dispatch policy the command line asked for, defaults filled in. The daemon's idle
375
+ // Reconcile passes none and gets the defaults, which it never uses: it dispatches nothing.
376
+ ranking: rankingPolicyFrom(options.ranking ?? {}),
282
377
  workspaces,
283
378
  install: shared.install,
284
379
  handOff,
@@ -422,25 +517,43 @@ export function reconciled(results: Array<Dispatch | BlockedTask>): RunOutcome {
422
517
  * push...), so a script can tell the two apart. The code host is chosen from origin before the
423
518
  * first dispatch: a repository the Foreman could never hand off from is refused up front. */
424
519
  export async function runForemanRun(deps: ForemanRunDeps, args: ForemanRunArgs): Promise<number> {
425
- const account = chooseAccount(deps.store.load(), args.account);
426
520
  const repoRoot = await deps.repoRoot();
427
521
  if (!repoRoot) throw new ForemanError("Not inside a git repository; run foreman run from the checkout the Initiative's Tasks are about.");
428
522
 
429
523
  const daemon = deps.local ? liveDaemonPid(deps.local.pidFile, deps.local.probe) : undefined;
430
524
  if (daemon !== undefined && deps.local) {
525
+ // Resolved here when it is unambiguous — a name was given, or there is only one Account — and
526
+ // left to the daemon when it is not. A `RunRequestRow` carries no strategy, so the daemon's
527
+ // policy is what will apply; deciding here would pick under this terminal's flags and run under
528
+ // the daemon's, which is the mismatch that made `foreman run` demand `--account` from a fleet
529
+ // the daemon was already ranking. A name is still validated now, so a typo is refused by the
530
+ // terminal that made it rather than by a daemon somewhere else an hour later.
531
+ const registered = deps.store.load();
532
+ const queued = args.account !== undefined ? chooseAccount(registered, args.account).name : registered.length === 1 ? registered[0].name : undefined;
431
533
  const request = deps.local.table.requestRun({
432
534
  initiativeId: args.initiativeId,
433
- account: account.name,
535
+ account: queued,
434
536
  once: args.once,
435
537
  taskId: args.taskId,
436
538
  repoRoot,
437
539
  maxTurns: args.maxTurns,
438
540
  stallTimeoutMs: args.stallTimeoutMs,
439
541
  });
440
- deps.out(`Run #${request.id} queued for the Foreman daemon (pid ${daemon}) on Account "${account.name}"; it starts on the daemon's next tick and its output goes to the daemon's terminal.`);
542
+ deps.out(
543
+ `Run #${request.id} queued for the Foreman daemon (pid ${daemon}) ${queued === undefined ? "on the Account its own strategy picks" : `on Account "${queued}"`}; ` +
544
+ `it starts on the daemon's next tick and its output goes to the daemon's terminal.`,
545
+ );
441
546
  return 0;
442
547
  }
443
548
 
549
+ // In the foreground this terminal's own flags are the policy, so they decide Lane 0 too.
550
+ const account = chooseAccount(deps.store.load(), args.account, {
551
+ strategy: args.strategy,
552
+ usage: usageByAccount(deps.usage),
553
+ busy: busyAccounts(deps),
554
+ hint: "or let the policy choose with --strategy best, which starts on whichever Account has the most Headroom",
555
+ });
556
+
444
557
  // In the foreground this process is the one running the Workers, so it is the one that must not
445
558
  // double the Account's cap on top of another Foreman's.
446
559
  if (deps.local) refuseSecondRunOnAccount(deps.local.table, account, deps.isAlive ?? pidIsAlive);
@@ -450,7 +563,7 @@ export async function runForemanRun(deps: ForemanRunDeps, args: ForemanRunArgs):
450
563
  const learningsConfig = checkLearningsFile(repoRoot);
451
564
  if (learningsConfig.refused) deps.out(`learnings: ${learningsConfig.refused}`);
452
565
  // In the foreground there is no daemon to hand a paused Run back to, so it waits for the resume.
453
- const outcome = await (deps.run ?? runForeman)(await runDepsFor(deps, account, repoRoot, deps.local, { maxTurns: args.maxTurns, stallTimeoutMs: args.stallTimeoutMs }), {
566
+ const outcome = await (deps.run ?? runForeman)(await runDepsFor(deps, account, repoRoot, deps.local, { maxTurns: args.maxTurns, stallTimeoutMs: args.stallTimeoutMs, ranking: args }), {
454
567
  initiativeId: args.initiativeId,
455
568
  once: args.once,
456
569
  taskId: args.taskId,
@@ -461,5 +574,14 @@ export async function runForemanRun(deps: ForemanRunDeps, args: ForemanRunArgs):
461
574
  return needingAttention === 0 && blocked === 0 ? 0 : 1;
462
575
  }
463
576
 
577
+ /** Which Accounts another Foreman has live Workers on, for the auto-pick to pass over. Empty when
578
+ * this process keeps no machine-local record, which is every test that hands in no `local`. */
579
+ function busyAccounts(deps: Pick<ForemanRunDeps, "local" | "isAlive">): ((name: string) => boolean) | undefined {
580
+ const table = deps.local?.table;
581
+ if (!table) return undefined;
582
+ const isAlive = deps.isAlive ?? pidIsAlive;
583
+ return (name) => otherForemanWorkersOn(table, name, Date.now(), { isAlive }).length > 0;
584
+ }
585
+
464
586
  /** The repository the current directory is in, for the real command. */
465
587
  export const currentRepoRoot = () => repoRootOf(process.cwd());