@edgehero/pi-dispatch 4.0.1 → 4.1.0

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/src/cli.mjs CHANGED
@@ -52,6 +52,9 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
52
52
  pi-dispatch pause stop taking new jobs (durable; survives worker restart)
53
53
  pi-dispatch resume resume taking jobs
54
54
  pi-dispatch status show paused state + job counts
55
+ pi-dispatch capacity [--since 24h|7d|30d] [--host <name>] [--json] [--valkey-url <url>]
56
+ how busy each host was: busy and idle time, slots in use, memory and CPU
57
+ promised and used, waits and projects, read from the run records (jobs only)
55
58
  pi-dispatch cancel <jobId> stop one job: a queued or held job is removed (the line says whether it had
56
59
  made attempts; cancel records nothing), a running one is aborted on whichever
57
60
  host owns it (its record says operator-cancel)
@@ -235,6 +238,12 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
235
238
  return code;
236
239
  }
237
240
 
241
+ if (cmd === "capacity") {
242
+ // Read-only, and on the kill switch's footing: the Valkey URL and the logs directory, never loadConfig (issue #599).
243
+ const { runCapacity } = await import("./capacity-cli.mjs");
244
+ return runCapacity(argv.slice(1), { env, write, valkeyRefusal, deploymentEnv: cliDeploymentEnv });
245
+ }
246
+
238
247
  if (cmd === "cancel") {
239
248
  // The kill switch's doctrine (issue #287): VALKEY_URL only, never loadConfig, so one misbehaving
240
249
  // job can be stopped even when everything else about the deployment is misconfigured. On a shell/.env
package/src/config.mjs CHANGED
@@ -19,6 +19,9 @@ import { jobSizeDefaults } from "./job-size.mjs";
19
19
  import { hostBudgetSettings } from "./host-budget.mjs";
20
20
  import { CONTAINER_ENV_NAMES, KEYLESS_ENV_NAME, RUNNER_ENV_NAMES } from "./reserved-env.mjs";
21
21
  import { modelListProblem } from "./model-ref.mjs";
22
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
23
+
24
+ export { WORKER_NAME_RE };
22
25
  import { DOLLAR_ENV_NAMES, DOLLAR_WINDOW_KEYS, checkDollarInvariant, optionalUsdMicros } from "./money.mjs";
23
26
 
24
27
  /**
@@ -1146,26 +1149,7 @@ export function underOsTempDir(candidate, env = process.env, { realpath = realpa
1146
1149
  return false;
1147
1150
  }
1148
1151
 
1149
- /**
1150
- * What a worker may call itself (issue #57). The CHARACTER CLASS is `sanitizeJobId`'s
1151
- * (`[A-Za-z0-9._-]`), reused rather than invented so this project has one name-safe alphabet -- but that
1152
- * function is a REPLACER, not a validator, so the three rules around the class are NEW and are claimed
1153
- * as new here rather than borrowed:
1154
- *
1155
- * - a leading alphanumeric, which is what refuses `..` and a leading `-` that reads as a flag;
1156
- * - a 64-character ceiling, because the name is a Valkey key segment and a log field on every line;
1157
- * - no `.json`/`.log` tail, which is not decoration. The class contains the dot, so `prod.json` is
1158
- * otherwise a legal name -- and a later slice writes a per-host marker file into `PI_LOGS_DIR`,
1159
- * where `<something>.json` is parsed as a run record by the admin and DELETED by the log reaper.
1160
- * A name is refused here rather than escaped there, because the escape would have to be remembered
1161
- * at every site that ever composes a filename from this value.
1162
- *
1163
- * The class is `:`-free, `,`-free and `#`-free, which is what lets the name be a Valkey key segment
1164
- * UNHASHED. That is the point of validating instead of hashing (`scopeKeyPrefix` does the opposite for
1165
- * a folder path, which was never chosen for key-safety and cannot be refused): the whole value of a host
1166
- * registry is that `HGETALL host:h:mac-mini-1` is readable by a human.
1167
- */
1168
- export const WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
1152
+ // WORKER_NAME_RE lives in worker-name.mjs (a leaf, for the capacity report) and is re-exported above; its reasons are there.
1169
1153
 
1170
1154
  /** True when the name would collide with the run-history filename namespace. See WORKER_NAME_RE. */
1171
1155
  const RESERVED_NAME_TAIL = /\.(json|log)$/i;
@@ -189,8 +189,10 @@ export function parseConnection(url, { failFast = false, servername = null, cont
189
189
  * `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
190
190
  * `failFast` as ioredis and `parseConnection` read them.
191
191
  */
192
- export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false } = {}) {
193
- return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}) });
192
+ export function makeRedisClient(url, { servername = null, context = null, failFast = false, lazyConnect = false, judge = null, withoutPassword = false, disconnectTimeoutMs = null } = {}) {
193
+ // `disconnectTimeoutMs` (issue #599): how long ioredis's `disconnect()` waits for a socket to close before it destroys
194
+ // it (its default is 2 s, on a ref'd timer). A one-shot read that must not hold its process passes 0.
195
+ return new Redis({ ...parseConnection(url, { failFast, servername, context, judge, withoutPassword }), ...(lazyConnect ? { lazyConnect: true } : {}), ...(Number.isSafeInteger(disconnectTimeoutMs) && disconnectTimeoutMs >= 0 ? { disconnectTimeout: disconnectTimeoutMs } : {}) });
194
196
  }
195
197
 
196
198
  /**
package/src/doctor.mjs CHANGED
@@ -99,6 +99,8 @@ import { CONTAINER_HOME, SHIPPED_IMAGE_UID, SIZE_LABEL_CPU, SIZE_LABEL_MEM } fro
99
99
  import { DEFAULT_JOB_SIZE, cpuCeilingCenti, formatCpus, formatMemory, jobSizeDefaults, resolveJobSize } from "./job-size.mjs";
100
100
  import { SUGGEST_WINDOW_DAYS, cpusText, hostCap, refusalWords, sizeRefusal, suggestSize, suggestionCall, suggestionEvidence } from "./size-suggest.mjs";
101
101
  import { SIZING_RECORD_MAX_BYTES, readSizingRecords } from "./size-records.mjs";
102
+ import { CAPACITY_WINDOWS, computeCapacity } from "./capacity.mjs";
103
+ import { JOBS_ONLY, durationText, milliText, notSharedWhy, percentText, shareText } from "./capacity-cli.mjs";
102
104
  import { CGROUP_PARENT, cgroupParentFor, operatorQuotaCommand, readQuota, reservePlan, userQuotaCommand } from "./cpu-reserve.mjs";
103
105
  import { HOST_BUDGET_KEYS, computeHostBudget, hostBudgetSettings, largestFit, neverFits, projectBudgetRow, publishedBudget, readUserServiceLimits } from "./host-budget.mjs";
104
106
  import { makeImagePreflight, normalizeImageId } from "./image-preflight.mjs";
@@ -220,6 +222,9 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
220
222
  dollarKeysExist,
221
223
  // Issue #504 part B: the applied split's envelope digest (`alloc:plan`), read once. Undefined means the default.
222
224
  readAppliedSplit,
225
+ // Issue #599, phase 2: the capacity line's read of the run history (the run mirror and the logs directory).
226
+ // Undefined means the default, which reads the deployment's real Valkey and files.
227
+ readCapacity,
223
228
  // --live (issue #278, INT-LIVE-PROBE-CONTRACT): read the backend declarations back off short-lived real containers.
224
229
  // STRICTLY `=== true`, so only the CLI's own flag arms it: a truthy string from a caller that forwarded an
225
230
  // option bag runs nothing. The fs, PID-liveness and nonce are seams so the sequence is driven without Docker.
@@ -383,7 +388,7 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
383
388
  return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from };
384
389
  }
385
390
  : null;
386
- const seams = { cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
391
+ const seams = { cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
387
392
  // Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the
388
393
  // same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is
389
394
  // handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env`
@@ -595,7 +600,7 @@ export const ENV_FILE_READABLE_KEYS = Object.freeze(["PI_PAUSE_WINDOWS_FILE", "P
595
600
  export const GITHUB_SERVICE_KEYS = Object.freeze(["GITHUB_AUTH_SOURCE", "GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_APP_PRIVATE_KEY"]);
596
601
  /** Issue #471: the worker's settings doctor judges, which it read from this shell alone while the service read them from
597
602
  * `.env`. TEMP is TMPDIR's twin in the worker's temp root; PI_CODING_AGENT_DIR is where the worker reads auth.json. */
598
- export const WORKER_SERVICE_KEYS = Object.freeze(["PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS", "PI_CONCURRENCY", ...Object.values(HOST_BUDGET_KEYS), "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "PI_ALLOWED_MODELS", "PI_DISPATCH_RUN_ROOTS", "GITHUB_PAT_VAR", "TEMP", ...Object.values(DOLLAR_ENV_NAMES)]);
603
+ export const WORKER_SERVICE_KEYS = Object.freeze(["PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS", "PI_CONCURRENCY", ...Object.values(HOST_BUDGET_KEYS), "PI_TRIGGERS_FILE", "PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_SETTINGS_FILE", "PI_SESSIONS_DIR", "PI_SESSIONS_TTL_DAYS", "PI_SESSION_MAX_AGE_DAYS", "PI_SESSION_MAX_CONTEXT_PCT", "PI_SESSION_MAX_RESUME_CHAIN", "PI_GLOBAL_PI_DIR", "PI_GLOBAL_ALLOW_EXTENSIONS", "PI_FORWARD_ENV", "PI_AUTH_FROM_PI", "PI_CODING_AGENT_DIR", "PI_BACKEND_FLOOR", "PI_SECRET_PROFILES", "PI_SECRET_RESOLVER_ROOTS", "PI_WAIT_PROFILES", "PI_WAIT_AFTER_MAX_MS", "PI_SANDBOX_RETENTION_HOURS", "PI_ALLOWED_MODELS", "PI_DISPATCH_RUN_ROOTS", "GITHUB_PAT_VAR", "TEMP", ...Object.values(DOLLAR_ENV_NAMES)]);
599
604
  /** Issue #471: the receiver's keys doctor judges its boot by (the receiver's unit reads the same `.env`). */
600
605
  export const RECEIVER_SERVICE_KEYS = Object.freeze(["WEBHOOK_SECRET", "RECEIVER_PORT", "GITLAB_TOKEN", "GITLAB_URL", "GITLAB_WEBHOOK_MODE", "GITLAB_WEBHOOK_SECRET", "FORGEJO_URL", "FORGEJO_TOKEN", "FORGEJO_WEBHOOK_SECRET", "AZURE_ORG_URL", "AZURE_TOKEN", "AZURE_WEBHOOK_MODE", "AZURE_WEBHOOK_SECRET", "AZURE_WEBHOOK_HEADER"]);
601
606
  /**
@@ -2594,6 +2599,11 @@ export async function collectChecks(shellVars, seams) {
2594
2599
  // Said, rather than silently absent: "no peers" and "could not ask" are different facts.
2595
2600
  checks.push({ ok: true, label: `Fleet: could not read the host registry (${printable(fleet.unreachable)})` });
2596
2601
  }
2602
+ // Issue #599, phase 2: how busy each host was over the last 7 days, one fact line per host, never a warning (nothing
2603
+ // decides on it). On every deployment, a single host too: its own files are read when the run mirror is not. Read from
2604
+ // the same Valkey the fleet was, with the registry rows just read (their running jobs count up to now), every Valkey
2605
+ // call bounded and the whole read under `CAPACITY_DOCTOR_TIMEOUT_MS`, so a slow mirror costs this line, not the run.
2606
+ checks.push(...(await doctorCapacity({ seams, env, home, url: valkeyUsable ? valkeyTalkUrl : null, hosts: fleet.hosts ?? [], localHost: workerNameOf(declaredWorkerName) })));
2597
2607
  // Issue #504 part B: the APPLIED split names the envelope it was made for, and every host
2598
2608
  // whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
2599
2609
  // the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
@@ -4722,21 +4732,15 @@ export function appliedSplitChecks(applied, mine, myName, peers) {
4722
4732
  */
4723
4733
  export async function defaultReadAppliedSplit(url) {
4724
4734
  try {
4725
- const { makeRedisClient } = await import("./connection.mjs");
4726
- const client = makeRedisClient(url, { failFast: true, lazyConnect: true });
4727
- client.on("error", () => {});
4728
- try {
4729
- await client.connect();
4730
- const text = await client.get(ALLOC_PLAN_KEY);
4735
+ return await withDoctorClient(url, async (client) => {
4736
+ const text = await boundedOp(client.get(ALLOC_PLAN_KEY), DOCTOR_VALKEY_OP_TIMEOUT_MS);
4731
4737
  if (text === null || text === undefined) return null;
4732
4738
  let digest = null;
4733
4739
  try {
4734
4740
  digest = JSON.parse(text)?.envelopeDigest;
4735
4741
  } catch {}
4736
4742
  return typeof digest === "string" && /^[0-9a-f]{16}$/.test(digest) ? { digest } : { undecodable: true };
4737
- } finally {
4738
- client.disconnect();
4739
- }
4743
+ });
4740
4744
  } catch {
4741
4745
  return null;
4742
4746
  }
@@ -7389,16 +7393,9 @@ function workerNameOf(declared) {
7389
7393
 
7390
7394
  async function defaultReadHosts(url) {
7391
7395
  try {
7392
- const { makeRedisClient } = await import("./connection.mjs");
7393
7396
  const { readLiveHosts } = await import("./host-registry.mjs");
7394
- const client = makeRedisClient(url, { failFast: true, lazyConnect: true });
7395
- client.on("error", () => {});
7396
- try {
7397
- await client.connect();
7398
- return await readLiveHosts(client);
7399
- } finally {
7400
- client.disconnect();
7401
- }
7397
+ // readLiveHosts bounds each of its own commands.
7398
+ return await withDoctorClient(url, (client) => readLiveHosts(client));
7402
7399
  } catch (err) {
7403
7400
  return { unreachable: err?.message ?? "registry unreadable" };
7404
7401
  }
@@ -7447,35 +7444,60 @@ export async function dollarKeysExistWith(client, { now = () => new Date(), dead
7447
7444
  /** `dollarKeysExistWith` over a fail-fast client on `url`, always disconnected. */
7448
7445
  export async function defaultDollarKeysExist(url) {
7449
7446
  try {
7450
- const { makeRedisClient } = await import("./connection.mjs");
7451
- const client = makeRedisClient(url, { failFast: true, lazyConnect: true });
7452
- client.on("error", () => {});
7453
- try {
7454
- await client.connect();
7455
- return await dollarKeysExistWith(client);
7456
- } finally {
7457
- client.disconnect();
7458
- }
7447
+ // dollarKeysExistWith bounds its whole read.
7448
+ return await withDoctorClient(url, (client) => dollarKeysExistWith(client));
7459
7449
  } catch {
7460
7450
  return false;
7461
7451
  }
7462
7452
  }
7463
7453
 
7464
7454
  async function defaultProbeValkey(url) {
7465
- const { makeRedisClient } = await import("./connection.mjs");
7466
- const client = makeRedisClient(url, { failFast: true, lazyConnect: true });
7467
- client.on("error", () => {}); // swallow connect errors + retries; reachability is the ✓/✗, not a trace
7468
7455
  try {
7469
- await client.connect();
7470
- await client.ping();
7471
- return true;
7456
+ return await withDoctorClient(url, async (client) => (await boundedOp(client.ping(), DOCTOR_VALKEY_OP_TIMEOUT_MS), true));
7472
7457
  } catch {
7473
7458
  return false;
7459
+ }
7460
+ }
7461
+
7462
+ /** How long doctor waits for a Valkey connection to be ready (the TCP connect AND the ready check), and for one command. */
7463
+ export const DOCTOR_VALKEY_CONNECT_TIMEOUT_MS = 5_000;
7464
+ export const DOCTOR_VALKEY_OP_TIMEOUT_MS = 5_000;
7465
+
7466
+ /**
7467
+ * One fail-fast client for one of doctor's own Valkey reads, its connection bounded, always dropped. Measured: a
7468
+ * server that accepts the connection and never answers holds ioredis in its ready check forever (`failFast` bounds
7469
+ * only the TCP connect), so doctor never exited; and `disconnect()` arms a 2 s timer a closed socket never clears, so
7470
+ * the client is built with a disconnect timeout of 0 and its stream destroyed. Throws when the connection is not ready
7471
+ * in time; `fn`'s own commands are the caller's to bound (`boundedOp`).
7472
+ */
7473
+ export async function withDoctorClient(url, fn, { connectTimeoutMs = DOCTOR_VALKEY_CONNECT_TIMEOUT_MS, makeClient } = {}) {
7474
+ const make = makeClient ?? (await import("./connection.mjs")).makeRedisClient;
7475
+ const client = make(url, { failFast: true, lazyConnect: true, disconnectTimeoutMs: 0 });
7476
+ client.on?.("error", () => {}); // swallow connect errors + retries; reachability is the ✓/✗, not a trace
7477
+ try {
7478
+ await connectWithin(client, connectTimeoutMs);
7479
+ return await fn(client);
7474
7480
  } finally {
7475
- client.disconnect();
7481
+ try {
7482
+ client.disconnect?.();
7483
+ client.stream?.destroy?.();
7484
+ } catch {
7485
+ // a release that failed has stopped mattering
7486
+ }
7476
7487
  }
7477
7488
  }
7478
7489
 
7490
+ /** A command's reply, or a rejection after `ms` (the client is dropped by `withDoctorClient` either way). */
7491
+ export function boundedOp(promise, ms) {
7492
+ return new Promise((resolve, reject) => {
7493
+ const t = setTimeout(() => reject(new Error("timeout")), ms);
7494
+ Promise.resolve(promise).then(
7495
+ (v) => (clearTimeout(t), resolve(v)),
7496
+ (e) => (clearTimeout(t), reject(e)),
7497
+ );
7498
+ });
7499
+ }
7500
+
7479
7501
  /**
7480
7502
  * The deployment's default job size as doctor reads it (issue #596): `PI_JOB_MEMORY` and `PI_JOB_CPUS` through the
7481
7503
  * worker's own rule (`jobSizeDefaults`), or the built-in 4g and 2 when they do not parse (`jobSizeChecks` reports that as
@@ -7756,6 +7778,162 @@ export function hostBudgetChecks(view, { concurrency = 3, limits = [], env = {},
7756
7778
  return checks;
7757
7779
  }
7758
7780
 
7781
+ /** The most the capacity read may take in doctor, all of it: a full mirror reads in well under a second. */
7782
+ export const CAPACITY_DOCTOR_TIMEOUT_MS = 5_000;
7783
+
7784
+ /**
7785
+ * The capacity lines (issue #599, phase 2): the report over the last 7 days (`capacity.mjs`, the CLI's function), read
7786
+ * by `seams.readCapacity` (default: `readCapacityRecords` through a fail-fast client, `prune`-free and writing nothing).
7787
+ * Never throws and never warns: an unreadable history is one line saying so.
7788
+ */
7789
+ export async function doctorCapacity({ seams, env, home, url, hosts, localHost }) {
7790
+ try {
7791
+ const window = CAPACITY_WINDOWS["7d"];
7792
+ const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
7793
+ const retention = typeof env.PI_LOG_RETENTION_DAYS === "string" && /^\d{1,6}$/.test(env.PI_LOG_RETENTION_DAYS) ? Number(env.PI_LOG_RETENTION_DAYS) : 30;
7794
+ // The read is told to stop when the bound passes (`signal`): giving up on it is not enough, since a client still
7795
+ // waiting on its connection would keep this process alive after doctor has printed everything.
7796
+ const stop = new AbortController();
7797
+ const args = { url, logsDir: logsDirPath(env, home), sinceMs: nowMs - window.ms, nowMs, retentionDays: retention, localHost, signal: stop.signal };
7798
+ let timer;
7799
+ const read = await Promise.race([
7800
+ Promise.resolve()
7801
+ .then(() => (seams.readCapacity ?? defaultReadCapacity)(args))
7802
+ .finally(() => clearTimeout(timer)),
7803
+ new Promise((resolve) => {
7804
+ timer = setTimeout(() => {
7805
+ stop.abort();
7806
+ resolve(null);
7807
+ }, seams.capacityTimeoutMs ?? CAPACITY_DOCTOR_TIMEOUT_MS);
7808
+ }),
7809
+ ]);
7810
+ if (read === null) return [{ ok: true, label: "Capacity: the run history did not answer in time, so nothing is shown (pi-dispatch capacity waits up to 2 s per Valkey call)" }];
7811
+ const report = computeCapacity({ records: read.records, live: hosts, windowStartMs: nowMs - window.ms, nowMs, bucketMs: window.bucketMs, coverage: read.coverage });
7812
+ return capacityChecks(report, { since: "7d" });
7813
+ } catch (err) {
7814
+ if (err?.[FORBIDDEN_READ]) throw err;
7815
+ return [{ ok: true, label: `Capacity: not read (${printable(err?.message ?? "error")})` }];
7816
+ }
7817
+ }
7818
+
7819
+ /**
7820
+ * Set by the test helper (test/helpers/doctor.mjs): from then on the DEFAULT capacity read throws an error doctor does
7821
+ * not swallow, so a test that reaches the developer's real Valkey and logs directory fails loudly instead of printing
7822
+ * whatever that machine holds.
7823
+ */
7824
+ let defaultCapacityReadForbidden = false;
7825
+ export function forbidDefaultCapacityRead() {
7826
+ defaultCapacityReadForbidden = true;
7827
+ }
7828
+ const FORBIDDEN_READ = Symbol("forbidden-read");
7829
+
7830
+ /** How long the default read waits for its Valkey connection to be ready (the TCP connect and the ready check). */
7831
+ export const CAPACITY_CONNECT_TIMEOUT_MS = 2_000;
7832
+
7833
+ /**
7834
+ * The default capacity read: a fail-fast client, its connection bounded (`connectWithin`: a server that accepts TCP and
7835
+ * never answers holds ioredis in its ready check forever) and DISCONNECTED when `signal` aborts, so a read doctor gave
7836
+ * up on cannot keep the process alive.
7837
+ */
7838
+ async function defaultReadCapacity({ url, logsDir, sinceMs, nowMs, retentionDays, localHost, signal }) {
7839
+ if (defaultCapacityReadForbidden) throw Object.assign(new Error("doctor reached its real capacity read in a test: pass readCapacity"), { [FORBIDDEN_READ]: true });
7840
+ const { readCapacityRecords } = await import("./capacity-records.mjs");
7841
+ let client = null;
7842
+ // Measured on ioredis 5.11: `disconnect()` ends the socket and arms a timer (`disconnectTimeout`, 2 s by default)
7843
+ // that destroys it unless the socket's `close` clears it first, and the client's own failed ready check disconnects
7844
+ // again after the close, arming one that nothing clears: a server that never answered held the process 2 s past
7845
+ // doctor's bound. So the client is built with a disconnect timeout of 0 (no timer outlives the socket), its stream
7846
+ // is destroyed in the same tick (the socket is closed by the time doctor prints), and it is dropped once.
7847
+ const drop = () => {
7848
+ const c = client;
7849
+ client = null;
7850
+ try {
7851
+ c?.disconnect?.();
7852
+ c?.stream?.destroy?.();
7853
+ } catch {
7854
+ // a release that failed has stopped mattering
7855
+ }
7856
+ };
7857
+ signal?.addEventListener?.("abort", drop, { once: true });
7858
+ if (url && !signal?.aborted) {
7859
+ try {
7860
+ const { makeRedisClient } = await import("./connection.mjs");
7861
+ client = makeRedisClient(url, { failFast: true, lazyConnect: true, disconnectTimeoutMs: 0 });
7862
+ client.on("error", () => {});
7863
+ await connectWithin(client, CAPACITY_CONNECT_TIMEOUT_MS, signal);
7864
+ } catch {
7865
+ drop();
7866
+ }
7867
+ }
7868
+ try {
7869
+ return await readCapacityRecords({ redis: client, logsDir, sinceMs, nowMs, retentionDays, localHost, noMirrorReason: url ? "the Valkey did not answer: only this host's files were read" : "no Valkey to read: only this host's files were read" });
7870
+ } finally {
7871
+ signal?.removeEventListener?.("abort", drop);
7872
+ drop();
7873
+ }
7874
+ }
7875
+
7876
+ /**
7877
+ * `client.connect()`, or a rejection after `ms` or as soon as `signal` aborts (the client is then the caller's to
7878
+ * disconnect). The timer goes with an abort, so nothing of a read doctor gave up on holds the process.
7879
+ */
7880
+ export function connectWithin(client, ms, signal) {
7881
+ return new Promise((resolve, reject) => {
7882
+ const stop = () => (clearTimeout(t), reject(new Error("connect stopped")));
7883
+ const t = setTimeout(() => (signal?.removeEventListener?.("abort", stop), reject(new Error("connect timeout"))), ms);
7884
+ signal?.addEventListener?.("abort", stop, { once: true });
7885
+ Promise.resolve(client.connect()).then(
7886
+ (v) => (clearTimeout(t), signal?.removeEventListener?.("abort", stop), resolve(v)),
7887
+ (e) => (clearTimeout(t), signal?.removeEventListener?.("abort", stop), reject(e)),
7888
+ );
7889
+ });
7890
+ }
7891
+
7892
+ /**
7893
+ * One fact line per host of a capacity report (`INT-CAPACITY-REPORT`), `{ ok: true, label }` each, never a warning:
7894
+ * `Host a: last 7d busy 63% (avg 2.1 of 4 slots, full 12%), promised 48% memory / 40% CPU, used 18% CPU of 8, wait p50
7895
+ * 40s p95 6m, most busy: web`, each part only when it is known, then what the line cannot see: the running jobs not
7896
+ * counted, and the coverage when the history is cut, not shared, or this host's files only. Every control character is
7897
+ * removed (the report admits none; this is the printer not relying on it).
7898
+ */
7899
+ export function capacityChecks(report, { since = "7d" } = {}) {
7900
+ const out = [];
7901
+ const localOnly = report?.coverage?.source === "local";
7902
+ for (const h of Array.isArray(report?.hosts) ? report.hosts : []) {
7903
+ const notes = [];
7904
+ let line;
7905
+ if (h.coveredMs === 0) {
7906
+ line = `Host ${h.name}: last ${since} no history here`;
7907
+ if (!h.shared) notes.push(notSharedWhy(h, report.coverage));
7908
+ } else {
7909
+ const c = h.capacity ?? {};
7910
+ const slots = Number.isSafeInteger(c.slots) ? ` of ${c.slots} slots` : " at once";
7911
+ const full = h.fullMs !== null ? `, full ${shareText(h.fullMs, h.coveredMs)}` : "";
7912
+ const parts = [`busy ${shareText(h.busyMs, h.coveredMs)} (avg ${milliText(h.avgMilli ?? 0)}${slots}${full})`];
7913
+ const promised = [h.promisedMemPerMille !== null ? `${percentText(h.promisedMemPerMille)} memory` : null, h.promisedCpuPerMille !== null ? `${percentText(h.promisedCpuPerMille)} CPU` : null].filter(Boolean);
7914
+ if (promised.length > 0) parts.push(`promised ${promised.join(" / ")}`);
7915
+ if (h.usedCpuPerMille !== null) parts.push(`used ${percentText(h.usedCpuPerMille)} CPU${Number.isSafeInteger(c.cpus) ? ` of ${c.cpus}` : ""}`);
7916
+ if (h.waits?.n > 0) parts.push(`wait p50 ${durationText(h.waits.p50Ms)} p95 ${durationText(h.waits.p95Ms)}`);
7917
+ if (h.projects?.length > 0) parts.push(`most busy: ${h.projects[0].project ?? "(no project)"}`);
7918
+ line = `Host ${h.name}: last ${since} ${parts.join(", ")}`;
7919
+ if (h.coverage.live > 0) notes.push(`${h.coverage.live} running now, counted to now`);
7920
+ if (h.missingMs > 0) notes.push(`history from ${new Date(h.coverage.fromMs).toISOString().slice(0, 16).replace("T", " ")} UTC only${h.coverage.truncated ? " (the run mirror holds nothing older)" : ""}, earlier time counted as neither busy nor idle`);
7921
+ // The reason with it: a run mirror that did not answer leaves a busy fleet reading nearly idle here.
7922
+ // The reasons end in "only this host's files were read", which this clause already says.
7923
+ const why = typeof report.coverage.reason === "string" ? report.coverage.reason.replace(/: only this host's files were read$/, "") : "";
7924
+ if (localOnly) notes.push(`this host's files only${why ? ` (${why})` : ""}`);
7925
+ }
7926
+ if (h.coverage?.liveNotCounted > 0) notes.push(`${h.coverage.liveNotCounted} running now not counted`);
7927
+ if (h.coverage?.liveUnreadable > 0) notes.push("its running jobs could not be read, not counted");
7928
+ if (report.coverage?.liveRowMissing === h.name) notes.push("no live row read for it, so its running jobs are not known");
7929
+ out.push({ ok: true, label: `${line}${notes.length > 0 ? `; ${notes.join("; ")}` : ""}`.replace(/[\u0000-\u001f\u007f-\u009f]/g, "") });
7930
+ }
7931
+ // What none of these lines can see, in the words every surface of the report uses (REQ-CAPACITY-INSIGHTS): a fact,
7932
+ // never a warning, once after the hosts.
7933
+ if (out.length > 0) out.push({ ok: true, label: JOBS_ONLY.replace(/\.$/, "") });
7934
+ return out;
7935
+ }
7936
+
7759
7937
  /**
7760
7938
  * The fleet's budgets (issue #596, phase 2), from the registry rows (this host's own included): one line per host that
7761
7939
  * publishes a budget (its budget, what its jobs hold, what its holds keep, and the largest project size that fits it),
@@ -484,6 +484,9 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
484
484
  return Number.isSafeInteger(limit) && limit >= 0 ? limit : null;
485
485
  };
486
486
 
487
+ // The runtime's own CPU count from the last facts read (`docker info` NCPU, `podman info` host.cpus), or null: what a
488
+ // run record names as the host's CPUs (issue #599), never the worker's own count, which on Docker Desktop is the Mac's.
489
+ let hostCpus = null;
487
490
  const refresh = async () => {
488
491
  let facts = {};
489
492
  try {
@@ -493,6 +496,7 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
493
496
  }
494
497
  const next = computeHostBudget(settings, facts, jobDefault);
495
498
  budget = { memMiB: next.memMiB, cpuCenti: next.cpuCenti };
499
+ hostCpus = Number.isSafeInteger(facts.hostCpus) && facts.hostCpus >= 1 ? facts.hostCpus : null;
496
500
  detail = next.detail;
497
501
  for (const entry of ledger.values()) {
498
502
  if (entry.guess) Object.assign(entry, pessimisticSize([], entry.guess, budget));
@@ -618,6 +622,8 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
618
622
  refresh,
619
623
  /** The budget in force: `{ memMiB, cpuCenti }`, each an integer, `Infinity` (off) or null (unknown). */
620
624
  current: () => ({ ...budget }),
625
+ /** The runtime's CPU count from the last facts read, or null when no venue answered. */
626
+ hostCpus: () => hostCpus,
621
627
  detail: () => detail,
622
628
  /** null when `size` can start here some day, else `host` or `share` (`neverFits`). */
623
629
  neverFits: (size, project, limits = scopedLimits()) => neverFits(size, budget, project ? rulesOf(limits).shareOf(project) : null),
@@ -29,8 +29,14 @@
29
29
  * from the reader rather than the writer -- the panel is where an operator screenshots, and doctor
30
30
  * prints these rows. A value that must be carried but cannot satisfy the rule is HASHED before it gets
31
31
  * here (`scopeKeyPrefix`'s idiom), never abbreviated.
32
+ *
33
+ * `jobs` (issue #599, phase 2) is the one field that carries a list: JSON of the jobs this host runs now, ids and
34
+ * integers only, built and read back by `live-jobs.mjs`, whose header argues each field against this rule. The reader
35
+ * below parses it through that module's allowlist, so a row's `jobs` is an array (or null), never the raw string.
32
36
  */
33
37
 
38
+ import { parseJobsMore, parseLiveJobs } from "./live-jobs.mjs";
39
+
34
40
  /** The index. A SET cannot expire its members, so the leak is handled by the reader, as `wait:held` does. */
35
41
  export const HOST_SET = "host:live";
36
42
 
@@ -270,7 +276,7 @@ export function makeHostRegistry({ redis, name, now = () => Date.now(), ttlMs =
270
276
  * one treats them alike: "there are no other hosts" and "I could not find out" differ, and a panel that
271
277
  * renders the second as the first tells an operator their fleet is gone when Valkey merely blinked.
272
278
  */
273
- export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS } = {}) {
279
+ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS, prune = true } = {}) {
274
280
  let names;
275
281
  try {
276
282
  names = await bounded(redis.smembers(HOST_SET), timeoutMs);
@@ -285,12 +291,24 @@ export async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs =
285
291
  try {
286
292
  const row = await bounded(redis.hgetall(hostKey(member)), timeoutMs);
287
293
  if (!row || Object.keys(row).length === 0) {
288
- await bounded(redis.srem(HOST_SET, member), timeoutMs).catch(() => {});
294
+ // `prune: false` for a reader that promises to write nothing (the capacity report, issue #599): the stale member
295
+ // is skipped the same, and left for the next pruning reader.
296
+ if (prune) await bounded(redis.srem(HOST_SET, member), timeoutMs).catch(() => {});
289
297
  continue;
290
298
  }
291
299
  const beatAt = typeof row.beatAt === "string" && row.beatAt.trim() !== "" ? Number(row.beatAt) : NaN;
300
+ // The running jobs through the allowlist (issue #599, phase 2): an array, or null where the row publishes none (a
301
+ // worker from before the field) or none that parses. A listed entry that does not read is a running job not
302
+ // counted, so it joins `jobsMore` rather than vanishing.
303
+ const live = parseLiveJobs(row.jobs);
304
+ const more = parseJobsMore(row.jobsMore);
292
305
  hosts.push({
293
306
  ...row,
307
+ jobs: live.jobs,
308
+ jobsMore: live.jobs === null ? more : (more ?? 0) + live.dropped,
309
+ // A row that HAS a `jobs` value that is not a list: how many it runs is unknown, which a reader must say
310
+ // rather than read as a worker from before the field.
311
+ jobsUnreadable: live.jobs === null && typeof row.jobs === "string" && row.jobs !== "",
294
312
  name: row.name || member,
295
313
  // Derived rather than stored, so the panel can say "stale 2m" about a row that still lives.
296
314
  // A row whose clock is AHEAD of ours reads as 0 rather than negative: the difference is the