@edgehero/pi-dispatch 4.0.1 → 4.2.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/start.mjs CHANGED
@@ -25,6 +25,7 @@ import { builtinModel, checkModelsKnown } from "./model-catalog.mjs";
25
25
  import { capabilityTokens, serializeCaps } from "./capabilities.mjs";
26
26
  import { cronFingerprint } from "./fingerprint.mjs";
27
27
  import { makeHostRegistry } from "./host-registry.mjs";
28
+ import { liveJobsFields, liveJobsOf } from "./live-jobs.mjs";
28
29
  import { budgetField, readUserServiceLimits } from "./host-budget.mjs";
29
30
  import { makeCpuReserve, reservePlan } from "./cpu-reserve.mjs";
30
31
  import { makeImagePreflight } from "./image-preflight.mjs";
@@ -61,9 +62,12 @@ import { PODMAN_BOOT_REFUSING_CAUSES, PODMAN_INFO_TIMEOUT_MS, cachedPodmanInfo,
61
62
  import { PODMAN_RESTART_HOLD_EXPIRED, makePodmanServiceReader, onceFs, makeRootfulMemory, observeHost, observeRootfulConf, readRootfulService, rootfulConfRefusal, rootfulConfRetries, rootfulUnreadList, runtimeObservationKey } from "./runtime-observations.mjs";
62
63
 
63
64
  import { makeRunContainer } from "./run-container.mjs";
64
- import { resolveProviderCredential } from "./env-allowlist.mjs";
65
+ import { piProviders, providerKeyCandidates, resolveProviderCredential } from "./env-allowlist.mjs";
66
+ import { parseAllowlist } from "./egress-allowlist.mjs";
67
+ import { MCP_TRANSIENT_READ_CODES, jobProviders, makeMcpCheck, providerKeyNames } from "./mcp-preflight.mjs";
68
+ import { RESERVED_ENV_NAMES } from "./triggers.mjs";
65
69
  import { makeSecretsResolver } from "./secrets.mjs";
66
- import { buildRecord, EXIT_OOM_KILLED, makeFindPreviousRun, makeLogReaper, makeLogSink, makeReadRecord, makeRecordWriter, makeSettledRecord, RUNNER_POLICY_REASONS, sanitizeJobId } from "./run-history.mjs";
70
+ import { buildRecord, EXIT_OOM_KILLED, makeFindPreviousRun, makeLogReaper, makeLogSink, makeReadRecord, makeRecordWriter, makeSettledRecord, newerRecord, RUNNER_POLICY_REASONS, sanitizeJobId, UNREADABLE_RECORD } from "./run-history.mjs";
67
71
  import { makeRunMirror, readMirroredRecord } from "./run-mirror.mjs";
68
72
  import { readOverlay, resolveSettings } from "./runtime-settings.mjs";
69
73
  import { usdFingerprint } from "./dollar-fingerprint.mjs";
@@ -1368,7 +1372,7 @@ export async function startWorker(
1368
1372
  // would be bytes nothing reads. That is also what keeps a single-host deployment byte-identical, since
1369
1373
  // no job then issues a single extra Valkey command.
1370
1374
  const runMirror = config.workerNameDeclared ? makeRunMirrorFn({ redis, retentionDays: config.logRetentionDays, log }) : null;
1371
- const recordRun = ({ job, result, error, startedAt, endedAt, project, size = null }) => {
1375
+ const recordRun = ({ job, result, error, startedAt, endedAt, project, size = null, capacity = null, earlier = null }) => {
1372
1376
  // The project (issue #499) was resolved at the pickup gate and rides here as `project` (an id or null), so a live
1373
1377
  // edit of projects.json mid-run cannot make the record disagree with what the job was counted against. A record
1374
1378
  // path that ends BEFORE the pickup gate (the wait gate's refusals) passes none, and resolves from the live ref
@@ -1378,7 +1382,7 @@ export async function startWorker(
1378
1382
  // four `recordRun` call sites byte-unchanged and `buildRecord` a pure function of its arguments.
1379
1383
  // The default venue rides the same way and for the same reason (#277): it is the value the registry
1380
1384
  // below is built with, so the record resolves a job's venue exactly as dispatch does.
1381
- const record = buildRecord({ job, result, error, startedAt, endedAt, host: config.workerName, defaultBackend: config.defaultBackend, project: projectId, size });
1385
+ const record = buildRecord({ job, result, error, startedAt, endedAt, host: config.workerName, defaultBackend: config.defaultBackend, project: projectId, size, capacity, earlier });
1382
1386
  writeRecord(record);
1383
1387
  // STRICTLY AFTER the file, and deliberately not awaited. After, because a crash between the two must
1384
1388
  // leave a record with no fleet row rather than a fleet row with no record -- the mirror is a VIEW,
@@ -1399,10 +1403,23 @@ export async function startWorker(
1399
1403
  // The record read back, for a job the queue lost the lock of after it finished (DES-TERMINAL-COMMENTS-AND-FAILURE-HOOK,
1400
1404
  // CONST-RETRY-INFRA-ONLY): this host's own file first, then the fleet's copy where a mirror is armed, because the
1401
1405
  // host that meets the stalled job need not be the one that ran it. A single host has no mirror and needs none.
1406
+ const readRecord = makeReadRecord({ logsDir: config.logsDir });
1402
1407
  const settledRecord = makeSettledRecord({
1403
- readRecord: makeReadRecord({ logsDir: config.logsDir }),
1408
+ readRecord,
1404
1409
  readMirrored: runMirror ? (jobId) => readMirroredRecordFn(redis, sanitizeJobId(jobId)) : null,
1405
1410
  });
1411
+ // The record a retry (or a pickup after a stall) is about to replace (issue #599), read BOTH ways: this host's file and
1412
+ // the fleet's copy where a mirror is armed. Both, because each host keeps its own file: a job that ran on A, then B,
1413
+ // then A again finds A's stale first attempt locally while B's second is in the mirror. `newerRecord` keeps the higher
1414
+ // attempt (a tie: the later end). Its slot interval rides the new record as `earlier`. Never rejects: unreadable or
1415
+ // unreachable is no record from that side.
1416
+ const previousRecord = async (jobId) => {
1417
+ const read = readRecord(jobId);
1418
+ const local = read === UNREADABLE_RECORD ? null : read;
1419
+ if (!runMirror) return local;
1420
+ const mirrored = await readMirroredRecordFn(redis, sanitizeJobId(jobId)).catch(() => null);
1421
+ return newerRecord(local, mirrored === UNREADABLE_RECORD ? null : mirrored);
1422
+ };
1406
1423
 
1407
1424
  // INT-CONFIG-OVERLAY-CONTRACT: the worker reads the runtime-settings overlay at EACH job start, so this
1408
1425
  // closure -- not a value frozen at boot -- is what the processor calls per job. It resolves the fourteen
@@ -1618,6 +1635,18 @@ export async function startWorker(
1618
1635
  const snap = worker?.hostBudget?.snapshot?.();
1619
1636
  return Number.isSafeInteger(snap?.[key]) ? String(snap[key]) : "";
1620
1637
  };
1638
+ // Issue #599, phase 2: the jobs this host runs now and its budget's orphans, oldest first, as the row's two fields
1639
+ // (`live-jobs.mjs`). Without a budget there is no ledger, so no orphan, and the list is the in-flight map alone.
1640
+ const liveJobsNow = () => {
1641
+ const read = (fn) => {
1642
+ try {
1643
+ return fn() ?? [];
1644
+ } catch {
1645
+ return [];
1646
+ }
1647
+ };
1648
+ return liveJobsFields(liveJobsOf({ running: read(() => worker?.runningJobs?.list?.()), budgetEntries: read(() => worker?.hostBudget?.entries?.()) }));
1649
+ };
1621
1650
  // NOT awaited, and that is load-bearing rather than an optimisation. `makeRedisClient` sets
1622
1651
  // `maxRetriesPerRequest: null` -- required for BullMQ's blocking connections -- which means a command
1623
1652
  // issued against an unreachable server QUEUES FOREVER instead of rejecting. Awaiting the first beat
@@ -1684,6 +1713,14 @@ export async function startWorker(
1684
1713
  heldMemMiB: () => snapshotField("heldMemMiB"),
1685
1714
  heldCpuCenti: () => snapshotField("heldCpuCenti"),
1686
1715
  budgetRunning: () => snapshotField("running"),
1716
+ // Issue #599, phase 2: the jobs waiting for the budget ("" without one), and the jobs this host runs now: `jobs` is
1717
+ // JSON of at most 32 `{ id, p, m, c, at, o? }` (job id, project id or null, size integers, admission millis, 1 for an
1718
+ // orphan), oldest first, `jobsMore` how many are not listed. Ids and integers only (live-jobs.mjs says why that
1719
+ // meets the content rule). Each thunk builds the list again; the beat reads every thunk with no await between, so
1720
+ // the two agree. "" before the worker exists.
1721
+ waiters: () => snapshotField("waiters"),
1722
+ jobs: () => (worker ? liveJobsNow().jobs : ""),
1723
+ jobsMore: () => (worker ? liveJobsNow().jobsMore : ""),
1687
1724
  budgetHolds: () => snapshotField("holds"),
1688
1725
  budgetOrphans: () => snapshotField("orphans"),
1689
1726
  // whether the boot listing of the job containers left from before this worker started has been read
@@ -2059,6 +2096,7 @@ export async function startWorker(
2059
2096
  redis,
2060
2097
  recordRun,
2061
2098
  settledRecord,
2099
+ previousRecord,
2062
2100
  extraClosers,
2063
2101
  // REQ-SCOPED-PAUSE-WINDOWS: the processor defers a job whose folder/repo is inside an active window.
2064
2102
  // Reads the live-reloaded ref, so an operator edit takes effect on the next job without a restart.
@@ -2221,6 +2259,42 @@ export async function startWorker(
2221
2259
  // injected env they were not, which is the divergence this file already calls out by name for
2222
2260
  // sessionsDir. One deployment value, one place, same rule as the profiles below it.
2223
2261
  resolveSecrets: makeSecretsResolverFn({ envProfiles: config.secretProfiles, roots: config.secretResolverRoots, timeoutMs: config.secretResolveTimeoutMs, forwardEnv: config.forwardEnv, hostEnv: env, log }),
2262
+ // REQ-MCP-OPERATOR-SERVERS: the overlay's mcp.json read once per job that names a server, never at boot (an
2263
+ // operator who re-runs import-pi must not have to restart the worker). The allowlist is the one the proxy
2264
+ // mounts, this folder's egress-allowlist.conf on both venues (up.mjs, podman-stack.mjs), and the endpoints
2265
+ // are the pickup's snapshot, so the check and the proxy read the same declarations.
2266
+ checkMcp: makeMcpCheck({
2267
+ globalPiDir: config.globalPiDir,
2268
+ forwardEnv: config.forwardEnv,
2269
+ hostEnv: env,
2270
+ egress: config.egress,
2271
+ // DES-FORGE-MCP: the GitLab a gitlab job's forge server calls, the same URL its clone and its glab use.
2272
+ gitlabUrl: config.gitlab?.apiUrl ?? null,
2273
+ // The credentials the job is set up to spend with (providerKeyNames). A read of the overlay models.json that
2274
+ // may pass on a retry is thrown to the check, which retries the job; any other failure adds no overlay
2275
+ // names, and the model gates, which ran first, refuse every job such a file breaks.
2276
+ reserved: (job) => {
2277
+ let overlayModels = null;
2278
+ try {
2279
+ overlayModels = readOverlayModels(config.globalPiDir);
2280
+ } catch (error) {
2281
+ if (MCP_TRANSIENT_READ_CODES.has(error?.code)) throw error;
2282
+ }
2283
+ const providers = jobProviders(job, { defaultProvider: config.provider, allowedModels: config.allowedModels, everyProvider: piProviders() });
2284
+ return new Set([...RESERVED_ENV_NAMES, ...providerKeyNames({ providers, candidatesOf: providerKeyCandidates, overlayModels })]);
2285
+ },
2286
+ allowlist: () => {
2287
+ try {
2288
+ return parseAllowlist(readFileSync(join(process.cwd(), "egress-allowlist.conf"), "utf8"));
2289
+ } catch (error) {
2290
+ // No allowlist here: every server outside a declared endpoint is unreachable, which it is. Any other
2291
+ // failure is the check's to classify (a transient errno is retried).
2292
+ if (error?.code === "ENOENT") return [];
2293
+ throw error;
2294
+ }
2295
+ },
2296
+ endpoints: () => modelEndpoints.current,
2297
+ }),
2224
2298
  // #227. What PI_BACKENDS blessed, so a trigger naming an unblessed venue refuses pre-spend. The
2225
2299
  // panel's picker is bounded by the same list, and this is the half that binds: the overlay is
2226
2300
  // not the reviewed artifact (DES-PER-TRIGGER-SECRET-PROFILE).
@@ -475,6 +475,9 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
475
475
  // unknown), the job would run under the deployment's wider cap, or with none, on a clean record. Any present
476
476
  // value counts as authored; whether it is a valid amount is the loader's question, not this one.
477
477
  Object.freeze({ key: "maxCostUsd", authored: (v) => v !== undefined && v !== null }),
478
+ // REQ-MCP-OPERATOR-SERVERS: the MCP servers a trigger's jobs connect. Not a widening when dropped but the same
479
+ // silent outcome: the job runs its flow without the servers it was written against, on a clean record.
480
+ Object.freeze({ key: "mcp", authored: (v) => Array.isArray(v) && v.length > 0 }),
478
481
  ]);
479
482
 
480
483
  export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
package/src/triggers.mjs CHANGED
@@ -32,6 +32,8 @@ import { imageRefProblem } from "./image-ref.mjs";
32
32
  import { SKILL_NAME_RE } from "./flow-gate.mjs";
33
33
  import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
34
34
  import { findDuplicateKey } from "./json-duplicates.mjs";
35
+ // The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
36
+ import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
35
37
  import { validateModelRef } from "./model-ref.mjs";
36
38
  import { parseUsdMicros } from "./money.mjs";
37
39
  import { PROVIDER_STEERING_VARS } from "./provider-steering.mjs";
@@ -189,6 +191,29 @@ const REVIEW_ACTION = "review_submitted";
189
191
  */
190
192
  // EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
191
193
  export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
194
+ /**
195
+ * The tools a job's MCP brings (REQ-MCP-OPERATOR-SERVERS, image/runner/src/mcp.mjs), excludable only by a trigger that
196
+ * names servers: codemode, tool_search and pi's three resource tools. One server's tools are excluded as a whole by
197
+ * `mcpServerToolPattern(name)`; a single tool of a server is not, since its name (pi's sanitized `mcp__<server>__<tool>`)
198
+ * is unknown until the server lists it, and a misspelled one would exclude nothing. That is the overlay's
199
+ * `toolExposure: "hidden"`, the operator's.
200
+ */
201
+ export const MCP_EXCLUDABLE_TOOL_NAMES = new Set(["codemode", "tool_search", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
202
+ /**
203
+ * `excludeTools` without the entries that name the job's MCP tools, or undefined when nothing is left: what a job that
204
+ * connects no MCP server carries (a chained child, which never inherits `run.mcp`). Its runner knows none of those
205
+ * names, and with no MCP there is nothing for them to remove, so dropping them narrows nothing.
206
+ */
207
+ export function withoutMcpExclusions(excludeTools) {
208
+ if (!Array.isArray(excludeTools)) return excludeTools;
209
+ const kept = excludeTools.filter((name) => !MCP_EXCLUDABLE_TOOL_NAMES.has(name) && !String(name).startsWith("mcp__"));
210
+ return kept.length > 0 ? kept : undefined;
211
+ }
212
+
213
+ /** The excludeTools entry that removes every tool of one named server: pi's namespace, `-` written as `_`. */
214
+ export function mcpServerToolPattern(name) {
215
+ return `mcp__${String(name).replace(/-/g, "_")}__*`;
216
+ }
192
217
 
193
218
  // A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
194
219
  // parse; the charset also excludes `:` and the dedicated check names the reason.
@@ -416,6 +441,7 @@ function normalizeCron(on, run, index, path, state) {
416
441
  // A cron job's session narrows like any other: the field selects what the container's agent can do,
417
442
  // which is orthogonal to what triggered it (issue #291).
418
443
  const excludeTools = validateExcludeTools(on, run, `cron trigger "${id}"`, path);
444
+ const mcp = validateMcp(on, run, `cron trigger "${id}"`, path, { kind: "local" });
419
445
  // Validated since #502; before it these three were copied untouched, so `run.provider: 7` loaded.
420
446
  const ref = validateRunModel(on, run, `cron trigger "${id}"`, path);
421
447
  // Issue #505: the portfolio flag. Strictly boolean, the house rule (`run.github`'s reason: a truthy string must not
@@ -430,7 +456,7 @@ function normalizeCron(on, run, index, path, state) {
430
456
  // freeze today's default into every stored repeatable.
431
457
  return {
432
458
  on: { type: "cron", id, pattern },
433
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
459
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
434
460
  };
435
461
  }
436
462
 
@@ -1368,14 +1394,22 @@ function validateExcludeTools(on, run, at, path) {
1368
1394
  if (!Array.isArray(excludeTools) || excludeTools.length === 0) {
1369
1395
  throw configError(`${at}: run.excludeTools must be a non-empty array of tool names when present (got ${JSON.stringify(excludeTools)}) -- an empty exclusion is a field that does nothing, and accepted-and-ignored is how an operator comes to trust one that does: ${path}`);
1370
1396
  }
1397
+ // With run.mcp, the tools the job's MCP brings are known too (its shape is the next validator's to judge; here a
1398
+ // name that is not a valid server name simply adds no pattern).
1399
+ const known = new Set(EXCLUDABLE_TOOL_NAMES);
1400
+ if (Array.isArray(run?.mcp) && run.mcp.length > 0) {
1401
+ for (const name of MCP_EXCLUDABLE_TOOL_NAMES) known.add(name);
1402
+ for (const name of run.mcp) if (typeof name === "string" && MCP_SERVER_NAME_RE.test(name)) known.add(mcpServerToolPattern(name));
1403
+ }
1371
1404
  const seen = new Set();
1372
1405
  for (let i = 0; i < excludeTools.length; i++) {
1373
1406
  const name = excludeTools[i];
1374
1407
  if (!isNonEmptyString(name)) {
1375
1408
  throw configError(`${at}: run.excludeTools[${i}] must be a non-empty string naming a pi tool (got ${JSON.stringify(name)}): ${path}`);
1376
1409
  }
1377
- if (!EXCLUDABLE_TOOL_NAMES.has(name)) {
1378
- throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...EXCLUDABLE_TOOL_NAMES].join(", ")}) -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1410
+ if (!known.has(name)) {
1411
+ const mcpHint = MCP_EXCLUDABLE_TOOL_NAMES.has(name) || name.startsWith("mcp__") ? " (an MCP tool is excludable only by a trigger whose run.mcp names its server, and a server's tools only all at once, as mcp__<server>__*)" : "";
1412
+ throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...known].join(", ")})${mcpHint} -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1379
1413
  }
1380
1414
  if (seen.has(name)) {
1381
1415
  throw configError(`${at}: run.excludeTools names ${JSON.stringify(name)} twice -- the entries are a removal set, so the second can never change the answer: ${path}`);
@@ -1386,6 +1420,84 @@ function validateExcludeTools(on, run, at, path) {
1386
1420
  return [...excludeTools];
1387
1421
  }
1388
1422
 
1423
+ /** The most MCP servers one trigger may name. A bound, not a budget: each one is a connection the job opens at start. */
1424
+ export const MCP_SERVERS_MAX = 8;
1425
+
1426
+ /** The spellings of `run.mcp` a key is refused for, compared after `mcpKeySkeleton`. */
1427
+ const MCP_NEAR_MISSES = Object.freeze(["mcp", "mcps", "mcpserver", "mcpservers"]);
1428
+
1429
+ /** An ASCII key reduced to its letters and digits, lowercased: `MCP_Servers` and `mcp-servers` both read `mcpservers`. */
1430
+ export function mcpKeySkeleton(key) {
1431
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, "");
1432
+ }
1433
+
1434
+ /**
1435
+ * Whether `key` (not the field itself) is refused as a spelling of `run.mcp` rather than dropped: an ASCII key whose
1436
+ * skeleton is one of the field's spellings, or a key that is not plain ASCII and whose plain letters are all letters
1437
+ * of `mcpservers`. A key in another script can be drawn exactly like this one (Greek, Cyrillic, Cherokee, small
1438
+ * capitals, fullwidth forms), which no table of lookalikes keeps up with, but any such key keeps at most the field's
1439
+ * own letters in ASCII. An unrelated non-ASCII key (`fl\u00f6w`) keeps others and still loads, and a lookalike of
1440
+ * another field (`m\u043edels`) is left to that field's sweep.
1441
+ */
1442
+ export function isMcpNearMiss(key) {
1443
+ if (key === "mcp") return false;
1444
+ if (/^[\x20-\x7E]*$/.test(key)) return MCP_NEAR_MISSES.includes(mcpKeySkeleton(key));
1445
+ return [...key.matchAll(/[a-z]/gi)].every(([letter]) => "mcpservers".includes(letter.toLowerCase()));
1446
+ }
1447
+
1448
+ /**
1449
+ * `run.mcp` -- the overlay MCP servers this trigger's jobs connect (REQ-MCP-OPERATOR-SERVERS), by name.
1450
+ *
1451
+ * NAMES ONLY. A server's definition is the operator's, in the overlay's `mcp.json` (INT-OVERLAY-MCP-CONFIG), so a
1452
+ * trigger edit can choose among the servers the deployment offers and never point a credential at a new host. The
1453
+ * loader is fs-free, so whether a name is in the overlay is checked where the file is read: at the job's pickup,
1454
+ * before anything is spent, and by doctor. Absent means none: a job connects no MCP server unless its trigger says
1455
+ * so. The near-miss sweep is excludeTools': a dropped `run.mcp` runs the job without the servers its flow was written
1456
+ * against, while the file reads as though it named them.
1457
+ *
1458
+ * One name is not the overlay's: `forge` (DES-FORGE-MCP) is the job's own forge, through that forge's MCP server and
1459
+ * the job's own token, and the worker writes its entry. Accepted on a github or gitlab trigger only, and refused here
1460
+ * on the rest, since whether it can ever work is a fact about the kind: a cron job holds no forge token of its own, and
1461
+ * forgejo and azure have no forge MCP server this repository has verified.
1462
+ */
1463
+ function validateMcp(on, run, at, path, { kind }) {
1464
+ for (const [label, source, exactIsLegal] of [
1465
+ ["run", run, true],
1466
+ ["on", on, false],
1467
+ ]) {
1468
+ for (const key of Object.keys(source ?? {})) {
1469
+ if (exactIsLegal ? isMcpNearMiss(key) : key === "mcp" || isMcpNearMiss(key)) {
1470
+ if (!/^[\x20-\x7E]*$/.test(key)) {
1471
+ throw configError(`${at}: ${label}.${JSON.stringify(key)} is not a field: a key outside plain ASCII with no plain letter other than run.mcp's can be drawn exactly like run.mcp, which the loader would drop while the file reads as though it named servers. Write field names in plain ASCII: ${path}`);
1472
+ }
1473
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.mcp? A list of MCP servers the loader drops runs the job without them while the file reads as though it named them, so a near miss is refused rather than dropped: ${path}`);
1474
+ }
1475
+ }
1476
+ }
1477
+ const mcp = run?.mcp;
1478
+ if (mcp === undefined) return undefined;
1479
+ if (!Array.isArray(mcp) || mcp.length === 0) {
1480
+ throw configError(`${at}: run.mcp must be a non-empty array of server names from the overlay's mcp.json when present (got ${JSON.stringify(mcp)}); leave it out for none: ${path}`);
1481
+ }
1482
+ if (mcp.length > MCP_SERVERS_MAX) {
1483
+ throw configError(`${at}: run.mcp names ${mcp.length} servers, more than ${MCP_SERVERS_MAX}: ${path}`);
1484
+ }
1485
+ const seen = new Set();
1486
+ for (let i = 0; i < mcp.length; i++) {
1487
+ const name = mcp[i];
1488
+ if (typeof name !== "string" || !MCP_SERVER_NAME_RE.test(name)) {
1489
+ throw configError(`${at}: run.mcp[${i}] must name a server of the overlay's mcp.json: lowercase letters and digits, words joined by single -, up to 32 (got ${JSON.stringify(name)}): ${path}`);
1490
+ }
1491
+ if (seen.has(name)) throw configError(`${at}: run.mcp names ${JSON.stringify(name)} twice: ${path}`);
1492
+ seen.add(name);
1493
+ if (name === FORGE_MCP_SERVER_NAME && !FORGE_MCP_KINDS.includes(kind)) {
1494
+ const why = kind === "local" ? "a cron job holds no forge token of its own for the server to use" : `${kind} has no forge MCP server this deployment has verified yet`;
1495
+ throw configError(`${at}: run.mcp names "forge", the job's own forge server, which a ${FORGE_MCP_KINDS.join(" or ")} trigger may name and this one may not: ${why}: ${path}`);
1496
+ }
1497
+ }
1498
+ return [...mcp];
1499
+ }
1500
+
1389
1501
  /**
1390
1502
  * `run.waitFor` -- the conditions that must all clear before this trigger's job starts (issue #230).
1391
1503
  *
@@ -1544,10 +1656,11 @@ function normalizeLabel(on, run, index, path) {
1544
1656
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1545
1657
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1546
1658
  const excludeTools = validateExcludeTools(on, run, at, path);
1659
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1547
1660
  const ref = validateRunModel(on, run, at, path);
1548
1661
  return {
1549
1662
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
1550
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1663
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1551
1664
  };
1552
1665
  }
1553
1666
 
@@ -1586,10 +1699,11 @@ function normalizeComment(on, run, index, path, state) {
1586
1699
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1587
1700
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1588
1701
  const excludeTools = validateExcludeTools(on, run, at, path);
1702
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1589
1703
  const ref = validateRunModel(on, run, at, path);
1590
1704
  return {
1591
1705
  on: { type: "comment", phrase: on.phrase },
1592
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1706
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1593
1707
  };
1594
1708
  }
1595
1709
 
@@ -1655,6 +1769,7 @@ function normalizeIssue(on, run, index, path) {
1655
1769
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1656
1770
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1657
1771
  const excludeTools = validateExcludeTools(on, run, at, path);
1772
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1658
1773
  const ref = validateRunModel(on, run, at, path);
1659
1774
  return {
1660
1775
  on: {
@@ -1665,7 +1780,7 @@ function normalizeIssue(on, run, index, path) {
1665
1780
  ...(number !== undefined && { number }),
1666
1781
  ...(once !== undefined && { once }),
1667
1782
  },
1668
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1783
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1669
1784
  };
1670
1785
  }
1671
1786
 
@@ -1742,6 +1857,7 @@ function normalizePullRequest(on, run, index, path) {
1742
1857
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1743
1858
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1744
1859
  const excludeTools = validateExcludeTools(on, run, at, path);
1860
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1745
1861
  const ref = validateRunModel(on, run, at, path);
1746
1862
  return {
1747
1863
  on: {
@@ -1757,7 +1873,7 @@ function normalizePullRequest(on, run, index, path) {
1757
1873
  ...(number !== undefined && { number }),
1758
1874
  ...(once !== undefined && { once }),
1759
1875
  },
1760
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1876
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1761
1877
  };
1762
1878
  }
1763
1879
 
package/src/wait-for.mjs CHANGED
@@ -40,6 +40,13 @@ function configError(message) {
40
40
  */
41
41
  export const WAIT_CONDITION_KEYS = ["after", "profile"];
42
42
 
43
+ /**
44
+ * Every reason the wait gate refuses a job with (index.mjs `refuseWait`), each before the job holds a slot. A test
45
+ * holds this list equal to the processor's calls; the capacity report reads it to tell such a refusal from a run on
46
+ * a record from before `capacity` was recorded (issue #599).
47
+ */
48
+ export const WAIT_REFUSAL_REASONS = Object.freeze(["wait-superseded", "wait-unreadable", "wait-profile-unknown", "wait-after-beyond-max", "wait-expired", "wait-refused", "wait-unanswerable"]);
49
+
43
50
  /**
44
51
  * The ceiling on conditions per trigger. Four, on `SECRETS_MAX`'s reasoning rather than `REPLICAS_MAX`':
45
52
  * this multiplies no spend, but every `profile` condition is a subprocess evaluated before the container,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The worker name rule (issue #57), in a module with NO imports, so a pure reader (the capacity report, issue #599) can
3
+ * hold a record's `host` to it without loading config.mjs and its fs and os. `config.mjs` re-exports the name.
4
+ */
5
+
6
+ /**
7
+ * What a worker may call itself (issue #57). The CHARACTER CLASS is `sanitizeJobId`'s
8
+ * (`[A-Za-z0-9._-]`), reused rather than invented so this project has one name-safe alphabet -- but that
9
+ * function is a REPLACER, not a validator, so the three rules around the class are NEW and are claimed
10
+ * as new here rather than borrowed:
11
+ *
12
+ * - a leading alphanumeric, which is what refuses `..` and a leading `-` that reads as a flag;
13
+ * - a 64-character ceiling, because the name is a Valkey key segment and a log field on every line;
14
+ * - no `.json`/`.log` tail, which is not decoration. The class contains the dot, so `prod.json` is
15
+ * otherwise a legal name -- and a later slice writes a per-host marker file into `PI_LOGS_DIR`,
16
+ * where `<something>.json` is parsed as a run record by the admin and DELETED by the log reaper.
17
+ * A name is refused here rather than escaped there, because the escape would have to be remembered
18
+ * at every site that ever composes a filename from this value.
19
+ *
20
+ * The class is `:`-free, `,`-free and `#`-free, which is what lets the name be a Valkey key segment
21
+ * UNHASHED. That is the point of validating instead of hashing (`scopeKeyPrefix` does the opposite for
22
+ * a folder path, which was never chosen for key-safety and cannot be refused): the whole value of a host
23
+ * registry is that `HGETALL host:h:mac-mini-1` is readable by a human.
24
+ */
25
+ export const WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;