@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/.env.example +2 -1
- package/package.json +6 -1
- package/src/capacity-cli.mjs +375 -0
- package/src/capacity-records.mjs +209 -0
- package/src/capacity.mjs +654 -0
- package/src/cli.mjs +9 -0
- package/src/config.mjs +4 -20
- package/src/connection.mjs +4 -2
- package/src/doctor.mjs +399 -49
- package/src/egress-allowlist.mjs +81 -0
- package/src/env-allowlist.mjs +5 -1
- package/src/host-budget.mjs +6 -0
- package/src/host-registry.mjs +20 -2
- package/src/image-preflight.mjs +25 -1
- package/src/import-pi.mjs +113 -8
- package/src/index.mjs +97 -6
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +135 -0
- package/src/mcp-config.mjs +466 -0
- package/src/mcp-preflight.mjs +262 -0
- package/src/model-endpoints.mjs +8 -3
- package/src/outbox.mjs +7 -1
- package/src/prepare.mjs +13 -13
- package/src/processor.mjs +36 -2
- package/src/queue.mjs +4 -2
- package/src/repeat-slot.mjs +13 -0
- package/src/reserved-env.mjs +1 -0
- package/src/run-container.mjs +2 -0
- package/src/run-earlier.mjs +29 -0
- package/src/run-history.mjs +123 -6
- package/src/run-mirror.mjs +161 -12
- package/src/schedules.mjs +1 -1
- package/src/size-suggest.mjs +34 -14
- package/src/start.mjs +79 -5
- package/src/triggers-file.mjs +3 -0
- package/src/triggers.mjs +123 -7
- package/src/wait-for.mjs +7 -0
- package/src/worker-name.mjs +25 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The egress allowlist as the proxy reads it, and whether a URL a job dials gets through (docs/egress.md). Squid
|
|
3
|
+
* matches `egress-allowlist.conf` as a `dstdomain` file (deploy/egress-proxy.conf, `acl allowed dstdomain -n`): each
|
|
4
|
+
* line, trimmed and with a `#` comment removed, is ONE entry, and an entry with a leading dot admits that domain and
|
|
5
|
+
* every name under it. A line with two words is one entry that matches nothing (measured on the pinned squid: both
|
|
6
|
+
* names refused), so it admits nothing here either. A job's runner sends every request as a CONNECT
|
|
7
|
+
* (image/runner/src/env-proxy.mjs, `proxyTunnel: true`), and the proxy allows a CONNECT to an allowlisted name on port
|
|
8
|
+
* 443 only, never to a host that resolves to this machine (`to_host_local`), and to a declared model endpoint on
|
|
9
|
+
* exactly its host and port (`model-endpoints.conf`).
|
|
10
|
+
*
|
|
11
|
+
* The proxy stays the enforcement. This is the same reading done before anything is spent, so a job whose server the
|
|
12
|
+
* policy cannot serve is refused with a reason instead of failing at its first call. It is a necessary condition, not
|
|
13
|
+
* a sufficient one: a NAME that resolves to this machine is refused by the proxy and not here, since this reads no DNS.
|
|
14
|
+
*/
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
import { basename } from "node:path";
|
|
17
|
+
import { isProxyLocalHost } from "./backends.mjs";
|
|
18
|
+
import { baseUrlTarget, endpointsAt } from "./model-endpoints.mjs";
|
|
19
|
+
|
|
20
|
+
/** The entries of an allowlist's text, lowercased, in order: one per line, a line with inner whitespace skipped. */
|
|
21
|
+
export function parseAllowlist(text) {
|
|
22
|
+
const entries = [];
|
|
23
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
24
|
+
// A comment starts at a `#` that opens the line or follows whitespace; one inside a word is part of the word,
|
|
25
|
+
// which then matches no host.
|
|
26
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
27
|
+
if (line === "" || /\s/.test(line)) continue;
|
|
28
|
+
entries.push(line.toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first entry that admits `host`, or null: the name itself, or a leading-dot entry for it or a name above it. */
|
|
34
|
+
export function allowlistEntryFor(entries, host) {
|
|
35
|
+
const name = String(host).toLowerCase();
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.startsWith(".") ? name === entry.slice(1) || name.endsWith(entry) : name === entry) return entry;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `host` (as `baseUrlTarget` gives it) is an address squid's `to_host_local` refuses, or `localhost`. */
|
|
43
|
+
function hostIsLocal(host) {
|
|
44
|
+
const bare = host.startsWith("[") ? host.slice(1, -1) : host;
|
|
45
|
+
const kind = isIP(bare) === 4 ? "ipv4" : isIP(bare) === 6 ? "ipv6" : "name";
|
|
46
|
+
if (isProxyLocalHost(kind, bare)) return true;
|
|
47
|
+
if (kind === "ipv4") return bare.startsWith("169.254.");
|
|
48
|
+
if (kind === "ipv6") return /^fe[89ab][0-9a-f]:/i.test(bare);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why a job's CONNECT to `url` would be refused by the proxy, or null when it gets through: a declared endpoint's
|
|
54
|
+
* exact host and port, or an allowlisted name on port 443, over https, that is not this machine. Both schemes go as
|
|
55
|
+
* a CONNECT, so the proxy itself does not tell them apart.
|
|
56
|
+
*/
|
|
57
|
+
export function egressRefusalFor(url, { allowlist = [], endpoints = [] } = {}) {
|
|
58
|
+
const target = baseUrlTarget(url);
|
|
59
|
+
if (target === null) return "it is not an http or https URL";
|
|
60
|
+
if (endpointsAt(url, endpoints).length > 0) return null;
|
|
61
|
+
const entry = allowlistEntryFor(allowlist, target.host);
|
|
62
|
+
if (entry !== null && hostIsLocal(target.host)) return `${target.host} is the proxy's own machine, which it never opens; a server on this machine is declared in model-endpoints.json under host.docker.internal or host.containers.internal`;
|
|
63
|
+
// The proxy tunnels to 443 whatever speaks inside, so an `http://` url on 443 is plain HTTP to what is almost always
|
|
64
|
+
// a TLS port, carrying the server's headers in clear inside the tunnel: refused, with the fix.
|
|
65
|
+
if (entry !== null && target.port === 443 && new URL(url).protocol !== "https:") return `the egress proxy opens ${target.host} on port 443 for TLS, and this url is http://; use https://`;
|
|
66
|
+
if (entry !== null && target.port === 443) return null;
|
|
67
|
+
if (entry !== null) return `the egress proxy opens ${target.host} on port 443 only, and this is port ${target.port}; declare ${target.host}:${target.port} in model-endpoints.json to open exactly that port`;
|
|
68
|
+
return `${target.host} is not in egress-allowlist.conf${target.port === 443 ? "" : ", and port " + target.port + " opens only as a declared endpoint in model-endpoints.json"}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every host a package runner fetches from at start, by name, since the proxy matches names: uv and pipx take the
|
|
73
|
+
* index from pypi.org and the files from files.pythonhosted.org, yarn's default registry is its own name, and the npm
|
|
74
|
+
* family reads registry.npmjs.org.
|
|
75
|
+
*/
|
|
76
|
+
export function packageRunnerHosts(command) {
|
|
77
|
+
const runner = basename(String(command));
|
|
78
|
+
if (["uv", "uvx", "pipx"].includes(runner)) return ["pypi.org", "files.pythonhosted.org"];
|
|
79
|
+
if (runner === "yarn") return ["registry.yarnpkg.com"];
|
|
80
|
+
return ["registry.npmjs.org"];
|
|
81
|
+
}
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -367,7 +367,7 @@ function resolveEnvName(provider) {
|
|
|
367
367
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
368
368
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
369
369
|
*/
|
|
370
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
370
|
+
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], mcpServers = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
371
371
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
372
372
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
373
373
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
@@ -429,6 +429,10 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCos
|
|
|
429
429
|
// second-validator rule directly above). Absent means the full pinned default set, never an empty
|
|
430
430
|
// string, for PI_PACKAGES' reason.
|
|
431
431
|
PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
|
|
432
|
+
// The trigger's run.mcp (REQ-MCP-OPERATOR-SERVERS): the overlay servers the job's session connects, so the
|
|
433
|
+
// runner can hold `/job/mcp.json` to the names the worker meant. Comma-safe for excludeTools' reason (the loader
|
|
434
|
+
// admits only lowercase letters, digits and `-`). Absent means none, never an empty string.
|
|
435
|
+
PI_MCP_SERVERS: mcpServers.length > 0 ? mcpServers.join(",") : undefined,
|
|
432
436
|
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
433
437
|
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
434
438
|
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
package/src/host-budget.mjs
CHANGED
|
@@ -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),
|
package/src/host-registry.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/image-preflight.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { imageRefProblem } from "./image-ref.mjs";
|
|
3
|
+
import { FORGE_MCP_SERVER_NAME } from "./mcp-config.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Which image a job runs in, and whether it is on this host.
|
|
@@ -44,7 +45,9 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
44
45
|
* { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
|
|
45
46
|
* { <gate.result>, declared } -- present, but does not declare a capability this job needs, one key per
|
|
46
47
|
* CAPABILITY_GATES row (replicaUnsupported, commandUnsupported,
|
|
47
|
-
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported
|
|
48
|
+
* excludeToolsUnsupported, modelPolicyUnsupported, costCapUnsupported, mcpUnsupported,
|
|
49
|
+
* forgeMcpUnsupported)
|
|
50
|
+
* => POLICY
|
|
48
51
|
*
|
|
49
52
|
* A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
|
|
50
53
|
* so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
|
|
@@ -191,6 +194,27 @@ export const CAPABILITY_GATES = Object.freeze([
|
|
|
191
194
|
event: "refused_image_cost_cap_unsupported",
|
|
192
195
|
comment: (image, declared) => `Refused: the job image "${image}" does not declare cost-cap support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would ignore the per-job dollar cap and run this job with no cap at all. Rebuild the image from a version that has this feature. Not run.`,
|
|
193
196
|
}),
|
|
197
|
+
// REQ-MCP-OPERATOR-SERVERS: a runner that predates run.mcp reads no /job/mcp.json, so the job would run without
|
|
198
|
+
// the servers its flow was written against and record a clean exit while the file reads as though it named them.
|
|
199
|
+
Object.freeze({
|
|
200
|
+
token: "mcp",
|
|
201
|
+
needed: (job) => Array.isArray(job?.mcp) && job.mcp.length > 0,
|
|
202
|
+
result: "mcpUnsupported",
|
|
203
|
+
reason: "job-image-mcp-unsupported",
|
|
204
|
+
event: "refused_image_mcp_unsupported",
|
|
205
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare MCP support (\`dev.pi-dispatch.capabilities\` ${declared}), so its runner would not connect the servers \`run.mcp\` names. Rebuild the image from a version that has this feature. Not run.`,
|
|
206
|
+
}),
|
|
207
|
+
// DES-FORGE-MCP: `forge` in run.mcp starts a server the IMAGE carries (github-mcp-server, mcp-gitlab), not one the
|
|
208
|
+
// overlay names. An image with `mcp` and without them would pass every gate, reserve the budget, and refuse at the
|
|
209
|
+
// container's start as mcp-command-missing; this row says so before anything is spent.
|
|
210
|
+
Object.freeze({
|
|
211
|
+
token: "forgeMcp",
|
|
212
|
+
needed: (job) => Array.isArray(job?.mcp) && job.mcp.includes(FORGE_MCP_SERVER_NAME),
|
|
213
|
+
result: "forgeMcpUnsupported",
|
|
214
|
+
reason: "job-image-forge-mcp-unsupported",
|
|
215
|
+
event: "refused_image_forge_mcp_unsupported",
|
|
216
|
+
comment: (image, declared) => `Refused: the job image "${image}" does not declare the forge's own MCP server (\`dev.pi-dispatch.capabilities\` ${declared}), so it does not carry the server \`run.mcp\` names as \`forge\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
217
|
+
}),
|
|
194
218
|
]);
|
|
195
219
|
|
|
196
220
|
/**
|
package/src/import-pi.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* `auth.json` / env and reaches the container through the env allowlist, never a mounted file. So this
|
|
8
8
|
* command copies only the safe subset and REFUSES a `models.json` that embeds a literal key.
|
|
9
9
|
*
|
|
10
|
-
* Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md,
|
|
10
|
+
* Copied: models.json (definitions only, sanitized), skills/, APPEND_SYSTEM.md, mcp.json (rewritten to the
|
|
11
|
+
* servers a job may connect, references only; `--no-mcp` leaves it out), and extensions/ (verbatim;
|
|
11
12
|
* the admin extension is hard-blocked, and one the operator disabled in `pi config` is left behind).
|
|
12
13
|
* Extensions come along BY DEFAULT -- staging is the vetting step, and an overlay missing the
|
|
13
14
|
* operator's own extensions is not the setup they asked for -- with `--no-extensions` as the escape
|
|
@@ -19,17 +20,19 @@
|
|
|
19
20
|
* setup and staged at the exact version their host runs (issue #102); `pi-packages.json` becomes
|
|
20
21
|
* the override-and-addition layer rather than the only road in, and `--no-host-packages` restores
|
|
21
22
|
* the declared-only behaviour exactly.
|
|
22
|
-
* Never: auth.json, settings.json, sessions/, themes/, prompts/, tools
|
|
23
|
-
* learn what pi has installed and what the operator turned off;
|
|
24
|
-
* of that file reaches the overlay.
|
|
23
|
+
* Never: auth.json, settings.json, sessions/, themes/, prompts/, tools/, mcp-auth.json (pi's MCP sign-ins).
|
|
24
|
+
* Discovery READS settings.json to learn what pi has installed and what the operator turned off;
|
|
25
|
+
* reading is not copying, and no part of that file reaches the overlay.
|
|
25
26
|
*/
|
|
26
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync } from "node:fs";
|
|
27
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync, unlinkSync } from "node:fs";
|
|
27
28
|
import { execFile } from "node:child_process";
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
28
30
|
import { join, resolve } from "node:path";
|
|
29
31
|
import { promisify } from "node:util";
|
|
30
32
|
import { FROM_HOST, PACKAGES_SUBDIR, RESOURCE_DIRS, STAGE_MANIFEST, mergeHostPackages, parsePackagesFile } from "./packages.mjs";
|
|
31
33
|
import { agentDirFrom, readHostPi } from "./host-pi.mjs";
|
|
32
34
|
import { ENTRY_NAME_RE, copyDirContents, copySkillTree } from "./copy-tree.mjs";
|
|
35
|
+
import { MCP_CONFIG_FILE_NAME, MCP_CONFIG_MAX_BYTES, mcpServerWhere, overlayMcpText, parseOverlayMcp } from "./mcp-config.mjs";
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* A valid skill/extension entry name. Defined in copy-tree.mjs, which is the module that enforces it,
|
|
@@ -46,7 +49,7 @@ export const ADMIN_RE = /pi-dispatch|dispatch-admin/i;
|
|
|
46
49
|
* silently means "third-party code you did not expect runs in every job container". A typo must not be able
|
|
47
50
|
* to widen what loads.
|
|
48
51
|
*/
|
|
49
|
-
const BOOL_FLAGS = new Set(["--no-extensions", "--with-extensions", "--with-packages", "--host-packages", "--no-host-packages"]);
|
|
52
|
+
const BOOL_FLAGS = new Set(["--no-extensions", "--with-extensions", "--with-packages", "--host-packages", "--no-host-packages", "--no-mcp"]);
|
|
50
53
|
const VALUE_FLAGS = new Set(["--from", "--to", "--packages-file"]);
|
|
51
54
|
|
|
52
55
|
const execFileAsync = promisify(execFile);
|
|
@@ -98,7 +101,7 @@ export async function runImportPi(argv = [], deps = {}) {
|
|
|
98
101
|
out = (s) => process.stdout.write(s),
|
|
99
102
|
// lstatSync rides along for the shared copier, whose symlink guard is the whole point of it: the
|
|
100
103
|
// walker this dir's copies used to go through guarded with statSync, which FOLLOWS links.
|
|
101
|
-
fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync },
|
|
104
|
+
fs = { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, lstatSync, statSync, copyFileSync, renameSync, rmSync, unlinkSync },
|
|
102
105
|
exec = defaultExec,
|
|
103
106
|
// Injected like `fs`/`exec`/`out` so the win32 npm branch below is reachable from a test on any host;
|
|
104
107
|
// it is the branch that was dead on arrival precisely because nothing could exercise it here.
|
|
@@ -181,6 +184,13 @@ export async function runImportPi(argv = [], deps = {}) {
|
|
|
181
184
|
results.push(["APPEND_SYSTEM.md", "global persona (layers under each repo's persona)"]);
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
// mcp.json -- the MCP servers jobs may connect (REQ-MCP-OPERATOR-SERVERS). REWRITTEN, never copied: only the servers
|
|
188
|
+
// a job may connect are written, each in pi's shape with every header and env value a reference, so a host entry
|
|
189
|
+
// with a literal value, an OAuth sign-in or a key this deployment does not take never reaches the overlay. Each one
|
|
190
|
+
// left behind is named with why. The overlay's file follows the host's (`stageMcp`), since a job would otherwise
|
|
191
|
+
// keep connecting servers the operator no longer offers.
|
|
192
|
+
results.push(...stageMcp({ fs, from, to, skip: argv.includes("--no-mcp") }));
|
|
193
|
+
|
|
184
194
|
// extensions/ -- the sharp edge, and copied BY DEFAULT: the operator staged this overlay to be their own
|
|
185
195
|
// setup, and one they have to arm twice more is not that. The admin extension stays hard-blocked (it can
|
|
186
196
|
// enqueue paid jobs from inside a container: a recursion vector, not part of this relaxation).
|
|
@@ -268,11 +278,106 @@ export async function runImportPi(argv = [], deps = {}) {
|
|
|
268
278
|
// A note-less row is a list item under the row above it (the extension names), so it prints bare rather
|
|
269
279
|
// than padded out to a column that has nothing to hold.
|
|
270
280
|
for (const [name, note] of results) out(note ? ` ${name.padEnd(18)} ${note}\n` : ` ${name}\n`);
|
|
271
|
-
out(`\n (auth.json, settings.json, sessions/ are never copied
|
|
281
|
+
out(`\n (auth.json, settings.json, sessions/ and mcp-auth.json are never copied: your credential stays in env/auth.json.)\n`);
|
|
272
282
|
out(nextSteps(to, withExtensions, withPackages, withHostPackages && hostPi.packages.length > 0));
|
|
273
283
|
return 0;
|
|
274
284
|
}
|
|
275
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Stage the host's `mcp.json` into the overlay, rewritten to the servers a job may connect. Returns the result rows.
|
|
288
|
+
* The overlay's file follows the host's: it is replaced by each import, and removed when the host has none, when
|
|
289
|
+
* `--no-mcp` is passed, or when the host offers no server a job may connect, so a hand-written overlay file is
|
|
290
|
+
* replaced too (docs/mcp.md says so). One exception keeps what is there: a host file that cannot be read whole (not
|
|
291
|
+
* JSON, a duplicate key, too large) changes nothing, since the operator is mid-edit and the last good list stands.
|
|
292
|
+
* The overlay's file is never followed if it is a link: a new file is renamed over it, so a link back to the host's
|
|
293
|
+
* own file cannot make this command rewrite it.
|
|
294
|
+
*/
|
|
295
|
+
function stageMcp({ fs, from, to, skip }) {
|
|
296
|
+
const src = join(from, MCP_CONFIG_FILE_NAME);
|
|
297
|
+
const dst = join(to, MCP_CONFIG_FILE_NAME);
|
|
298
|
+
const rows = [];
|
|
299
|
+
const existing = () => {
|
|
300
|
+
try {
|
|
301
|
+
return fs.lstatSync(dst);
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const removeOld = (why) => {
|
|
307
|
+
const entry = existing();
|
|
308
|
+
if (entry === null) return true;
|
|
309
|
+
if (entry.isDirectory()) {
|
|
310
|
+
rows.push([`${MCP_CONFIG_FILE_NAME} (overlay)`, `WARN: ${dst} is a folder; remove it by hand`]);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
// unlink, not rmSync: `force` skips a dangling link, which the write would then follow.
|
|
314
|
+
fs.unlinkSync(dst);
|
|
315
|
+
rows.push([`${MCP_CONFIG_FILE_NAME} (overlay)`, `removed (${why})`]);
|
|
316
|
+
return true;
|
|
317
|
+
};
|
|
318
|
+
if (skip) {
|
|
319
|
+
if (fs.existsSync(src)) rows.push([MCP_CONFIG_FILE_NAME, "skipped -- --no-mcp was passed (jobs are offered no MCP server)"]);
|
|
320
|
+
removeOld("--no-mcp");
|
|
321
|
+
return rows;
|
|
322
|
+
}
|
|
323
|
+
if (!fs.existsSync(src)) {
|
|
324
|
+
removeOld(`no ${MCP_CONFIG_FILE_NAME} in your pi setup`);
|
|
325
|
+
return rows;
|
|
326
|
+
}
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
// statSync follows a link, so a link to a regular file is read and one to a pipe or device is not: a FIFO would
|
|
330
|
+
// block this read forever, and /dev/zero would never end.
|
|
331
|
+
const stat = fs.statSync(src);
|
|
332
|
+
if (!stat.isFile()) throw Object.assign(new Error(`${src}: is not a regular file`), { mcpConfig: true });
|
|
333
|
+
if (stat.size > MCP_CONFIG_MAX_BYTES) throw Object.assign(new Error(`${src}: is larger than ${MCP_CONFIG_MAX_BYTES} bytes`), { mcpConfig: true });
|
|
334
|
+
parsed = parseOverlayMcp(String(fs.readFileSync(src, "utf8")), src);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
const kept = existing() === null ? "" : "; the overlay's file is kept as it was";
|
|
337
|
+
rows.push([MCP_CONFIG_FILE_NAME, `SKIPPED -- ${error?.mcpConfig ? error.message : `${src}: cannot be read (${error?.code ?? "unreadable"})`}${kept}`]);
|
|
338
|
+
return rows;
|
|
339
|
+
}
|
|
340
|
+
const names = Object.keys(parsed.servers);
|
|
341
|
+
if (names.length > 0) {
|
|
342
|
+
// Replaced, never written through: a link or an earlier file goes first, and a folder stops the write.
|
|
343
|
+
const entry = existing();
|
|
344
|
+
if (entry?.isDirectory()) {
|
|
345
|
+
rows.push([MCP_CONFIG_FILE_NAME, `WARN: ${dst} is a folder; remove it by hand, then re-run`]);
|
|
346
|
+
} else {
|
|
347
|
+
// A new file renamed over the old: the rename replaces a link rather than its target, and a write that fails
|
|
348
|
+
// leaves the last good list in place.
|
|
349
|
+
// A name no earlier run left, opened only if it does not exist (`wx`), so it is never a link to follow; removed
|
|
350
|
+
// again if the write or the rename fails, since every job mounts this folder.
|
|
351
|
+
const tmp = `${dst}.${randomBytes(6).toString("hex")}.tmp`;
|
|
352
|
+
try {
|
|
353
|
+
fs.writeFileSync(tmp, overlayMcpText(parsed), { flag: "wx", mode: 0o644 });
|
|
354
|
+
fs.renameSync(tmp, dst);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
try {
|
|
357
|
+
fs.unlinkSync(tmp);
|
|
358
|
+
} catch {
|
|
359
|
+
// Never written.
|
|
360
|
+
}
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
rows.push([MCP_CONFIG_FILE_NAME, `${names.length} MCP server${names.length === 1 ? "" : "s"} offered to jobs`]);
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
const server = parsed.servers[name];
|
|
366
|
+
const runner = server.packageRunner ? ", fetches its server from a registry at start" : "";
|
|
367
|
+
rows.push([` - ${name} (${server.transport}, ${mcpServerWhere(server)}${runner})`, ""]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
rows.push([MCP_CONFIG_FILE_NAME, "no MCP server a job may connect"]);
|
|
372
|
+
removeOld("nothing to offer");
|
|
373
|
+
}
|
|
374
|
+
if (parsed.refused.length > 0) {
|
|
375
|
+
rows.push([`${MCP_CONFIG_FILE_NAME} (left)`, `${parsed.refused.length} server${parsed.refused.length === 1 ? "" : "s"} not staged`]);
|
|
376
|
+
for (const { name, problem } of parsed.refused) rows.push([` - ${JSON.stringify(name)}: ${problem}`, ""]);
|
|
377
|
+
}
|
|
378
|
+
return rows;
|
|
379
|
+
}
|
|
380
|
+
|
|
276
381
|
/**
|
|
277
382
|
* Copy `<src>/<name>/**` for each valid, non-symlink child dir. Returns the count copied.
|
|
278
383
|
*
|
package/src/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { availableParallelism } from "node:os";
|
|
1
2
|
import { DelayedError, UnrecoverableError, Worker } from "bullmq";
|
|
2
3
|
import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
|
|
3
4
|
import { jobContainerName } from "./backend-local.mjs";
|
|
@@ -7,7 +8,7 @@ import { CANCEL_ACK_TTL_MS, cancelAckKey, cancelReqKey } from "./cancel-state.mj
|
|
|
7
8
|
import { InfraRetry, NETNS_KEEPER_CRASH_LOOP, NETNS_KEEPER_NOT_HOLDING, TERMINAL_COMMENTS, runJob } from "./processor.mjs";
|
|
8
9
|
import { NETNS_KEEPER_YOUNG_HOLD_MAX_MS, netnsKeeperCrashLoopSentence, netnsKeeperLoopAgainSentence } from "./netns-keeper.mjs";
|
|
9
10
|
import { PODMAN_RESTART_HOLD_EXPIRED, PODMAN_RESTART_HOLD_MAX_MS, PODMAN_RESTART_HOLD_RECHECK_MS } from "./runtime-observations.mjs";
|
|
10
|
-
import { targetFor } from "./run-history.mjs";
|
|
11
|
+
import { earlierFrom, targetFor } from "./run-history.mjs";
|
|
11
12
|
import { isPerMachineHost } from "./backends.mjs";
|
|
12
13
|
import { hash16 } from "./fleet-lease.mjs";
|
|
13
14
|
import { endpointsForModel } from "./model-endpoints.mjs";
|
|
@@ -190,7 +191,43 @@ export function effectiveJobOf(data, settings, allowedModels = null, log = () =>
|
|
|
190
191
|
};
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
|
|
194
|
+
/**
|
|
195
|
+
* The jobs this host runs right now (issue #599, phase 2): one entry per PICKUP that holds a slot, `{ id, project, memMiB,
|
|
196
|
+
* cpuCenti, at }`, `at` its admission instant in millis (the record's `startedAt`). The registry beat publishes it
|
|
197
|
+
* (`jobs`, live-jobs.mjs), and the capacity report counts each as busy until its record exists.
|
|
198
|
+
*
|
|
199
|
+
* It works WITHOUT a host budget, which is why it is not the budget's ledger: the ledger exists only when a budget is
|
|
200
|
+
* set. Keyed by a per-pickup number, not the job id, so a second pickup of the same id (a stalled job handed back while
|
|
201
|
+
* the first still runs here) can never remove the first's entry. Process memory, `makeInFlight`'s reason: it counts
|
|
202
|
+
* this process's own pickups, and a restart that loses it has no pickup left to count.
|
|
203
|
+
*
|
|
204
|
+
* An entry is added where the job is admitted (`startedAt`, `capacity`) and removed only by `releaseAllHolds`, the one
|
|
205
|
+
* release every exit goes through, so it cannot outlive its pickup. An ORPHAN (the container's stop did not take) is
|
|
206
|
+
* removed like any other exit: the processor is done with it, and only the host budget keeps watching its container,
|
|
207
|
+
* so the beat flags orphans from the budget's ledger alone. Without a budget nothing watches such a container, and the
|
|
208
|
+
* list says only what this process still runs.
|
|
209
|
+
*/
|
|
210
|
+
export function makeRunningJobs() {
|
|
211
|
+
const entries = new Map();
|
|
212
|
+
let next = 0;
|
|
213
|
+
return {
|
|
214
|
+
/** Adds one pickup's entry; returns its key. Total: it never throws, so the admission block may call it. */
|
|
215
|
+
add(entry) {
|
|
216
|
+
next += 1;
|
|
217
|
+
entries.set(next, { ...entry });
|
|
218
|
+
return next;
|
|
219
|
+
},
|
|
220
|
+
/** Removes one pickup's entry; true only the first time. */
|
|
221
|
+
remove(key) {
|
|
222
|
+
return entries.delete(key);
|
|
223
|
+
},
|
|
224
|
+
/** Copies, never the live map. */
|
|
225
|
+
list: () => [...entries.values()].map((e) => ({ ...e })),
|
|
226
|
+
size: () => entries.size,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function makeProcessor({ cancelJob, stopContainer, containerName = (job) => jobContainerName(job.id), redis, getSettings, hostBudget = null, abortGraceMs = ABORT_GRACE_MS, applyConcurrency = () => {}, pauseUntil = () => null, scopedLimits = () => [], projects = () => [], jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), hostBound = null, checkLease = null, scopeLease = null, endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels = () => null, endpointSetFor = mainModelEndpoints, deps, recordRun = () => {}, settledRecord = null, previousRecord = null, timeoutMs = JOB_TIMEOUT_MS, cancelPollMs = 2_000, cancelStopBoundMs = CANCEL_STOP_BOUND_MS, hostName = "", multiHost = false, now = () => Date.now(), waitState = makeWaitState({ redis, now }), afterMaxMs = () => WAIT_AFTER_MAX_DEFAULT_MS, checkSlots = makeInFlight(), checkSlotCount = () => 1, checkTimeoutMs = () => 10_000, concurrencyNow = () => 3, intervalMs = () => WAIT_INTERVAL_FLOOR_MS * 2, maxWaitMs = () => 24 * 3600 * 1000, maxChecks = () => 96, maxFaults = () => 5, random = Math.random, cpus = availableParallelism, runningJobs = makeRunningJobs() }) {
|
|
194
231
|
// The lost-lock gate's rejection lines, said ONCE per job id, stall count and attempt. The gate runs before every
|
|
195
232
|
// deferral (pause window, wait, scope or endpoint busy), and BullMQ never resets a job's stall count, so a stalled
|
|
196
233
|
// scheduled job whose record is refused meets the gate again on every deferred pickup: a 30-minute scope-busy hold
|
|
@@ -233,6 +270,19 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
233
270
|
}
|
|
234
271
|
}
|
|
235
272
|
|
|
273
|
+
// THE EARLIER ATTEMPTS' SLOT TIME (issue #599): a retry, or a pickup after a stall, writes its record over the one
|
|
274
|
+
// before it (one file and one mirror key per job id), so that record is read NOW, before anything here can replace
|
|
275
|
+
// it, and its slot interval (with the ones it carried) rides every record this pickup writes (`earlier`). Only on
|
|
276
|
+
// such a pickup, so a first attempt reads nothing; bounded by the reader, and a fault reads as none.
|
|
277
|
+
let earlier = null;
|
|
278
|
+
if (typeof previousRecord === "function" && ((Number.isInteger(job.attemptsMade) && job.attemptsMade > 0) || Number(job.stalledCounter) > 0)) {
|
|
279
|
+
try {
|
|
280
|
+
earlier = earlierFrom(await previousRecord(job.id));
|
|
281
|
+
} catch {
|
|
282
|
+
earlier = null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
236
286
|
// Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): if this job's folder/repo is inside an active pause
|
|
237
287
|
// window, DEFER it to the window end via BullMQ's delayed set -- the job keeps its identity/dedup and
|
|
238
288
|
// auto-resumes when re-picked. This is FIRST, before the kill timer, the settings read, and the budget
|
|
@@ -302,7 +352,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
302
352
|
const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false, hostBudget: { memMiB: budgetNow.memMiB, cpuCenti: budgetNow.cpuCenti, hostShare: share } };
|
|
303
353
|
// Above the wait gate, so through the recorder's own arguments rather than the bound one below it: the
|
|
304
354
|
// same pickup project and size, by hand once.
|
|
305
|
-
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), project, size });
|
|
355
|
+
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), project, size, earlier });
|
|
306
356
|
return result;
|
|
307
357
|
}
|
|
308
358
|
deps?.log?.("job_size_never_fits_here_deferred", { jobId: job.id, project, misfit, delayMs: NEVER_FITS_RECHECK_MS, ...sizeFields });
|
|
@@ -344,7 +394,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
344
394
|
// resolver path or a vault topology -- `secret-profile-unknown` sets that rule.
|
|
345
395
|
if (sentence && deps?.comment) await Promise.resolve(deps.comment(job.data, sentence)).catch(() => {});
|
|
346
396
|
const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
347
|
-
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString() });
|
|
397
|
+
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), earlier });
|
|
348
398
|
return result;
|
|
349
399
|
};
|
|
350
400
|
|
|
@@ -673,6 +723,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
673
723
|
// guard) still frees every local slot before it rethrows; the fleet halves are release-if-mine and awaited where
|
|
674
724
|
// the caller can. Draining, so a second call releases nothing: the in-process map's release is not idempotent.
|
|
675
725
|
let budgetHeld = false;
|
|
726
|
+
// This pickup's entry in the running jobs (issue #599, phase 2), null until it is admitted below.
|
|
727
|
+
let runningKey = null;
|
|
676
728
|
let stopDidNotTake = false;
|
|
677
729
|
let name;
|
|
678
730
|
let venue;
|
|
@@ -692,6 +744,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
692
744
|
return Promise.all(taken.map((hold) => hold.fleet?.release?.()));
|
|
693
745
|
};
|
|
694
746
|
const releaseAllHolds = ({ orphan = false } = {}) => {
|
|
747
|
+
// The running-jobs entry first (issue #599, phase 2): this pickup no longer runs the job, orphan or not.
|
|
748
|
+
if (runningKey !== null) {
|
|
749
|
+
runningJobs.remove(runningKey);
|
|
750
|
+
runningKey = null;
|
|
751
|
+
}
|
|
695
752
|
const endpointsReleased = releaseEndpointHolds();
|
|
696
753
|
const scopesReleased = releaseScopeHolds();
|
|
697
754
|
if (hostHeld) {
|
|
@@ -718,11 +775,31 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
718
775
|
hostHeld = true;
|
|
719
776
|
}
|
|
720
777
|
|
|
778
|
+
// The host's capacity as the record carries it (issue #599). Read when the job is admitted, not here: the slot count,
|
|
779
|
+
// the budget and the runtime's CPU count are live, and the value a run is judged against is the one it started under.
|
|
780
|
+
const capacityNow = () => {
|
|
781
|
+
const read = (fn) => {
|
|
782
|
+
try {
|
|
783
|
+
return fn();
|
|
784
|
+
} catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
const budget = hostBudget ? read(() => hostBudget.current()) : null;
|
|
789
|
+
// The CPUs are the RUNTIME's count where its facts were read (the budget's own read), the worker's only when
|
|
790
|
+
// they were not: on Docker Desktop the worker sees the Mac's cores while every job runs in the VM's.
|
|
791
|
+
const runtimeCpus = hostBudget ? read(() => hostBudget.hostCpus?.()) : null;
|
|
792
|
+
return { slots: read(concurrencyNow), memMiB: budget?.memMiB ?? null, cpuCenti: budget?.cpuCenti ?? null, cpus: Number.isSafeInteger(runtimeCpus) && runtimeCpus >= 1 ? runtimeCpus : read(cpus) };
|
|
793
|
+
};
|
|
721
794
|
// THE ONE RECORDER BELOW THE GATE, bound once, so the pickup project is a property of the path and not of each call
|
|
722
795
|
// site: every record from here on goes through it, and none can drop the field and fall back to the live ref in
|
|
723
796
|
// start.mjs, which would disagree with the pickup value exactly when projects.json was edited mid-run. A bolt in
|
|
724
797
|
// project-pickup.test.mjs refuses a bare `recordRun(` call below this line.
|
|
725
|
-
|
|
798
|
+
//
|
|
799
|
+
// `capacity` (issue #599) rides the same binding, and is null until the job is admitted below (where `startedAt` is
|
|
800
|
+
// set): every record of a refusal before that point says, by its null, that the job never held a slot.
|
|
801
|
+
let capacity = null;
|
|
802
|
+
const recordAfterGate = (args) => recordRun({ ...args, project, size, capacity, earlier });
|
|
726
803
|
|
|
727
804
|
// The MATCHED ROW's scope keys both the in-process slot and the fleet lease (issue #498), the same string
|
|
728
805
|
// `budgetCapsFor` hashes below and the boot sweeper hashes from the file: a qualified `github:acme/web` row holds
|
|
@@ -984,6 +1061,14 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
984
1061
|
// exception, and it is named: the registry's refusal of an unheld venue is caught at the call, and
|
|
985
1062
|
// anything else it throws propagates to the catch below, which releases what was acquired.
|
|
986
1063
|
startedAt = new Date().toISOString();
|
|
1064
|
+
// What this host offered as the job took its slot (issue #599, INT-RUN-HISTORY-FILE-CONTRACT): the live slot
|
|
1065
|
+
// count, the host budget (null without one; `recordedCapacity` writes its `Infinity` as "off") and the CPUs the OS
|
|
1066
|
+
// reports. Each read guarded: this block must not throw (above), and a fact that cannot be read is unknown, never
|
|
1067
|
+
// a reason to fail a job that is about to run.
|
|
1068
|
+
capacity = capacityNow();
|
|
1069
|
+
// This host runs the job from here on (issue #599, phase 2): its entry, at the same instant as `startedAt`, given
|
|
1070
|
+
// back by `releaseAllHolds` on every exit. `add` is total, so this block still cannot throw.
|
|
1071
|
+
runningKey = runningJobs.add({ id: job.id, project, memMiB: size.memMiB, cpuCenti: size.cpuCenti, at: Date.parse(startedAt) });
|
|
987
1072
|
// The producer of the name both boot reapers sweep by substring. Built from the shared prefix
|
|
988
1073
|
// rather than typed here, so a rename cannot land in the producer and not in the sweeps (#227).
|
|
989
1074
|
// From the VENUE that will build the container, not from the local adapter reached for directly:
|
|
@@ -1517,7 +1602,7 @@ export function keeperHoldState(job, at) {
|
|
|
1517
1602
|
return { at, since, startedMs };
|
|
1518
1603
|
}
|
|
1519
1604
|
|
|
1520
|
-
export function createWorker({ connection, name, stopContainer, containerName, hostQueue = null, checkLease = null, scopeLease = null, checkTimeoutMs, concurrency, getSettings, redis, deps, recordRun, settledRecord = null, limiter, pauseUntil, scopedLimits, projects, jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), waitState, afterMaxMs, checkSlots = makeInFlight(), checkSlotCount, concurrencyNow, intervalMs, maxWaitMs, maxChecks, maxFaults, hostSlots = makeInFlight(), endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels, extraClosers = [], hostBudget: hostBudgetOptions = null }) {
|
|
1605
|
+
export function createWorker({ connection, name, stopContainer, containerName, hostQueue = null, checkLease = null, scopeLease = null, checkTimeoutMs, concurrency, getSettings, redis, deps, recordRun, settledRecord = null, previousRecord = null, limiter, pauseUntil, scopedLimits, projects, jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), waitState, afterMaxMs, checkSlots = makeInFlight(), checkSlotCount, concurrencyNow, intervalMs, maxWaitMs, maxChecks, maxFaults, hostSlots = makeInFlight(), endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels, extraClosers = [], hostBudget: hostBudgetOptions = null, runningJobs = makeRunningJobs() }) {
|
|
1521
1606
|
// One Worker per queue name (issue #57). A host-affine job -- one whose folder, secret resolver or wait
|
|
1522
1607
|
// check lives on THIS machine -- is enqueued to `pi-jobs@<name>` rather than filtered for at pickup,
|
|
1523
1608
|
// because BullMQ has no selective pop and the put-it-back alternative does not work: promotion out of
|
|
@@ -1626,6 +1711,10 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1626
1711
|
// The run-record lookup a stalled job is checked against before it can run again (see the processor's
|
|
1627
1712
|
// first gate). `null` in a bare wiring, which keeps today's behaviour: the job runs.
|
|
1628
1713
|
settledRecord,
|
|
1714
|
+
previousRecord,
|
|
1715
|
+
// Issue #599, phase 2: SHARED across both workers for the host slot's reason: the registry row lists what this
|
|
1716
|
+
// HOST runs, and two maps would each list half.
|
|
1717
|
+
runningJobs,
|
|
1629
1718
|
});
|
|
1630
1719
|
|
|
1631
1720
|
// Issue #464: only a connection `parseConnection` built, which judges and pins the Valkey it dials.
|
|
@@ -1652,6 +1741,8 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1652
1741
|
primary.hostWorker = workers[1] ?? null;
|
|
1653
1742
|
// Issue #596, phase 2: the host budget, for the registry beat's thunks (start.mjs) and doctor-facing snapshots.
|
|
1654
1743
|
primary.hostBudget = hostBudget;
|
|
1744
|
+
// Issue #599, phase 2: the jobs this host runs now, for the registry beat's `jobs` thunk (start.mjs).
|
|
1745
|
+
primary.runningJobs = runningJobs;
|
|
1655
1746
|
|
|
1656
1747
|
const stop = async () => {
|
|
1657
1748
|
// Abort active jobs (=> docker stop via onAbort), then close. Without the cancel,
|
package/src/job-size.mjs
CHANGED
|
@@ -54,6 +54,16 @@ export const JOB_MEMORY_CEILING_MIB = 1024 * 1024;
|
|
|
54
54
|
export const JOB_CPUS_FLOOR_CENTI = 25;
|
|
55
55
|
/** The largest CPU size: 256, as hundredths, because round(256 x 1024) is 262144, the top of `--cpu-shares`. */
|
|
56
56
|
export const JOB_CPUS_CEILING_CENTI = 256 * 100;
|
|
57
|
+
/**
|
|
58
|
+
* The most a host can have, as a run record's `capacity` may say it (issue #599, phase 4's review): a value past these is
|
|
59
|
+
* not a host but a fault or a forgery, so the writer records it as unknown and the capacity report counts a record that
|
|
60
|
+
* says it as unreadable, rather than judging a week against "1 of 1000000000 slots". The CPUs and the memory are the
|
|
61
|
+
* ceilings the runtime facts are read to (daemon-facts.mjs `cpuCount`, `memoryMiB`); the slots are as many of the
|
|
62
|
+
* smallest jobs (`JOB_CPUS_FLOOR_CENTI`) as those CPUs hold.
|
|
63
|
+
*/
|
|
64
|
+
export const HOST_CPUS_MAX = 4096;
|
|
65
|
+
export const HOST_MEMORY_MAX_MIB = 64 * 1024 * 1024;
|
|
66
|
+
export const HOST_SLOTS_MAX = (HOST_CPUS_MAX * 100) / JOB_CPUS_FLOOR_CENTI;
|
|
57
67
|
/** The valid range of `--cpu-shares` on cgroup v2 (runc and crun clamp to it; 2 is the kernel's cgroup v1 minimum). */
|
|
58
68
|
export const CPU_SHARES_MIN = 2;
|
|
59
69
|
export const CPU_SHARES_MAX = 262144;
|
|
@@ -119,6 +129,12 @@ export function parseCpus(value) {
|
|
|
119
129
|
return centi;
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The two never-fits refusals (index.mjs `SIZE_REFUSAL_COMMENTS`, issue #596), both decided before the job holds a slot.
|
|
134
|
+
* Here, beside the size they judge, so a leaf can read them; a test holds them equal to the processor's.
|
|
135
|
+
*/
|
|
136
|
+
export const SIZE_REFUSAL_REASONS = Object.freeze(["job-size-exceeds-host", "job-size-exceeds-share"]);
|
|
137
|
+
|
|
122
138
|
/**
|
|
123
139
|
* The one spelling of a memory size: `<n>g` when it is whole gigabytes, else `<n>m`. Every `--memory`, `--memory-swap`
|
|
124
140
|
* and stored row is written by this function, and `container-spec.mjs`'s `memoryBytes` reads every value it can write
|