@nodii/dev 0.6.37 → 0.6.38
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 +42 -3
- package/dist/accounts.js +342 -0
- package/dist/cli.js +19 -2
- package/dist/core.js +2 -2
- package/dist/setup.js +1 -1
- package/dist/starting.js +120 -4
- package/dist/watchdog.js +37 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -856,14 +856,53 @@ first.
|
|
|
856
856
|
coordinator run and shows its running lanes, orphaned runs and unclaimed
|
|
857
857
|
results. A live second coordinator is refused; a coordinator whose heartbeat
|
|
858
858
|
watchdog has recorded it orphaned is recorded as the predecessor of the
|
|
859
|
-
takeover.
|
|
859
|
+
takeover. Each `--account RUNTIME:PROFILE` (for example
|
|
860
|
+
`--account claude:/Users/me/.claude-work`) registers that model account on
|
|
861
|
+
the machine, and the command then reports every registered account's usage.
|
|
862
|
+
|
|
863
|
+
### Account capacity (MW1-38.1, MW1-38.2)
|
|
864
|
+
|
|
865
|
+
Each Claude or Codex profile a machine launches lanes on is a Dev runner of
|
|
866
|
+
kind `account`, owned by the machine's coordinator.
|
|
867
|
+
`machine accounts register --runtime claude|codex --profile PATH` registers
|
|
868
|
+
one and reports its usage at once; `machine accounts list` shows them;
|
|
869
|
+
`machine usage report [--profile PATH]` re-reads and reports each one. The
|
|
870
|
+
reading is read-only and never prints a token: Claude reads
|
|
871
|
+
`GET https://api.anthropic.com/api/oauth/usage` with the profile's own login
|
|
872
|
+
(the macOS keychain, or `PROFILE/.credentials.json`), and an expired login is
|
|
873
|
+
left for Claude to renew, never refreshed here; Codex reads the newest
|
|
874
|
+
rollout's `rate_limits`, which counts only while it is under 15 minutes old
|
|
875
|
+
(MW1-38.1: an older reading is unknown). An idle Codex account can opt in with
|
|
876
|
+
`machine accounts register --runtime codex --profile PATH --probe` (and out with
|
|
877
|
+
`--no-probe`): when its
|
|
878
|
+
newest reading is 10 minutes old, a `lane launch` on it or an explicit
|
|
879
|
+
`machine usage report --probe` (never the watch loop, since a probe spends
|
|
880
|
+
quota) runs one minimal
|
|
881
|
+
`codex exec --skip-git-repo-check -s read-only "reply ok"` under that
|
|
882
|
+
`CODEX_HOME` and reads the fresh rollout. Dev places a launch by the higher of the 5-hour and
|
|
883
|
+
weekly windows: under 80% anything; 80–94% no new long lanes; 95–99% only
|
|
884
|
+
`no-build` work (a one-file fix, a publish or verify relaunch, an
|
|
885
|
+
evidence-only step); 100% nothing. A reading older than 15 minutes counts as
|
|
886
|
+
unknown. `lane launch` refreshes its own account's reading first, and the
|
|
887
|
+
watchdog sweep re-reports every account every 5 minutes (install it with
|
|
888
|
+
`watchdog install` under the coordinator's identity).
|
|
889
|
+
|
|
890
|
+
`coordinator start` also launches a detached heartbeater
|
|
891
|
+
(`coordinator heartbeat`, its PID saved in
|
|
892
|
+
`coordinator-NAME.heartbeat.pid` next to the config) tied to the coordinator
|
|
893
|
+
session's process. It heartbeats the run at half its expected interval while
|
|
894
|
+
the session lives, ends the run (`done`) when the session exits, and stops
|
|
895
|
+
without touching a run that a later `coordinator start` replaced.
|
|
860
896
|
|
|
861
897
|
`lane launch KEY` replaces the manual worktree/start/process sequence. It
|
|
862
898
|
requires the issue (whose title supplies both lane title and objective), done
|
|
863
|
-
check, build kind and side, runtime profile and model, subagent quota,
|
|
899
|
+
check, build kind and side, runtime profile and model, subagent quota, the
|
|
900
|
+
lane's class (`--class long|short|no-build`, MW1-38.3), and
|
|
864
901
|
repeated `--repo NAME=/SOURCE` plus
|
|
865
902
|
`--branch NAME=BRANCH` pairs. The Hub preflight checks the coordinator waiter,
|
|
866
|
-
target agent and machine,
|
|
903
|
+
target agent and machine, the account's fresh usage band against the lane's
|
|
904
|
+
class (refusing with `ACCOUNT_LIMIT_UNAVAILABLE` or `ACCOUNT_BAND_REFUSED` and
|
|
905
|
+
the command that fixes it), repository grants,
|
|
867
906
|
lane limit, open issues and (when `--figma` is present) the authenticated Figma
|
|
868
907
|
account. Only after that passes does the CLI create one worktree per repository,
|
|
869
908
|
atomically register the lane and watched run, and launch the runtime with the
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Model-account capacity (MW1-38.1, AGENT-HARNESS-43). Each Claude or Codex
|
|
2
|
+
// profile a machine launches lanes on is a Dev runner of kind `account`; its
|
|
3
|
+
// subscription usage ceiling carries the account's measured 5-hour and weekly
|
|
4
|
+
// windows, and `lane launch` refuses an account with no reading under 15
|
|
5
|
+
// minutes old. Readings are read-only: a token is never printed or refreshed.
|
|
6
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
13
|
+
|
|
14
|
+
const execFile = promisify(execFileCallback);
|
|
15
|
+
export const RUNTIMES = ["claude", "codex"];
|
|
16
|
+
export const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
17
|
+
// The watchdog sweeps every minute; it re-reports at this cadence so a launch
|
|
18
|
+
// always finds a reading well inside Dev's 15-minute window.
|
|
19
|
+
export const USAGE_REFRESH_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* MW1-38.2 bands, placed by the higher of the windows: under 80% ok (normal
|
|
23
|
+
* placement), 80-94% warning (no new long lanes), 95-99% short_only (a
|
|
24
|
+
* one-file fix, a publish or verify relaunch, an evidence-only step), 100%
|
|
25
|
+
* exhausted (nothing new).
|
|
26
|
+
*/
|
|
27
|
+
export function usageBand(windows) {
|
|
28
|
+
const peak = Math.max(...Object.values(windows).map((window) => window?.utilization ?? 0), 0);
|
|
29
|
+
return peak >= 100 ? "exhausted" : peak >= 95 ? "short_only" : peak >= 80 ? "warning" : "ok";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ceiling's stored state (ok | warning | exhausted). Dev places a launch
|
|
34
|
+
* by the band it computes from the percentages in counters, so 95-99% is
|
|
35
|
+
* stored as warning without losing the stricter band.
|
|
36
|
+
*/
|
|
37
|
+
export function usageState(windows) {
|
|
38
|
+
const band = usageBand(windows);
|
|
39
|
+
return band === "short_only" ? "warning" : band;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One spelling per profile directory: Dev matches an account's profile exactly. */
|
|
43
|
+
export const profilePath = (value) => (typeof value === "string" && value.length > 1 ? value.replace(/\/+$/, "") : value);
|
|
44
|
+
|
|
45
|
+
function checkAccount({ runtime, profile }) {
|
|
46
|
+
if (!RUNTIMES.includes(runtime)) throw new CliError("USAGE", "--runtime must be claude or codex.", 2);
|
|
47
|
+
if (!profile || !isAbsolute(profile) || normalize(profile) !== profile)
|
|
48
|
+
throw new CliError("USAGE", "--profile must be an absolute, normalized runtime profile path.", 2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The keychain services that may hold a profile's Claude login, in order.
|
|
53
|
+
* With CLAUDE_CONFIG_DIR set (as lane launch always sets it), Claude Code
|
|
54
|
+
* suffixes the service with the directory's hash; only the default
|
|
55
|
+
* ~/.claude also has the plain service, from sessions started without it.
|
|
56
|
+
*/
|
|
57
|
+
export function claudeKeychainServices(profile, home = homedir()) {
|
|
58
|
+
const hashed = `Claude Code-credentials-${createHash("sha256").update(profile).digest("hex").slice(0, 8)}`;
|
|
59
|
+
return profile === join(home, ".claude") ? [hashed, "Claude Code-credentials"] : [hashed];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function claudeCredential(profile, { platform = process.platform, run = execFile, read = readFile } = {}) {
|
|
63
|
+
if (platform !== "darwin") return JSON.parse(await read(join(profile, ".credentials.json"), "utf8"))?.claudeAiOauth ?? null;
|
|
64
|
+
for (const service of claudeKeychainServices(profile)) {
|
|
65
|
+
try { return JSON.parse((await run("security", ["find-generic-password", "-s", service, "-w"], { encoding: "utf8" })).stdout)?.claudeAiOauth ?? null; }
|
|
66
|
+
catch {}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isoOrNull = (value) => {
|
|
72
|
+
if (value === null || value === undefined || value === "") return null;
|
|
73
|
+
const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
|
|
74
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The account's own windows from Anthropic's read-only usage endpoint. */
|
|
78
|
+
export async function readClaudeUsage(profile, { credential = claudeCredential, fetchImpl = fetch, now = () => new Date() } = {}) {
|
|
79
|
+
let oauth;
|
|
80
|
+
try { oauth = await credential(profile); }
|
|
81
|
+
catch { throw new CliError("ACCOUNT_CREDENTIAL_UNAVAILABLE", `No Claude login found for ${profile}.`, 1); }
|
|
82
|
+
if (!oauth?.accessToken) throw new CliError("ACCOUNT_CREDENTIAL_UNAVAILABLE", `No Claude login found for ${profile}.`, 1);
|
|
83
|
+
// Lanes share this login: refreshing it here could sign them out, so an
|
|
84
|
+
// expired token is reported as unreadable and left for the runtime to renew.
|
|
85
|
+
if (oauth.expiresAt && Number(oauth.expiresAt) <= now().getTime())
|
|
86
|
+
throw new CliError("ACCOUNT_TOKEN_EXPIRED", `The Claude login for ${profile} has expired. Lanes share it, so it is not refreshed here: start Claude once on that profile (CLAUDE_CONFIG_DIR=${profile}) to renew it, then report again.`, 1);
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await fetchImpl(CLAUDE_USAGE_URL, {
|
|
90
|
+
headers: { authorization: `Bearer ${oauth.accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
91
|
+
signal: AbortSignal.timeout(15_000),
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new CliError("ACCOUNT_USAGE_UNAVAILABLE", `Anthropic's usage endpoint was unreachable for ${profile} (${error?.cause?.code ?? error?.name ?? "network error"}).`, 1);
|
|
95
|
+
}
|
|
96
|
+
if (!response.ok) throw new CliError("ACCOUNT_USAGE_UNAVAILABLE", `Anthropic's usage endpoint answered ${response.status} for ${profile}.`, 1);
|
|
97
|
+
const body = await response.json();
|
|
98
|
+
const window = (value) => value && typeof value.utilization === "number"
|
|
99
|
+
? { utilization: value.utilization, resetsAt: isoOrNull(value.resets_at) }
|
|
100
|
+
: null;
|
|
101
|
+
// Model-scoped weekly windows (seven_day_opus, seven_day_sonnet, …) bind
|
|
102
|
+
// too when present, so they count toward the band.
|
|
103
|
+
const scoped = Object.entries(body)
|
|
104
|
+
.filter(([name, value]) => /^seven_day_/.test(name) && window(value))
|
|
105
|
+
.map(([name, value]) => [name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), window(value)]);
|
|
106
|
+
return {
|
|
107
|
+
source: "anthropic-oauth-usage",
|
|
108
|
+
measuredAt: now().toISOString(),
|
|
109
|
+
windows: { fiveHour: window(body.five_hour), sevenDay: window(body.seven_day), ...Object.fromEntries(scoped) },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The newest rollouts first, walking sessions/YYYY/MM/DD newest-first and stopping at `limit`. */
|
|
114
|
+
async function newestRollouts(profile, limit = 20) {
|
|
115
|
+
const files = [];
|
|
116
|
+
const names = async (dir) => {
|
|
117
|
+
try { return (await readdir(dir, { withFileTypes: true })).sort((a, b) => b.name.localeCompare(a.name)); }
|
|
118
|
+
catch { return []; }
|
|
119
|
+
};
|
|
120
|
+
async function walk(dir) {
|
|
121
|
+
for (const entry of await names(dir)) {
|
|
122
|
+
if (files.length >= limit) return;
|
|
123
|
+
const path = join(dir, entry.name);
|
|
124
|
+
if (entry.isDirectory()) await walk(path);
|
|
125
|
+
else if (/^rollout-.*\.jsonl$/.test(entry.name)) files.push(path);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await walk(join(profile, "sessions"));
|
|
129
|
+
return files;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function lastRateLimits(text) {
|
|
133
|
+
const lines = text.trimEnd().split("\n");
|
|
134
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
135
|
+
if (!lines[index].includes('"rate_limits"')) continue;
|
|
136
|
+
let record;
|
|
137
|
+
try { record = JSON.parse(lines[index]); } catch { continue; }
|
|
138
|
+
const limits = record?.payload?.rate_limits ?? record?.rate_limits;
|
|
139
|
+
if (limits && (limits.primary || limits.secondary)) return { limits, at: record.timestamp };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// MW1-38.1: a reading older than 15 minutes is unknown, never free. A Codex
|
|
145
|
+
// rollout is only as fresh as the last Codex run on that profile.
|
|
146
|
+
export const MAX_READING_AGE_MS = 15 * 60_000;
|
|
147
|
+
// A probing account takes a fresh reading before its newest one gets close
|
|
148
|
+
// to that limit when a launch or an explicit report asks for one.
|
|
149
|
+
export const PROBE_AFTER_MS = 10 * 60_000;
|
|
150
|
+
|
|
151
|
+
/** The newest rate-limit reading in the profile's rollouts, or null. */
|
|
152
|
+
async function newestCodexReading(profile, { rollouts, read, now }) {
|
|
153
|
+
for (const path of await rollouts(profile)) {
|
|
154
|
+
const found = lastRateLimits(await read(path, "utf8"));
|
|
155
|
+
if (!found) continue;
|
|
156
|
+
const windows = { fiveHour: null, sevenDay: null };
|
|
157
|
+
for (const limit of [found.limits.primary, found.limits.secondary]) {
|
|
158
|
+
if (!limit || typeof limit.used_percent !== "number") continue;
|
|
159
|
+
const resetsAt = isoOrNull(limit.resets_at);
|
|
160
|
+
// A window that has reset since the rollout was written is empty now.
|
|
161
|
+
const utilization = resetsAt && Date.parse(resetsAt) <= now().getTime() ? 0 : limit.used_percent;
|
|
162
|
+
const name = limit.window_minutes === 300 ? "fiveHour" : limit.window_minutes === 10_080 ? "sevenDay" : `window${limit.window_minutes}m`;
|
|
163
|
+
windows[name] = { utilization, resetsAt };
|
|
164
|
+
}
|
|
165
|
+
return { source: "codex-rollout", measuredAt: isoOrNull(found.at), windows };
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* One minimal Codex turn under the profile's CODEX_HOME, so Codex writes a
|
|
172
|
+
* fresh rollout with the account's current rate limits (opt-in per account).
|
|
173
|
+
*/
|
|
174
|
+
export async function probeCodex(profile, { codexBin = "codex", run = execFile } = {}) {
|
|
175
|
+
// The probe must run on the profile's own ChatGPT login, never an API key.
|
|
176
|
+
const { OPENAI_API_KEY: _key, OPENAI_BASE_URL: _base, ...env } = process.env;
|
|
177
|
+
try {
|
|
178
|
+
await run(codexBin, ["exec", "--skip-git-repo-check", "-s", "read-only", "reply ok"], {
|
|
179
|
+
cwd: tmpdir(), env: { ...env, CODEX_HOME: profile }, timeout: 120_000, encoding: "utf8",
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new CliError("ACCOUNT_PROBE_FAILED", `The Codex probe on ${profile} failed (${error?.code ?? error?.signal ?? "error"}).`, 1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The account's windows from the newest Codex rollout that recorded them, under 15 minutes old. */
|
|
187
|
+
export async function readCodexUsage(profile, { probe = null, rollouts = newestRollouts, read = readFile, now = () => new Date(), runProbe = probeCodex } = {}) {
|
|
188
|
+
const age = (reading) => (reading?.measuredAt ? now().getTime() - Date.parse(reading.measuredAt) : Number.POSITIVE_INFINITY);
|
|
189
|
+
let reading = await newestCodexReading(profile, { rollouts, read, now });
|
|
190
|
+
if (probe && age(reading) >= PROBE_AFTER_MS) {
|
|
191
|
+
try {
|
|
192
|
+
await runProbe(profile, probe);
|
|
193
|
+
reading = await newestCodexReading(profile, { rollouts, read, now });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
// A failed probe never discards a reading that still counts.
|
|
196
|
+
if (age(reading) > MAX_READING_AGE_MS) throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!reading)
|
|
200
|
+
throw new CliError("ACCOUNT_USAGE_UNAVAILABLE", `No Codex rollout under ${profile}/sessions records rate limits yet.`, 1);
|
|
201
|
+
if (age(reading) > MAX_READING_AGE_MS)
|
|
202
|
+
throw new CliError("ACCOUNT_USAGE_STALE", `The newest Codex reading on ${profile} is ${Math.round(age(reading) / 60_000)} min old, and MW1-38.1 counts it as unknown. Let it probe with \`nodii-dev machine accounts register --runtime codex --profile ${profile} --probe\`, or run Codex on it.`, 1);
|
|
203
|
+
return reading;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Probing is opt-in per account and runs on this machine, so the choice and
|
|
207
|
+
// the Codex binary (launchd's PATH has no user bin directories) live here.
|
|
208
|
+
const probesPath = () => join(dirname(configPath()), "account-probes.json");
|
|
209
|
+
export async function readProbes(read = readFile) {
|
|
210
|
+
try { return JSON.parse(await read(probesPath(), "utf8")); } catch { return {}; }
|
|
211
|
+
}
|
|
212
|
+
async function saveProbe(profile, codexBin) {
|
|
213
|
+
const probes = await readProbes();
|
|
214
|
+
if (codexBin) probes[profile] = { codexBin };
|
|
215
|
+
else delete probes[profile];
|
|
216
|
+
await mkdir(dirname(probesPath()), { recursive: true, mode: 0o700 });
|
|
217
|
+
const temporary = `${probesPath()}.tmp-${process.pid}`;
|
|
218
|
+
await writeFile(temporary, `${JSON.stringify(probes, null, 2)}\n`, { mode: 0o600 });
|
|
219
|
+
await rename(temporary, probesPath());
|
|
220
|
+
}
|
|
221
|
+
async function resolveCodexBin(run = execFile) {
|
|
222
|
+
if (process.env.NODII_DEV_CODEX_BIN) return process.env.NODII_DEV_CODEX_BIN;
|
|
223
|
+
try { return (await run("/usr/bin/which", ["codex"], { encoding: "utf8" })).stdout.trim() || null; }
|
|
224
|
+
catch { return null; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const READERS = { claude: readClaudeUsage, codex: readCodexUsage };
|
|
228
|
+
|
|
229
|
+
/** The usage ceiling body Dev stores for one reading. */
|
|
230
|
+
export function usageReport(reading) {
|
|
231
|
+
const state = usageState(reading.windows);
|
|
232
|
+
const binding = Object.values(reading.windows)
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
|
|
235
|
+
return {
|
|
236
|
+
ceiling: "subscription",
|
|
237
|
+
state,
|
|
238
|
+
resetsAt: state === "ok" ? null : binding?.resetsAt ?? null,
|
|
239
|
+
counters: { source: reading.source, measuredAt: reading.measuredAt, ...reading.windows },
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The coordinator's machine: from its saved coordinator run, else its agent record. */
|
|
244
|
+
export async function coordinatorMachineId({ request = devRequest, config = readConfig } = {}) {
|
|
245
|
+
const c = await config();
|
|
246
|
+
const agent = (await request("GET", `/api/v1/agents/${encode(c.agentId)}`)).data;
|
|
247
|
+
if (agent?.role !== "coordinator" || !agent.machineId)
|
|
248
|
+
throw new CliError("MACHINE_COORDINATOR_REQUIRED", "Account capacity is reported by the machine's coordinator; run this with the coordinator's Dev identity.", 1);
|
|
249
|
+
return agent.machineId;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function listAccounts(machineId, { request = devRequest } = {}) {
|
|
253
|
+
const runners = (await request("GET", "/api/v1/runners?kind=account")).data ?? [];
|
|
254
|
+
return runners.filter((runner) => runner.machineId === machineId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function registerAccount(machineId, { runtime, profile: given }, { request = devRequest } = {}) {
|
|
258
|
+
const profile = profilePath(given);
|
|
259
|
+
checkAccount({ runtime, profile });
|
|
260
|
+
return (await request("POST", "/api/v1/runners", { kind: "account", machineId, runtime, profile, status: "online" })).data;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read and report each account's windows; one failure never hides the rest.
|
|
265
|
+
* `probe` lets an opted-in Codex account take a fresh reading. Only an
|
|
266
|
+
* explicit report or a launch probes, never the watch loop: a probe spends
|
|
267
|
+
* the account's quota (hub-58, 30 Oct).
|
|
268
|
+
*/
|
|
269
|
+
export async function reportUsage(accounts, { request = devRequest, readers = READERS, probes = readProbes, probe = false } = {}) {
|
|
270
|
+
const results = [];
|
|
271
|
+
const probing = probe && accounts.some((account) => account.runtime === "codex") ? await probes() : {};
|
|
272
|
+
for (const account of accounts) {
|
|
273
|
+
try {
|
|
274
|
+
const reading = await readers[account.runtime](account.profile, probing[account.profile] ? { probe: probing[account.profile] } : {});
|
|
275
|
+
const body = usageReport(reading);
|
|
276
|
+
await request("POST", `/api/v1/runners/${encode(account.id)}/usage`, body);
|
|
277
|
+
// Bump liveness only: a status someone set (offline) is theirs to change.
|
|
278
|
+
await request("POST", `/api/v1/runners/${encode(account.id)}/heartbeat`, {});
|
|
279
|
+
results.push({ runnerId: account.id, runtime: account.runtime, profile: account.profile, state: body.state, band: usageBand(reading.windows), resetsAt: body.resetsAt, ...reading.windows });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
results.push({ runnerId: account.id, runtime: account.runtime, profile: account.profile, error: { code: error?.code ?? "ACCOUNT_USAGE_UNAVAILABLE", message: error?.message ?? String(error) } });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return results;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const percent = (window) => window ? `${Math.round(window.utilization)}%` : "n/a";
|
|
288
|
+
export const usageLine = (result) => result.error
|
|
289
|
+
? `${result.runtime} ${result.profile}: not reported (${result.error.code}: ${result.error.message})`
|
|
290
|
+
: `${result.runtime} ${result.profile}: ${result.band ?? result.state} (5h ${percent(result.fiveHour)}, week ${percent(result.sevenDay)})`;
|
|
291
|
+
|
|
292
|
+
function parseAccountFlag(value) {
|
|
293
|
+
const match = /^(claude|codex):(.+)$/.exec(String(value));
|
|
294
|
+
if (!match) throw new CliError("USAGE", "--account must be RUNTIME:PROFILE, for example claude:/Users/me/.claude-work.", 2);
|
|
295
|
+
const account = { runtime: match[1], profile: profilePath(match[2]) };
|
|
296
|
+
checkAccount(account);
|
|
297
|
+
return account;
|
|
298
|
+
}
|
|
299
|
+
export const accountFlags = (value) => (value === undefined ? [] : Array.isArray(value) ? value : [value]).map(parseAccountFlag);
|
|
300
|
+
|
|
301
|
+
/** The coordinator run `coordinator start --machine NAME` saved, if any. */
|
|
302
|
+
async function savedCoordinatorRun(machine) {
|
|
303
|
+
try { return JSON.parse(await readFile(join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.json`), "utf8")); }
|
|
304
|
+
catch { return null; }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** `machine accounts register|list` and `machine usage report`. */
|
|
308
|
+
export async function machineCapacityCommand(area, action, flags, { request = devRequest, config = readConfig, readers = READERS, savedRun = savedCoordinatorRun } = {}) {
|
|
309
|
+
const machineId = flags["machine-id"] ?? await coordinatorMachineId({ request, config });
|
|
310
|
+
// A coordinator coordinates one machine; --machine NAME must be that one.
|
|
311
|
+
const saved = flags.machine ? await savedRun(flags.machine) : null;
|
|
312
|
+
if (saved?.machineId && saved.machineId !== machineId)
|
|
313
|
+
throw new CliError("MACHINE_COORDINATOR_REQUIRED", `This Dev identity coordinates a different machine than ${flags.machine}.`, 1);
|
|
314
|
+
if (area === "accounts" && action === "register") {
|
|
315
|
+
if ((flags.probe || flags["no-probe"]) && flags.runtime !== "codex") throw new CliError("USAGE", "--probe and --no-probe apply to a codex account only.", 2);
|
|
316
|
+
const account = await registerAccount(machineId, { runtime: flags.runtime, profile: flags.profile }, { request });
|
|
317
|
+
if (flags.probe) {
|
|
318
|
+
const codexBin = await resolveCodexBin();
|
|
319
|
+
if (!codexBin) throw new CliError("CODEX_NOT_FOUND", "--probe needs the codex binary: put it on PATH or set NODII_DEV_CODEX_BIN.", 1);
|
|
320
|
+
await saveProbe(account.profile, codexBin);
|
|
321
|
+
}
|
|
322
|
+
if (flags["no-probe"]) await saveProbe(account.profile, null);
|
|
323
|
+
const [result] = await reportUsage([account], { request, readers });
|
|
324
|
+
return { payload: { data: { account, usage: result } }, human: `Registered ${account.runtime} account ${account.profile} (${account.id}).\n${usageLine(result)}` };
|
|
325
|
+
}
|
|
326
|
+
if (area === "accounts" && action === "list") {
|
|
327
|
+
const accounts = await listAccounts(machineId, { request });
|
|
328
|
+
return { payload: { data: accounts }, human: accounts.map((a) => `${a.id}\t${a.runtime}\t${a.profile}\t${a.status}`).join("\n") || "No accounts registered on this machine." };
|
|
329
|
+
}
|
|
330
|
+
if (area === "usage" && action === "report") {
|
|
331
|
+
const all = await listAccounts(machineId, { request });
|
|
332
|
+
const accounts = flags.profile ? all.filter((a) => a.profile === profilePath(flags.profile)) : all;
|
|
333
|
+
if (!accounts.length)
|
|
334
|
+
throw new CliError("ACCOUNT_NOT_REGISTERED", `No account${flags.profile ? ` ${flags.profile}` : "s"} registered on this machine. Run nodii-dev machine accounts register --runtime claude|codex --profile PATH first.`, 1);
|
|
335
|
+
const results = await reportUsage(accounts, { request, readers, probe: Boolean(flags.probe) });
|
|
336
|
+
const failed = results.filter((r) => r.error);
|
|
337
|
+
if (failed.length === results.length)
|
|
338
|
+
throw new CliError(failed[0].error.code, results.map(usageLine).join("\n"), 1);
|
|
339
|
+
return { payload: { data: results }, human: results.map(usageLine).join("\n") };
|
|
340
|
+
}
|
|
341
|
+
throw new CliError("USAGE", "machine accounts register --runtime claude|codex --profile PATH | machine accounts list | machine usage report [--profile PATH]", 2);
|
|
342
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -59,6 +59,7 @@ import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
|
|
|
59
59
|
import { changeSetCommand, guidelineCommand } from "./guidelines.js";
|
|
60
60
|
import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
|
|
61
61
|
import { watchdogCommand } from "./watchdog.js";
|
|
62
|
+
import { machineCapacityCommand } from "./accounts.js";
|
|
62
63
|
import {
|
|
63
64
|
ALIASES,
|
|
64
65
|
GROUPS,
|
|
@@ -150,7 +151,8 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
150
151
|
harness check # 0 current, 1 differs from Dev, 2 not installed
|
|
151
152
|
harness update --check # the same check
|
|
152
153
|
watchdog install [--interval SECONDS] # install/update the per-Mac launchd liveness sweep (default 60s)
|
|
153
|
-
watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees
|
|
154
|
+
watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees,
|
|
155
|
+
# and re-report account usage every 5 min
|
|
154
156
|
docs check|validate|upload <path...> --class ruling|conformance|editorial
|
|
155
157
|
[--base CODE=VERSION] [--decision FILE] [--conformance-ref REF]
|
|
156
158
|
[--meaning-unchanged REASON] [--wait]
|
|
@@ -176,10 +178,14 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
176
178
|
gather timings [--model M] [--last N] # this Mac's lane Gather picks: warm-up and first-pick p50/p95
|
|
177
179
|
# (each lane gather logs one; the first pick has 120 s once warm)
|
|
178
180
|
|
|
179
|
-
coordinator start --machine NAME
|
|
181
|
+
coordinator start --machine NAME [--account RUNTIME:PROFILE ...]
|
|
182
|
+
# register this machine's one watched coordinator; refuses a live second;
|
|
183
|
+
# registers each --account and reports every account's usage; a background
|
|
184
|
+
# heartbeater keeps the run alive while this session lives, then ends it
|
|
180
185
|
lane launch <key> --done-check TEXT --kind KIND --side SIDE
|
|
181
186
|
--issue KEY --repo NAME=/SOURCE --branch NAME=BRANCH [--repo ... --branch ...]
|
|
182
187
|
--machine NAME --runtime claude|codex --profile PATH --model MODEL --subagents N
|
|
188
|
+
--class long|short|no-build # MW1-38.3 class, weighed against the account's MW1-38.2 band
|
|
183
189
|
[--base NAME=REF] [--worktree NAME=PATH] [--owned-path NAME=PATH]
|
|
184
190
|
[--route /app/... ...] [--figma FILE_KEY[=COMPONENT_SET_KEY[,KEY]]] [--notes TEXT]
|
|
185
191
|
[--narrowing-note TEXT --narrowing-remainder DESTINATION]
|
|
@@ -228,6 +234,15 @@ Usage: nodii-dev <command> [options] [--json]
|
|
|
228
234
|
# local document pick; prints Lane contract then Clause map
|
|
229
235
|
built list | built ack <key...>
|
|
230
236
|
machine list|show|set-coordinator|assign|report
|
|
237
|
+
machine accounts register --runtime claude|codex --profile PATH [--probe|--no-probe] [--machine NAME]
|
|
238
|
+
# coordinator: one runner per model account; reads and reports its usage;
|
|
239
|
+
# --probe (codex): opt in to one minimal codex turn, at launch or an explicit
|
|
240
|
+
# report --probe, when its reading is 10+ min old (never from the watch loop)
|
|
241
|
+
machine accounts list [--machine NAME]
|
|
242
|
+
machine usage report [--profile PATH] [--probe] [--machine NAME]
|
|
243
|
+
# coordinator: each account's 5-hour and weekly windows, read-only
|
|
244
|
+
# (Claude usage endpoint / newest Codex rollout); lane launch needs one
|
|
245
|
+
# under 15 min old, and the watchdog sweep re-reports every 5 min
|
|
231
246
|
agent lane-limit|grant|revoke
|
|
232
247
|
people reveal <id> --reason TEXT | people refresh-names
|
|
233
248
|
|
|
@@ -698,6 +713,8 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
698
713
|
if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
|
|
699
714
|
if (command === "next") return emitCommand(await nextCommand(action, f));
|
|
700
715
|
if (command === "built") return emitCommand(await builtCommand(action, rest, f));
|
|
716
|
+
if (command === "machine" && ["accounts", "usage"].includes(action))
|
|
717
|
+
return emitCommand(await machineCapacityCommand(action, rest[0], f));
|
|
701
718
|
if (command === "machine") return emitCommand(await machineCommand(action, rest, f));
|
|
702
719
|
if (command === "people" && ["reveal", "refresh-names"].includes(action))
|
|
703
720
|
return emitCommand(await peopleCommand(action, rest, f));
|
package/dist/core.js
CHANGED
|
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.6.
|
|
7
|
+
export const VERSION = "0.6.38";
|
|
8
8
|
export const configPath = () =>
|
|
9
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
10
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
|
@@ -222,7 +222,7 @@ export async function checkLogin() {
|
|
|
222
222
|
export const encode = (value) => encodeURIComponent(value);
|
|
223
223
|
// Switches never take the next token as their value, so
|
|
224
224
|
// `docs upload --wait rulebook/` keeps rulebook/ as a path.
|
|
225
|
-
const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system", "delta"]);
|
|
225
|
+
const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system", "delta", "probe", "no-probe"]);
|
|
226
226
|
export function parseArgs(argv) {
|
|
227
227
|
const flags = {};
|
|
228
228
|
const args = [];
|
package/dist/setup.js
CHANGED
|
@@ -18,7 +18,7 @@ description: Drive every Nodii build lane through nodii-dev from start to verifi
|
|
|
18
18
|
|
|
19
19
|
Use \`nodii-dev\` as the control plane. It talks only to Dev, through the platform edge, signed in with \`nodii-dev login\` as the platform tenant's OAuth app. The canonical policy is the build-workflow rulebook in Dev (\`nodii-dev rulebook list\`), §§1–8 (operator rulings A-020/A-021). Never change lane state in Dev by another route.
|
|
20
20
|
|
|
21
|
-
1. The machine coordinator runs \`nodii-dev coordinator start --machine NAME
|
|
21
|
+
1. The machine coordinator runs \`nodii-dev coordinator start --machine NAME [--account RUNTIME:PROFILE ...]\` (each model account a lane may run on is registered once; its usage is reported then, before each launch and by the watchdog), then \`nodii-dev lane launch KEY\` with the issue (whose title is the objective), done check, build kind/side, repeated repository/branch pairs, runtime profile/model, subagent quota and the lane's class (\`--class long|short|no-build\`, weighed against the account's usage band). A launched agent begins with \`nodii-dev lane gather\` from its worktree.
|
|
22
22
|
2. Re-run \`nodii-dev lane gather\` for the current steps and rules; start, complete, or block the reported API step with \`nodii-dev lane step\`. After an authoritative \`lane amend\`, every run is blocked until its own \`lane gather --delta\` acknowledgement.
|
|
23
23
|
3. Record durable evidence with \`nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF\` and heartbeat long-running work with \`nodii-dev lane heartbeat KEY --note "..."\` (notes are at most 500 characters).
|
|
24
24
|
4. Record the review gate with \`nodii-dev lane review KEY\`. After merge, run \`nodii-dev lane land KEY\`. Done still means deployed and checked live on dev as the right user: \`nodii-dev lane verify KEY --dev-check REF\`.
|
package/dist/starting.js
CHANGED
|
@@ -7,6 +7,7 @@ import { CliError, configPath, devRequest, encode, query, readConfig, requireFla
|
|
|
7
7
|
import { assertKnownSets, checkComponentSetKey, checkFileKey, loadDsCatalog, mergeScopes, scopesFromRoutes } from "./figma-scope.js";
|
|
8
8
|
import { GATHER_TIMEOUT_MS, gatherDeadline } from "./gather.js";
|
|
9
9
|
import { laneCommand, nextCommand } from "./lanes.js";
|
|
10
|
+
import { accountFlags, listAccounts, profilePath, registerAccount, reportUsage, usageLine } from "./accounts.js";
|
|
10
11
|
|
|
11
12
|
const execFile = promisify(execFileCallback);
|
|
12
13
|
const via = { via: "nodii-dev" };
|
|
@@ -147,17 +148,99 @@ async function watchCoordinator(runId) {
|
|
|
147
148
|
});
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
const heartbeatPidFile = (machine) => join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.heartbeat.pid`);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start the coordinator run's heartbeater: a detached `coordinator heartbeat`
|
|
155
|
+
* process tied to the coordinator session's PID (hub-58, 30 Oct). Its PID is
|
|
156
|
+
* saved next to the coordinator state so only it is ever signalled.
|
|
157
|
+
*/
|
|
158
|
+
async function spawnHeartbeater({ runId, machine, pid, processStartedAt, intervalSeconds }) {
|
|
159
|
+
const { spawn } = await import("node:child_process");
|
|
160
|
+
const { openSync } = await import("node:fs");
|
|
161
|
+
const log = openSync(join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.heartbeat.log`), "a", 0o600);
|
|
162
|
+
const child = spawn(process.execPath, [
|
|
163
|
+
resolve(process.argv[1]), "coordinator", "heartbeat", "--machine", machine, "--run", runId, "--pid", String(pid),
|
|
164
|
+
...(processStartedAt ? ["--pid-started-at", processStartedAt] : []), "--interval", String(intervalSeconds),
|
|
165
|
+
], { detached: true, stdio: ["ignore", log, log], env: { ...process.env, NODII_DEV_CONFIG_PATH: configPath() } });
|
|
166
|
+
child.unref();
|
|
167
|
+
await writeFile(heartbeatPidFile(machine), `${child.pid}\n`, { mode: 0o600 });
|
|
168
|
+
return child.pid;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The heartbeater's loop: heartbeat the coordinator run while its session
|
|
173
|
+
* lives; when the session exits, end the run and stop. A run that a later
|
|
174
|
+
* `coordinator start` replaced, or that Dev already ended, is left alone.
|
|
175
|
+
*/
|
|
176
|
+
export async function coordinatorHeartbeatLoop({ machine, runId, pid, processStartedAt, intervalMs }, {
|
|
177
|
+
request = devRequest,
|
|
178
|
+
isAlive = async (target, startedAt) => (await import("./watchdog.js")).isProcessAlive(target, startedAt),
|
|
179
|
+
currentRunId = async () => (await loadJson(stateFile(machine)))?.runId,
|
|
180
|
+
sleep = (ms) => delay(ms),
|
|
181
|
+
} = {}) {
|
|
182
|
+
for (;;) {
|
|
183
|
+
if ((await currentRunId()) !== runId) return { stopped: "replaced" };
|
|
184
|
+
if (!(await isAlive(pid, processStartedAt))) {
|
|
185
|
+
try {
|
|
186
|
+
await request("POST", `/api/v1/runs/${encode(runId)}/end`, {
|
|
187
|
+
status: "done",
|
|
188
|
+
evidence: [{ kind: "process", ref: `pid ${pid}`, note: "The coordinator session exited." }],
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error?.code !== "RUN_ALREADY_TERMINAL") throw error;
|
|
192
|
+
}
|
|
193
|
+
return { stopped: "session_exited" };
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, { note: `coordinator pid ${pid} alive` });
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error?.code === "RUN_ALREADY_TERMINAL") return { stopped: "terminal" };
|
|
199
|
+
process.stderr.write(`coordinator heartbeat failed (${error?.code ?? error?.message ?? "error"}); retrying.\n`);
|
|
200
|
+
}
|
|
201
|
+
await sleep(intervalMs);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Register each --account, then report every account's usage (MW1-38.1). */
|
|
206
|
+
async function coordinatorCapacity(machineId, flags, { request, readers }) {
|
|
207
|
+
// The run is already started: a refused registration is reported, not thrown.
|
|
208
|
+
const refused = [];
|
|
209
|
+
for (const account of accountFlags(flags.account)) {
|
|
210
|
+
try { await registerAccount(machineId, account, { request }); }
|
|
211
|
+
catch (error) { refused.push({ ...account, error: { code: error?.code ?? "ACCOUNT_REGISTER_FAILED", message: error?.message ?? String(error) } }); }
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const reported = await reportUsage(await listAccounts(machineId, { request }), { request, ...(readers ? { readers } : {}) });
|
|
215
|
+
return [...refused, ...reported];
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return [...refused, { runtime: "accounts", profile: "(all)", error: { code: error?.code ?? "ACCOUNT_USAGE_REPORT_FAILED", message: error?.message ?? String(error) } }];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function coordinatorCommand(action, flags, { request = devRequest, config = readConfig, save = saveJson, watch = watchCoordinator, readers, heartbeater = spawnHeartbeater, heartbeatLoop = coordinatorHeartbeatLoop } = {}) {
|
|
222
|
+
if (action === "heartbeat") {
|
|
223
|
+
const result = await heartbeatLoop({
|
|
224
|
+
machine: requireFlag(flags, "machine"),
|
|
225
|
+
runId: requireFlag(flags, "run"),
|
|
226
|
+
pid: Number(requireFlag(flags, "pid")),
|
|
227
|
+
processStartedAt: flags["pid-started-at"] ?? null,
|
|
228
|
+
intervalMs: Number(flags.interval ?? 30) * 1000,
|
|
229
|
+
}, { request });
|
|
230
|
+
return { payload: { data: result }, human: `Coordinator heartbeater stopped: ${result.stopped}.` };
|
|
231
|
+
}
|
|
151
232
|
if (action !== "start") throw new CliError("USAGE", `Unknown coordinator action: ${action}`, 2);
|
|
152
233
|
const machine = requireFlag(flags, "machine");
|
|
234
|
+
accountFlags(flags.account);
|
|
153
235
|
const payload = await request("POST", "/api/v1/coordinator-runs/start", {
|
|
154
236
|
machine,
|
|
155
237
|
expectedHeartbeatSeconds: flags["heartbeat-seconds"] ? Number(flags["heartbeat-seconds"]) : 60,
|
|
156
238
|
...via,
|
|
157
239
|
});
|
|
158
240
|
const c = await config();
|
|
241
|
+
let watched;
|
|
159
242
|
try {
|
|
160
|
-
await watch(payload.data.run.id);
|
|
243
|
+
watched = await watch(payload.data.run.id);
|
|
161
244
|
} catch (error) {
|
|
162
245
|
try {
|
|
163
246
|
await request("POST", `/api/v1/runs/${encode(payload.data.run.id)}/process-exit`, {
|
|
@@ -175,14 +258,30 @@ export async function coordinatorCommand(action, flags, { request = devRequest,
|
|
|
175
258
|
agentId: c.agentId,
|
|
176
259
|
startedAt: payload.data.run.startedAt,
|
|
177
260
|
});
|
|
261
|
+
// The run expects a heartbeat every expectedHeartbeatSeconds; heartbeat at
|
|
262
|
+
// half that so one late beat never makes it quiet.
|
|
263
|
+
const expected = payload.data.run.expectedHeartbeatSeconds ?? (flags["heartbeat-seconds"] ? Number(flags["heartbeat-seconds"]) : 60);
|
|
264
|
+
let heartbeaterPid = null;
|
|
265
|
+
let heartbeaterError = null;
|
|
266
|
+
if (watched?.pid) {
|
|
267
|
+
try { heartbeaterPid = await heartbeater({ runId: payload.data.run.id, machine, pid: watched.pid, processStartedAt: watched.processStartedAt ?? null, intervalSeconds: Math.max(5, Math.floor(expected / 2)) }); }
|
|
268
|
+
catch (error) { heartbeaterError = error?.message ?? String(error); }
|
|
269
|
+
}
|
|
270
|
+
const accounts = await coordinatorCapacity(payload.data.machine.id, flags, { request, readers });
|
|
178
271
|
return {
|
|
179
|
-
payload,
|
|
272
|
+
payload: { ...payload, accounts, heartbeaterPid },
|
|
180
273
|
human: (p) => [
|
|
181
274
|
`Coordinator ${p.data.run.id} is watching ${p.data.machine.name}.`,
|
|
182
275
|
`running lanes: ${p.data.runningLanes.length}`,
|
|
183
276
|
`orphans: ${p.data.orphanedRuns.length}`,
|
|
184
277
|
`unclaimed results: ${p.data.unclaimedResults.length}`,
|
|
185
278
|
...(p.data.takeoverOfRunId ? [`takeover recorded from ${p.data.takeoverOfRunId}`] : []),
|
|
279
|
+
heartbeaterPid
|
|
280
|
+
? `heartbeater ${heartbeaterPid} keeps the run alive while session ${watched.pid} lives`
|
|
281
|
+
: `heartbeater not started${heartbeaterError ? ` (${heartbeaterError})` : ""}: the run goes quiet without heartbeats`,
|
|
282
|
+
...(accounts.length
|
|
283
|
+
? accounts.map((result) => `account ${usageLine(result)}`)
|
|
284
|
+
: [`accounts: none registered; add one with --account claude:/path/to/profile (lane launch needs its usage)`]),
|
|
186
285
|
].join("\n"),
|
|
187
286
|
};
|
|
188
287
|
}
|
|
@@ -277,8 +376,16 @@ export async function figmaRateLimit(task, {
|
|
|
277
376
|
}
|
|
278
377
|
}
|
|
279
378
|
|
|
379
|
+
async function refreshAccountUsage(machineId, runtime, profile, { request }) {
|
|
380
|
+
const accounts = (await listAccounts(machineId, { request })).filter((a) => a.runtime === runtime && a.profile === profile);
|
|
381
|
+
// A launch is about to spend this account anyway, so an opted-in Codex
|
|
382
|
+
// account may probe for a fresh reading here.
|
|
383
|
+
return reportUsage(accounts, { request, probe: true });
|
|
384
|
+
}
|
|
385
|
+
|
|
280
386
|
export async function launchLaneCommand(key, flags, {
|
|
281
387
|
request = devRequest,
|
|
388
|
+
capacity = refreshAccountUsage,
|
|
282
389
|
config = readConfig,
|
|
283
390
|
state = loadJson,
|
|
284
391
|
worktrees = createWorktrees,
|
|
@@ -310,8 +417,12 @@ export async function launchLaneCommand(key, flags, {
|
|
|
310
417
|
throw new CliError("LANE_IDENTITY_REQUIRED", `Lane ${key} needs its provisioned Dev identity at ${devConfigPath}.`, 1);
|
|
311
418
|
const scopes = await launchFigmaScopes(flags, { catalog });
|
|
312
419
|
const figmaSeatVerified = scopes.length ? await figmaSeat() : false;
|
|
313
|
-
const profile = requireFlag(flags, "profile");
|
|
420
|
+
const profile = profilePath(requireFlag(flags, "profile"));
|
|
314
421
|
if (!isAbsolute(profile)) throw new CliError("USAGE", "--profile must be an absolute runtime profile path.", 2);
|
|
422
|
+
// MW1-38.3: the coordinator classes the lane; Dev weighs it against the
|
|
423
|
+
// account's MW1-38.2 band.
|
|
424
|
+
const workClass = { long: "long", short: "short", "no-build": "no_build" }[requireFlag(flags, "class")];
|
|
425
|
+
if (!workClass) throw new CliError("USAGE", "--class must be long, short or no-build (a one-file fix, a publish or verify relaunch, or an evidence-only step).", 2);
|
|
315
426
|
const body = {
|
|
316
427
|
key,
|
|
317
428
|
doneCheck: requireFlag(flags, "done-check"),
|
|
@@ -327,6 +438,7 @@ export async function launchLaneCommand(key, flags, {
|
|
|
327
438
|
devConfigPath,
|
|
328
439
|
model: requireFlag(flags, "model"),
|
|
329
440
|
subagentQuota: Number(requireFlag(flags, "subagents")),
|
|
441
|
+
workClass,
|
|
330
442
|
...(flags.notes ? { notes: flags.notes } : {}),
|
|
331
443
|
...(flags["narrowing-note"] ? { narrowingNote: flags["narrowing-note"] } : {}),
|
|
332
444
|
...(flags["narrowing-remainder"] ? { narrowingRemainder: flags["narrowing-remainder"] } : {}),
|
|
@@ -337,6 +449,10 @@ export async function launchLaneCommand(key, flags, {
|
|
|
337
449
|
};
|
|
338
450
|
if (!Number.isInteger(body.subagentQuota) || body.subagentQuota < 0 || body.subagentQuota > 100)
|
|
339
451
|
throw new CliError("USAGE", "--subagents must be an integer from 0 to 100.", 2);
|
|
452
|
+
// MW1-38.1: read the account's limits before every launch. A failed read is
|
|
453
|
+
// not fatal here; Dev's preflight refuses a missing or stale reading and
|
|
454
|
+
// names the command that fixes it.
|
|
455
|
+
try { await capacity(coordinator.machineId, runtime, profile, { request }); } catch {}
|
|
340
456
|
await request("POST", "/api/v1/lanes/launch", { phase: "preflight", ...body });
|
|
341
457
|
await worktrees(repositories);
|
|
342
458
|
const lastPushedCommit = await head(repositories);
|
package/dist/watchdog.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir, platform } from "node:os";
|
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
|
|
7
|
+
import { USAGE_REFRESH_MS, listAccounts, reportUsage } from "./accounts.js";
|
|
7
8
|
|
|
8
9
|
const execFile = promisify(execFileCallback);
|
|
9
10
|
export const WATCHDOG_LABEL = "com.nodii.watchdog";
|
|
@@ -173,6 +174,29 @@ const observationBody = ({ observation, machineId, pid, observedAt, detail }) =>
|
|
|
173
174
|
observation, machineId, ...(pid === undefined ? {} : { pid }), observedAt, ...(detail ? { detail } : {}),
|
|
174
175
|
});
|
|
175
176
|
|
|
177
|
+
const usageStampPath = (home = homedir()) => join(watchdogHome(home), "usage-reported-at");
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Keep the machine's account readings fresh (MW1-38.1: at least every 10
|
|
181
|
+
* minutes): re-report every USAGE_REFRESH_MS, so lane launch's 15-minute
|
|
182
|
+
* window holds without anyone remembering (AGENT-HARNESS-43).
|
|
183
|
+
*/
|
|
184
|
+
export async function refreshMachineUsage(machineId, {
|
|
185
|
+
request = devRequest,
|
|
186
|
+
now = () => new Date(),
|
|
187
|
+
readStamp = async () => { try { return Date.parse(await readFile(usageStampPath(), "utf8")); } catch { return Number.NaN; } },
|
|
188
|
+
writeStamp = (at) => atomicWrite(usageStampPath(), at),
|
|
189
|
+
list = listAccounts,
|
|
190
|
+
report = reportUsage,
|
|
191
|
+
} = {}) {
|
|
192
|
+
const last = await readStamp();
|
|
193
|
+
if (Number.isFinite(last) && now().getTime() - last < USAGE_REFRESH_MS) return null;
|
|
194
|
+
// Never probe here: a probe spends the account's quota (hub-58, 30 Oct).
|
|
195
|
+
const results = await report(await list(machineId, { request }), { request, probe: false });
|
|
196
|
+
await writeStamp(now().toISOString());
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
/** One launchd sweep. The machine coordinator reports every observation through Dev's run watchdog contract. */
|
|
177
201
|
export async function sweepWatchdog({
|
|
178
202
|
records,
|
|
@@ -182,6 +206,7 @@ export async function sweepWatchdog({
|
|
|
182
206
|
saveRecord = saveWatchedRun,
|
|
183
207
|
machineId,
|
|
184
208
|
now = () => new Date(),
|
|
209
|
+
refreshUsage = refreshMachineUsage,
|
|
185
210
|
} = {}) {
|
|
186
211
|
const loaded = records ? { records, errors: [] } : await loadRecords();
|
|
187
212
|
const facts = await collect(loaded.records, { errors: [...loaded.errors] });
|
|
@@ -241,7 +266,18 @@ export async function sweepWatchdog({
|
|
|
241
266
|
facts.errors.push({ kind: "run", runId: record.runId, observation: "process_dead", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
|
|
242
267
|
}
|
|
243
268
|
}
|
|
244
|
-
|
|
269
|
+
// A coordinator session has no command loop to heartbeat its run: while its
|
|
270
|
+
// process lives, the sweep does (a lane heartbeats its own run, so a stuck
|
|
271
|
+
// lane still goes quiet).
|
|
272
|
+
let accounts = null;
|
|
273
|
+
try {
|
|
274
|
+
accounts = await refreshUsage(effectiveMachineId, { request, now });
|
|
275
|
+
for (const result of accounts ?? [])
|
|
276
|
+
if (result.error) facts.errors.push({ kind: "usage", runnerId: result.runnerId, code: result.error.code });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
facts.errors.push({ kind: "usage", code: error?.code ?? "ACCOUNT_USAGE_REPORT_FAILED" });
|
|
279
|
+
}
|
|
280
|
+
const report = { ...facts, orphanedRuns, accounts };
|
|
245
281
|
return report;
|
|
246
282
|
}
|
|
247
283
|
|