@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
|
@@ -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
|
-
|
|
139
|
-
|
|
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,
|
package/src/commands/foreman.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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());
|