@edgehero/pi-dispatch 5.2.0 → 5.2.1
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/package.json +1 -1
- package/src/auth-cli.mjs +76 -5
- package/src/cancel-cli.mjs +95 -6
- package/src/cancel-state.mjs +76 -17
- package/src/connection.mjs +207 -6
- package/src/doctor.mjs +23 -18
- package/src/env-allowlist.mjs +9 -1
- package/src/github-app-setup.mjs +17 -4
- package/src/host-loads.mjs +58 -4
- package/src/job-size.mjs +27 -0
- package/src/json-duplicates.mjs +53 -42
- package/src/mcp-preflight.mjs +2 -2
- package/src/reason-catalog.mjs +1 -1
- package/src/redact-secrets.mjs +4 -0
- package/src/reserved-env.mjs +4 -0
- package/src/sandbox-cli.mjs +9 -3
- package/src/sandbox-deployment.mjs +38 -5
- package/src/sandbox-prepare.mjs +16 -2
- package/src/sandbox.mjs +10 -3
- package/src/service-env.mjs +4 -3
- package/src/size-suggest.mjs +118 -8
- package/src/touch-outbox.mjs +5 -0
- package/src/valkey-endpoint.mjs +9 -6
package/src/json-duplicates.mjs
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* FIRST, IT RUNS ONLY ON TEXT `JSON.parse` HAS ALREADY ACCEPTED. Every caller parses first and scans
|
|
23
23
|
* second. So the scanner never has to decide whether malformed input is malformed, never has to recover
|
|
24
24
|
* from anything, and every branch it does not have is a branch that cannot be wrong. Feeding it text that
|
|
25
|
-
* did not parse is a caller error, not an input case: the result is unspecified rather than a refusal
|
|
25
|
+
* did not parse is a caller error, not an input case: the result is unspecified rather than a refusal, and
|
|
26
|
+
* since issue #868 it can be a `SyntaxError`, thrown when a key with an escape is decoded.
|
|
26
27
|
*
|
|
27
28
|
* SECOND, IT IS CHECKED AGAINST A GENERATOR THAT KNOWS THE ANSWER. `worker/test/json-duplicates.test.mjs`
|
|
28
29
|
* builds thousands of documents with adversarial key characters -- quotes, backslashes, newlines, braces,
|
|
@@ -33,14 +34,19 @@
|
|
|
33
34
|
* a key written plainly and the same key written with a `u00`-style escape are ONE key to `JSON.parse`,
|
|
34
35
|
* and a byte comparison sees two.
|
|
35
36
|
*
|
|
37
|
+
* SINCE ISSUE #868 NO KEY IS DECODED BY HAND. A key with no backslash is a slice of the text, and a key
|
|
38
|
+
* with one is handed to `JSON.parse` as its own string literal, so the decoder is the same code whose
|
|
39
|
+
* reading of the file the check defends. The hand-written decoder before it appended one character at a
|
|
40
|
+
* time, which took about 30 bytes of heap per character of a key: a 4MB key cost 123MB, and a 16MB key
|
|
41
|
+
* ended a process with a 256MB heap while it read its own configuration. That decoder agreed with
|
|
42
|
+
* `JSON.parse` on every escape, so no verdict moves. What is still written by hand about a string is
|
|
43
|
+
* where it ends, the one rule the value skip has always relied on, and the generator above checks it.
|
|
44
|
+
*
|
|
36
45
|
* IMPORT-FREE, like `reserved-env.mjs` and `provider-key.mjs` beside it: `triggers.mjs` is the shared
|
|
37
46
|
* validator, the receiver loads it and `admin/build.mjs` inlines it into the published console, so
|
|
38
47
|
* anything it reaches has to be as cheap as a list of strings.
|
|
39
48
|
*/
|
|
40
49
|
|
|
41
|
-
/** The escape characters JSON defines, other than `\u`. */
|
|
42
|
-
const SIMPLE_ESCAPES = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
43
|
-
|
|
44
50
|
/**
|
|
45
51
|
* The first duplicate key in `text`, as `{ key, at }`, or `null` when there is none.
|
|
46
52
|
*
|
|
@@ -73,54 +79,53 @@ export function findDuplicateKey(text) {
|
|
|
73
79
|
};
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
|
-
* Walk past the string starting at `s[i] === '"'` WITHOUT building it.
|
|
82
|
+
* Walk past the string starting at `s[i] === '"'` WITHOUT building it, and say whether it held an escape.
|
|
83
|
+
*
|
|
84
|
+
* Building a string here one character at a time (`out += ch`) costs about 30 bytes of heap per source
|
|
85
|
+
* character, because each append is a new rope node that lives until the string is flattened. A value's
|
|
86
|
+
* text is discarded, so it is never built: a single 64MB value cost 2GB of garbage that way, and a
|
|
87
|
+
* trigger's `run.task` and `run.instructions` are free text. A key is taken from the text by `readKey`
|
|
88
|
+
* with the end this finds (issue #868). A worker that dies at boot reading its own configuration is a
|
|
89
|
+
* worse failure than anything this file refuses.
|
|
77
90
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* function then discards. A trigger's `run.task` and `run.instructions` are free text, and a worker
|
|
81
|
-
* that dies at boot reading its own configuration is a worse failure than anything this file refuses.
|
|
91
|
+
* Character codes, as `skipWhitespace` reads them, because comparing numbers measured a little faster
|
|
92
|
+
* than comparing one-character strings on an ordinary triggers file. Either is correct.
|
|
82
93
|
*/
|
|
83
94
|
const skipString = () => {
|
|
84
95
|
i++; // the opening quote
|
|
96
|
+
let escaped = false;
|
|
85
97
|
while (i < n) {
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
98
|
+
const c = s.charCodeAt(i);
|
|
99
|
+
if (c === 0x22) {
|
|
88
100
|
i++;
|
|
89
|
-
return;
|
|
101
|
+
return escaped;
|
|
90
102
|
}
|
|
91
103
|
// An escape consumes its own next character, which is what stops `\"` ending the string. The
|
|
92
104
|
// `\uXXXX` digits need no special case here: none of them is a quote or a backslash.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
/** Read the string starting at `s[i] === '"'` and return its DECODED value. Keys only. */
|
|
98
|
-
const readString = () => {
|
|
99
|
-
i++; // the opening quote
|
|
100
|
-
let out = "";
|
|
101
|
-
while (i < n) {
|
|
102
|
-
const ch = s[i];
|
|
103
|
-
if (ch === '"') {
|
|
104
|
-
i++;
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
107
|
-
if (ch === "\\") {
|
|
108
|
-
const esc = s[i + 1];
|
|
105
|
+
if (c === 0x5c) {
|
|
106
|
+
escaped = true;
|
|
109
107
|
i += 2;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// written as two escapes still compares equal to the same pair written literally.
|
|
113
|
-
out += String.fromCharCode(Number.parseInt(s.slice(i, i + 4), 16));
|
|
114
|
-
i += 4;
|
|
115
|
-
} else {
|
|
116
|
-
out += SIMPLE_ESCAPES[esc] ?? esc;
|
|
117
|
-
}
|
|
118
|
-
continue;
|
|
108
|
+
} else {
|
|
109
|
+
i++;
|
|
119
110
|
}
|
|
120
|
-
out += ch;
|
|
121
|
-
i++;
|
|
122
111
|
}
|
|
123
|
-
return
|
|
112
|
+
return escaped; // unreachable on text that parsed
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read the key starting at `s[i] === '"'` and return its DECODED value.
|
|
117
|
+
*
|
|
118
|
+
* A key with no backslash is its own text, so it is a slice, which V8 keeps as a view of `s` rather than
|
|
119
|
+
* a copy from 13 characters up. A key with one is decoded by `JSON.parse` of its literal, quotes
|
|
120
|
+
* included. That literal is valid on its own because the whole text already parsed and `skipString`
|
|
121
|
+
* ends a string where the parse did. Decoding every key with `JSON.parse` would give the same answers;
|
|
122
|
+
* the slice is there because nearly every key has no escape and a slice copies nothing.
|
|
123
|
+
* `json-duplicates.test.mjs` holds every `\u` code unit, in both cases of hex, and every simple escape
|
|
124
|
+
* against the key `JSON.parse` makes, and the hand-written decoder this replaced passed it too.
|
|
125
|
+
*/
|
|
126
|
+
const readKey = () => {
|
|
127
|
+
const start = i;
|
|
128
|
+
return skipString() ? JSON.parse(s.slice(start, i)) : s.slice(start + 1, i - 1);
|
|
124
129
|
};
|
|
125
130
|
|
|
126
131
|
// True when the next string is a KEY rather than a value: just after `{`, or after a `,` inside one.
|
|
@@ -177,8 +182,14 @@ export function findDuplicateKey(text) {
|
|
|
177
182
|
if (ch === '"') {
|
|
178
183
|
const top = stack[stack.length - 1];
|
|
179
184
|
if (expectKey && top && top.keys !== null) {
|
|
180
|
-
const key =
|
|
181
|
-
|
|
185
|
+
const key = readKey();
|
|
186
|
+
// The key is copied on the way out, once: a slice is a view that keeps the whole text alive for as long
|
|
187
|
+
// as a caller holds the answer, and `secret-bundles.mjs` keeps `key` past the call while its text is a
|
|
188
|
+
// resolver's output, which holds secret values. JSON's own round trip keeps a lone surrogate as itself.
|
|
189
|
+
if (top.keys.has(key)) {
|
|
190
|
+
const own = JSON.parse(JSON.stringify(key));
|
|
191
|
+
return { key: own, at: [...path, own].join(".") };
|
|
192
|
+
}
|
|
182
193
|
top.keys.add(key);
|
|
183
194
|
path.push(key);
|
|
184
195
|
top.named = true;
|
package/src/mcp-preflight.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export function makeMcpCheck({ globalPiDir, forwardEnv = [], hostEnv = {}, egres
|
|
|
158
158
|
// writes under exactly that name, which is the reason every other server is refused it.
|
|
159
159
|
for (const name of overlayNames) {
|
|
160
160
|
for (const ref of servers[name].refs) {
|
|
161
|
-
if (own.has(ref)) return { refused: "mcp-secret-reserved", name, why: `it references ${ref}, which this deployment uses for the job's own credentials` };
|
|
161
|
+
if (own.has(ref)) return { refused: "mcp-secret-reserved", name, why: `it references ${ref}, which this deployment uses for the job's own credentials or settings` };
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
for (const name of overlayNames) {
|
|
@@ -276,7 +276,7 @@ export const MCP_REFUSAL_COMMENT = Object.freeze({
|
|
|
276
276
|
"mcp-list-invalid": "the list this job carries is not one the triggers file's loader writes (a service in this deployment may be at another version)",
|
|
277
277
|
"mcp-config-invalid": "this deployment's list of MCP servers (mcp.json in the overlay) cannot be used",
|
|
278
278
|
"mcp-server-unknown": "this deployment does not offer that server",
|
|
279
|
-
"mcp-secret-reserved": "that server would be handed a
|
|
279
|
+
"mcp-secret-reserved": "that server would be handed a variable this deployment keeps for the job itself",
|
|
280
280
|
"mcp-secret-unmapped": "a credential that server needs is not passed by this trigger",
|
|
281
281
|
"mcp-excluded": "this trigger's run.excludeTools removes every way the job could reach that server",
|
|
282
282
|
"mcp-egress-unreachable": "the egress policy does not let a job reach that server",
|
package/src/reason-catalog.mjs
CHANGED
|
@@ -274,7 +274,7 @@ const ROWS = [
|
|
|
274
274
|
["mcp-config-invalid", "Refused: the list of MCP servers could not be used: the overlay `mcp.json` before the job started, or the list the worker wrote for the job inside its container. Not run.", "Fix the overlay `mcp.json` (`pi-dispatch doctor` checks it), and run a worker and a job image of the same release."],
|
|
275
275
|
["mcp-server-unknown", mcpRefusalComment("mcp-server-unknown", null), "Declare the server in the overlay `mcp.json`, or fix the name in `run.mcp`."],
|
|
276
276
|
["mcp-secret-unmapped", mcpRefusalComment("mcp-secret-unmapped", null), "Pass the credential the server needs in the trigger's `run.secrets`."],
|
|
277
|
-
["mcp-secret-reserved", mcpRefusalComment("mcp-secret-reserved", null), "Give the server
|
|
277
|
+
["mcp-secret-reserved", mcpRefusalComment("mcp-secret-reserved", null), "Give the server a variable of its own, not one the job keeps."],
|
|
278
278
|
["mcp-excluded", mcpRefusalComment("mcp-excluded", null), "Remove the server from `run.mcp`, or keep a tool that reaches it out of `run.excludeTools`."],
|
|
279
279
|
["mcp-egress-unreachable", mcpRefusalComment("mcp-egress-unreachable", null), "Add the server's host to the egress allowlist, or drop the server from `run.mcp`."],
|
|
280
280
|
["mcp-package-runner", mcpRefusalComment("mcp-package-runner", null), "Allow the package registry in the egress allowlist, or install the server in the job image."],
|
package/src/redact-secrets.mjs
CHANGED
|
@@ -135,6 +135,10 @@ export function makeRedactor({ values = [], extraPatterns = [] } = {}) {
|
|
|
135
135
|
export const NON_SECRET_ENV_KEYS = new Set([
|
|
136
136
|
// Issue #693: a memory-derived concurrency count, never a credential; keep it in the structural venue env.
|
|
137
137
|
"PI_CODEMODE_MAX_WORKERS",
|
|
138
|
+
// Issue #865: the job's size, two integers, never a secret. Listed, or OpenShell would put each in the job's credential
|
|
139
|
+
// provider and the agent would read a placeholder where a tool's worker count needs the number.
|
|
140
|
+
"PI_JOB_SIZE_CPUS",
|
|
141
|
+
"PI_JOB_SIZE_MEMORY_MIB",
|
|
138
142
|
"PI_PROVIDER",
|
|
139
143
|
"PI_MODEL",
|
|
140
144
|
"PI_FORK_ENTRY",
|
package/src/reserved-env.mjs
CHANGED
|
@@ -37,6 +37,10 @@ export const CONTAINER_ENV_NAMES = new Set([
|
|
|
37
37
|
"PI_RUNNER_OBSERVABLE",
|
|
38
38
|
// Issue #693: derived from the resolved job memory, never chosen by a trigger or forwarded host value.
|
|
39
39
|
"PI_CODEMODE_MAX_WORKERS",
|
|
40
|
+
// Issue #865: the job's size as the job reads it (job-size.mjs `jobSizeEnv`). A trigger binding one through
|
|
41
|
+
// run.secrets, or a forwarded host value, would tell the job's tools a size the container was not given.
|
|
42
|
+
"PI_JOB_SIZE_CPUS",
|
|
43
|
+
"PI_JOB_SIZE_MEMORY_MIB",
|
|
40
44
|
"PI_PROVIDER",
|
|
41
45
|
"PI_MODEL",
|
|
42
46
|
"PI_FORK_ENTRY",
|
package/src/sandbox-cli.mjs
CHANGED
|
@@ -61,7 +61,9 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
61
61
|
|
|
62
62
|
if (values.deployment) {
|
|
63
63
|
const { sandboxDeploymentEnv } = await import("./sandbox-deployment.mjs");
|
|
64
|
-
|
|
64
|
+
// Only `--new` reads the preparation's settings (#863), so only `--new` loads and compares them: an opener's open or
|
|
65
|
+
// attach must not refuse over a shell's PI_JOB_MEMORY it would never read.
|
|
66
|
+
const loaded = await (deps.deploymentEnv ?? sandboxDeploymentEnv)({ dir: values.deployment, env, expectedHost: values["expected-host"], preparing: values.new !== undefined });
|
|
65
67
|
if (loaded.refused) return fail(err, loaded.message);
|
|
66
68
|
// Readers and runtime helpers use their process's environment. Re-exec in the clean environment so none
|
|
67
69
|
// can accidentally consult the SSH shell's DOCKER_HOST, egress or rootless store (#771).
|
|
@@ -209,9 +211,13 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
209
211
|
return result.code ?? 0;
|
|
210
212
|
}
|
|
211
213
|
|
|
212
|
-
|
|
214
|
+
// The child runs IN the deployment folder, as the service does (its unit's WorkingDirectory), and that is load-bearing
|
|
215
|
+
// (#863): a relative PI_PROJECTS_FILE, PI_SCOPED_LIMITS_FILE or PI_TRIGGERS_FILE, and an unset PI_TRIGGERS_FILE's
|
|
216
|
+
// `triggers.json`, then name the files the deployment's worker reads. Resolving them here instead would be a second
|
|
217
|
+
// rule for the same paths, which drifts.
|
|
218
|
+
export function runDeploymentSandbox(argv, env, dir, { spawnFn = spawn } = {}) {
|
|
213
219
|
return new Promise((resolve) => {
|
|
214
|
-
const child =
|
|
220
|
+
const child = spawnFn(process.execPath, [fileURLToPath(new URL("./cli.mjs", import.meta.url)), "sandbox", ...argv], { env, ...(dir ? { cwd: dir } : {}), stdio: "inherit" });
|
|
215
221
|
child.once("error", () => resolve(1));
|
|
216
222
|
child.once("exit", (code) => resolve(code ?? 1));
|
|
217
223
|
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* An explicit deployment handoff is stronger than a generic deployment pointer: it loads sandbox authority only, plus,
|
|
3
|
+
* for `--new` alone, what a sandbox's preparation reads. It never loads a forge credential.
|
|
4
|
+
*/
|
|
2
5
|
import * as nodeFs from "node:fs";
|
|
3
6
|
import { isAbsolute, join, win32 } from "node:path";
|
|
4
7
|
import { homedir } from "node:os";
|
|
@@ -10,15 +13,38 @@ export const SANDBOX_DEPLOYMENT_KEYS = Object.freeze([
|
|
|
10
13
|
"PI_BACKENDS", "PI_BACKEND_FLOOR", "PI_EGRESS", "PI_EGRESS_PROXY", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH",
|
|
11
14
|
"VALKEY_URL", "VALKEY_PASSWORD", "PI_VALKEY_SHARED", "PATH", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "CONTAINERS_STORAGE_CONF", "CONTAINERS_CONF", "CONTAINERS_CONF_OVERRIDE", "CONTAINERS_REGISTRIES_CONF", "CONTAINER_HOST", "CONTAINER_CONNECTION",
|
|
12
15
|
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Issue #863: what `--new` reads to plan a preparation (`planSandboxPreparation`) beyond the list above. Without them a
|
|
19
|
+
* sandbox prepared through `--deployment` got no project, the default size and the default image, so its plan was not
|
|
20
|
+
* the one the deployment's own worker and the console's preview make. A relative file path names a file in the
|
|
21
|
+
* deployment folder, because the re-exec runs there as the service does (`runDeploymentSandbox`).
|
|
22
|
+
*
|
|
23
|
+
* A LIST OF ITS OWN, read only when preparing. Opening, attaching and `--list` never read these, and folding them into
|
|
24
|
+
* the list above made every opener command refuse over a shell's `PI_JOB_MEMORY` or a `.env` line such as
|
|
25
|
+
* `PI_PROJECTS_FILE=$HOME/p.json` that only the service's shell can expand. When preparing they get the same strict rule
|
|
26
|
+
* as the rest, named: a value the shell sets that the deployment does not, or sets otherwise, refuses, and so does a line
|
|
27
|
+
* that cannot be read as the service reads it. Letting the deployment's value win quietly would prepare with a setting
|
|
28
|
+
* the person did not see.
|
|
29
|
+
*
|
|
30
|
+
* The forge's credential and address are left out on purpose (#771): with none, a GitHub trigger clones with this
|
|
31
|
+
* account's own `gh` login, and a trigger on another forge cannot be prepared this way (the console's preparation runs
|
|
32
|
+
* in the worker, with its credential). sandbox-deployment-plan.test.mjs records the plan's inputs from the real preparer,
|
|
33
|
+
* and fails on a new one until it is carried in one of these lists or named there with its reason.
|
|
34
|
+
*/
|
|
35
|
+
export const SANDBOX_PREPARATION_KEYS = Object.freeze(["PI_TRIGGERS_FILE", "PI_PROJECTS_FILE", "PI_SCOPED_LIMITS_FILE", "PI_JOB_IMAGE", "PI_JOB_MEMORY", "PI_JOB_CPUS"]);
|
|
13
36
|
const OS_KEYS = Object.freeze(["HOME", "USERPROFILE", "SystemRoot", "WINDIR", "TEMP", "TMP", "TMPDIR", "TERM", "LANG", "LC_ALL", "PATH"]);
|
|
14
|
-
export function sandboxDeploymentEnv({ dir, env = process.env, expectedHost = null, fs = nodeFs, platform = process.platform, uid = process.geteuid?.(), home = homedir(), read = deploymentServiceEnv } = {}) {
|
|
37
|
+
export function sandboxDeploymentEnv({ dir, env = process.env, expectedHost = null, preparing = false, fs = nodeFs, platform = process.platform, uid = process.geteuid?.(), home = homedir(), read = deploymentServiceEnv } = {}) {
|
|
15
38
|
const refuse = (message) => ({ refused: "sandbox-deployment", message });
|
|
16
39
|
if (typeof dir !== "string" || !isAbsolute(dir) || /[\x00-\x1f\x7f]/.test(dir)) return refuse("--deployment must name an absolute deployment directory");
|
|
17
40
|
// One descriptor-backed read. Ambient values are compared afterwards, never used as defaults for authority.
|
|
18
|
-
const resolved = read({ dir, env: {}, keys: SANDBOX_DEPLOYMENT_KEYS, platform, fs, uid });
|
|
41
|
+
const resolved = read({ dir, env: {}, keys: preparing ? [...SANDBOX_DEPLOYMENT_KEYS, ...SANDBOX_PREPARATION_KEYS] : SANDBOX_DEPLOYMENT_KEYS, platform, fs, uid });
|
|
19
42
|
if (resolved.unreadable || resolved.untrusted || resolved.hazard) return refuse("the deployment .env cannot be trusted or read as its service reads it; run pi-dispatch doctor");
|
|
20
43
|
if (!resolved.path || (resolved.fromFile && Object.keys(resolved.fromFile).length === 0 && resolved.env && !fs.existsSync(resolved.path))) return refuse("the deployment .env is missing; point --deployment at the installed deployment");
|
|
21
|
-
|
|
44
|
+
const unread = [...(resolved.unread ?? []).map((u) => u.key), ...(resolved.hazardSkipped ?? [])];
|
|
45
|
+
if (unread.some((key) => SANDBOX_DEPLOYMENT_KEYS.includes(key))) return refuse("a sandbox setting cannot be read the way the deployment service reads it; run pi-dispatch doctor");
|
|
46
|
+
const unreadPlan = unread.find((key) => SANDBOX_PREPARATION_KEYS.includes(key));
|
|
47
|
+
if (unreadPlan) return refuse(`the deployment's ${unreadPlan} line cannot be read the way its service reads it; run pi-dispatch doctor before preparing`);
|
|
22
48
|
const clean = { ...resolved.env };
|
|
23
49
|
for (const key of SANDBOX_DEPLOYMENT_KEYS) {
|
|
24
50
|
if (typeof env[key] !== "string") continue;
|
|
@@ -38,11 +64,18 @@ export function sandboxDeploymentEnv({ dir, env = process.env, expectedHost = nu
|
|
|
38
64
|
if (key === "XDG_CONFIG_HOME" && env[key] === join(home, ".config")) { clean[key] = env[key]; continue; }
|
|
39
65
|
return refuse(`the shell sets sandbox authority absent from the deployment: ${key}; unset that variable before opening`);
|
|
40
66
|
}
|
|
67
|
+
if (preparing) {
|
|
68
|
+
for (const key of SANDBOX_PREPARATION_KEYS) {
|
|
69
|
+
if (typeof env[key] !== "string") continue;
|
|
70
|
+
if (typeof clean[key] !== "string") return refuse(`the shell sets ${key}, which the deployment's .env does not set; unset that variable before preparing`);
|
|
71
|
+
if (clean[key] !== env[key]) return refuse(`the shell disagrees with the deployment for ${key}; unset that variable before preparing`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
41
74
|
const result = {};
|
|
42
75
|
for (const key of OS_KEYS) if (typeof env[key] === "string") result[key] = env[key];
|
|
43
76
|
// env-internal HOME: compare the launching account's home to the OS identity before opening a rootless store.
|
|
44
77
|
if (platform !== "win32" && env.HOME && env.HOME !== home) return refuse("HOME is not this account's home; open the sandbox as the worker's account");
|
|
45
|
-
for (const key of SANDBOX_DEPLOYMENT_KEYS) if (typeof clean[key] === "string") result[key] = clean[key];
|
|
78
|
+
for (const key of preparing ? [...SANDBOX_DEPLOYMENT_KEYS, ...SANDBOX_PREPARATION_KEYS] : SANDBOX_DEPLOYMENT_KEYS) if (typeof clean[key] === "string") result[key] = clean[key];
|
|
46
79
|
if (typeof result.PATH === "string" && (/[\x00-\x1f\x7f]/.test(result.PATH) || result.PATH.split(platform === "win32" ? ";" : ":").some((part) => !part || !(platform === "win32" ? win32.isAbsolute(part) : isAbsolute(part))))) return refuse("the sandbox runtime PATH must contain only absolute tool directories; correct PATH in the deployment .env");
|
|
47
80
|
try {
|
|
48
81
|
if (expectedHost !== null && workerName(result) !== expectedHost) return refuse("the deployment worker name does not match the opener host; check Fleet and the local opener configuration");
|
package/src/sandbox-prepare.mjs
CHANGED
|
@@ -21,6 +21,13 @@ const PREPARATION_LOCK_MAX_BYTES = 16 * 1024;
|
|
|
21
21
|
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
22
22
|
const refused = (reason, message) => ({ refused: reason, message });
|
|
23
23
|
const configRefusal = () => refused("sandbox-config-invalid", "the host's sandbox configuration could not be read; run pi-dispatch doctor on that host");
|
|
24
|
+
/**
|
|
25
|
+
* What a preparation needs set to clone from each forge whose address is configuration (#863), as `config.mjs` reads
|
|
26
|
+
* them (`loadGitLabAuth`, `loadForgejoAuth`, `loadAzureAuth`: no block at all without the token). GitHub's address is
|
|
27
|
+
* fixed. Named in the refusal so it says what to set, in words true both in a worker serving the console and in a
|
|
28
|
+
* terminal; sandbox-prepare.test.mjs holds each name to `loadConfig`.
|
|
29
|
+
*/
|
|
30
|
+
export const SANDBOX_FORGE_SETTINGS = Object.freeze({ gitlab: "GitLab (GITLAB_TOKEN, and GITLAB_URL when it is not gitlab.com)", forgejo: "Forgejo (FORGEJO_TOKEN and FORGEJO_URL)", azure: "Azure DevOps (AZURE_TOKEN and AZURE_ORG_URL)" });
|
|
24
31
|
|
|
25
32
|
export function sandboxTriggerDigest(text, index) {
|
|
26
33
|
const rows = JSON.parse(text)?.triggers;
|
|
@@ -123,11 +130,18 @@ export function createSandboxPreparer({ env = process.env, config = loadConfig(e
|
|
|
123
130
|
catch (error) { if (error?.piDispatchConfig || error?.code === "ENOENT") return configRefusal(); throw new Error("sandbox preparation configuration read failed", { cause: error }); }
|
|
124
131
|
if (planned.refused) return planned;
|
|
125
132
|
const { plan } = planned;
|
|
133
|
+
// A forge's settings are configuration, so a preparation without usable ones is a determinate refusal, said here
|
|
134
|
+
// before any directory, lock, credential or clone. Inside the directories' catch below it read as infrastructure: the
|
|
135
|
+
// CLI said "try again" and the control queue retried a request that can never succeed (#863,
|
|
136
|
+
// CONST-RETRY-INFRA-ONLY). The plan has already refused a malformed repository (`sandbox-repo-invalid`, from the same
|
|
137
|
+
// `sandboxRepoProblem` this calls), so what is left to throw is the forge's settings: no block at all (its token is
|
|
138
|
+
// unset, whatever its URL says), or an address that is not a plain http(s) URL.
|
|
139
|
+
let remote;
|
|
140
|
+
try { remote = sandboxRemoteUrl(plan.kind, plan.repo, config); }
|
|
141
|
+
catch { return refused("sandbox-forge-unconfigured", `this preparation has no usable settings for ${SANDBOX_FORGE_SETTINGS[plan.kind] ?? "the trigger's forge"}, so it cannot clone the repository`); }
|
|
126
142
|
const id = request.id ?? newSandboxId();
|
|
127
143
|
const fingerprint = hash(JSON.stringify({ plan, by: request.by ?? null }));
|
|
128
|
-
let remote;
|
|
129
144
|
try {
|
|
130
|
-
remote = sandboxRemoteUrl(plan.kind, plan.repo, config);
|
|
131
145
|
ensureDir(config.sandboxDir, { env });
|
|
132
146
|
fs.mkdirSync(config.sandboxDir, { recursive: true, mode: 0o700 });
|
|
133
147
|
ensureDir(config.sandboxDir, { env });
|
package/src/sandbox.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { configError } from "./config.mjs";
|
|
|
10
10
|
import { assertJobUser, CONTAINER_HOME, SHIPPED_IMAGE_UID } from "./container-spec.mjs";
|
|
11
11
|
import { insideDir } from "./docker-run.mjs";
|
|
12
12
|
import { CGROUP_PARENT, cgroupParentFor } from "./cpu-reserve.mjs";
|
|
13
|
-
import { DEFAULT_JOB_SIZE, recordedJobSize } from "./job-size.mjs";
|
|
13
|
+
import { DEFAULT_JOB_SIZE, jobSizeEnv, recordedJobSize } from "./job-size.mjs";
|
|
14
14
|
import { makeImagePreflight } from "./image-preflight.mjs";
|
|
15
15
|
import { decideJobUser, JOB_USER_FIX, makeDaemonFactsReader, relabelsPrivateMounts, resolveImageUser, socketFacts } from "./job-user.mjs";
|
|
16
16
|
import { DEFAULT_EGRESS_PROXY, NETWORK_SUFFIX, createJobNetwork, egressArmed, egressEnv, egressProxyName, networkEndpoints, networkExists, networkNameFor, removeJobNetwork, removeNetworkOrSay } from "./egress.mjs";
|
|
@@ -43,7 +43,8 @@ export { SANDBOX_LAUNCHERS, SANDBOX_OPEN_GRACE_MS, sandboxLauncher, sandboxSyncR
|
|
|
43
43
|
* (env-allowlist.mjs) and throws outright when no provider credential resolves, so a credential-free
|
|
44
44
|
* container cannot be produced from it. The env here is two variables about the terminal, plus the four
|
|
45
45
|
* proxy variables when an egress policy is armed, plus `HOME=/home/pi` beside `--user` when the run had a job
|
|
46
|
-
* user (issue #341)
|
|
46
|
+
* user (issue #341), plus the size the manifest records (`PI_JOB_SIZE_CPUS`, `PI_JOB_SIZE_MEMORY_MIB`, issue #865):
|
|
47
|
+
* the run's, or for a prepared sandbox the size its trigger's jobs get.
|
|
47
48
|
* - No `/outbox`, no `/session`, no `/opt/pi-global`. The agent is not running; there is nothing to
|
|
48
49
|
* chain, no transcript to continue and no overlay to layer.
|
|
49
50
|
*
|
|
@@ -177,9 +178,15 @@ export function buildSandboxRunArgs({ venue = DEFAULT_BACKEND, image, name, work
|
|
|
177
178
|
TMOUT: idleSeconds > 0 ? String(idleSeconds) : undefined,
|
|
178
179
|
// Beside `--user` only (issue #341). Not a credential either.
|
|
179
180
|
HOME: user !== null ? home : undefined,
|
|
181
|
+
// Issue #865: the size this shell is built at (`size`: the manifest's, the run's own or, for a prepared
|
|
182
|
+
// sandbox, its trigger's), by the job env's own function (`jobSizeEnv`), so a flow command copied in here
|
|
183
|
+
// (`jest --maxWorkers=$PI_JOB_SIZE_CPUS`) sizes its pool as a job of that size would instead of expanding to
|
|
184
|
+
// nothing. A run retained before this release never had the variables; its shell gets them for its recorded
|
|
185
|
+
// size all the same. Two integers, not a credential.
|
|
186
|
+
...jobSizeEnv(size),
|
|
180
187
|
// Still NO CREDENTIALS, and that clause is untouched: a proxy URL is not a credential, and
|
|
181
188
|
// buildContainerEnv is still not reused here. The env is two variables about the terminal, HOME
|
|
182
|
-
// beside `--user`, and, when a policy is armed, four about the network.
|
|
189
|
+
// beside `--user`, two about the run's size and, when a policy is armed, four about the network.
|
|
183
190
|
...proxyEnv,
|
|
184
191
|
},
|
|
185
192
|
// Ahead of the env and the mounts, and well ahead of the image, which the builder keeps as
|
package/src/service-env.mjs
CHANGED
|
@@ -237,7 +237,8 @@ export function readDeploymentEnv({ dir, fs, uid = process.geteuid?.(), names =
|
|
|
237
237
|
/**
|
|
238
238
|
* The `.env` of the deployment in `dir`, read the way doctor reads it (a regular file only, as bytes, one read) and
|
|
239
239
|
* resolved for `keys` (`resolveServiceEnv`). For a caller that has no file of its own in hand (the admin panel).
|
|
240
|
-
* Returns the resolution plus `path`, `
|
|
240
|
+
* Returns the resolution plus `path`, `absent` (true when nothing is at the path: issue #845's console tells a file that
|
|
241
|
+
* went away from one that never was), `unreadable` (a reason, when something is at the path and could not be read) and
|
|
241
242
|
* `untrusted` (`envFileTrust`'s reason); in either case nothing was taken from it. `shellRunsLine` is the line a sourcing shell does
|
|
242
243
|
* not read as an assignment, whatever keys were asked for: it can set any of them without spelling one (`. ./more.env`, `eval`), so
|
|
243
244
|
* `hazardSkipped`, which needs the key spelled, says nothing of it. Only for that loader: systemd ignores such a line and the cmd wrapper
|
|
@@ -245,11 +246,11 @@ export function readDeploymentEnv({ dir, fs, uid = process.geteuid?.(), names =
|
|
|
245
246
|
*/
|
|
246
247
|
export function deploymentServiceEnv({ env, dir, keys, platform = process.platform, fs, uid = process.geteuid?.() }) {
|
|
247
248
|
// Issue #471 (gate rounds 1 and 2): one descriptor, and nothing taken from a file another account can change.
|
|
248
|
-
const { path, bytes, unreadable, untrusted } = readDeploymentEnv({ dir, fs, uid });
|
|
249
|
+
const { path, bytes, absent, unreadable, untrusted } = readDeploymentEnv({ dir, fs, uid });
|
|
249
250
|
const raw = untrusted ? null : bytes;
|
|
250
251
|
const file = raw === null ? null : serviceEnvFileOf(raw, path, serviceEnvLoader(platform));
|
|
251
252
|
const shellRunsLine = file?.loader === "shell" && file.hazard !== null && file.hazard.shape !== "shell-crlf-comment" ? file.hazard.line : null;
|
|
252
|
-
return { ...resolveServiceEnv({ env, file, keys }), path, unreadable, untrusted, hazard: file?.hazard ?? null, shellRunsLine };
|
|
253
|
+
return { ...resolveServiceEnv({ env, file, keys }), path, absent, unreadable, untrusted, hazard: file?.hazard ?? null, shellRunsLine };
|
|
253
254
|
}
|
|
254
255
|
|
|
255
256
|
/**
|
package/src/size-suggest.mjs
CHANGED
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
* 4. otherwise it fits.
|
|
40
40
|
* A FACT (no call) rides along when runs at the limit were also under real memory pressure (`memFullUsec` above 1%
|
|
41
41
|
* of the wall time): information, never an instruction.
|
|
42
|
+
* An `oom-killed` raise also says what CPU count the killed runs' tools counted at least (issue #865, `oomCeiling`,
|
|
43
|
+
* worded as `memoryCeiling`): on a venue whose `--cpus` is the host ceiling (`CEILING_VENUES`), the fewest whole CPUs
|
|
44
|
+
* a run's ceiling gives `os.availableParallelism()` (`seenCpusOf`), from its record's `capacity`, when that is more
|
|
45
|
+
* than its size in whole CPUs (`PI_JOB_SIZE_CPUS`). A tool that starts a worker per CPU it sees starts one per such CPU inside the size's memory,
|
|
46
|
+
* so a smaller pool may be a cheaper fix than a larger size. Information beside the raise, which is the same either
|
|
47
|
+
* way.
|
|
42
48
|
* A size is rounded UP to a step: 256m up to 2g, 512m up to 8g, then 1g.
|
|
43
49
|
* CPUs: fewer than `SUGGEST_MIN_SAMPLES` runs is not enough runs; the p95 cores used (CPU time over wall time) below
|
|
44
50
|
* 0.4x the current cpus lowers to 1.25x that p95, never below 1.25x the window's largest cores used, nor 0.25;
|
|
@@ -59,7 +65,7 @@
|
|
|
59
65
|
* land on the side the rule says, on every host.
|
|
60
66
|
*/
|
|
61
67
|
|
|
62
|
-
import { JOB_CPUS_CEILING_CENTI, JOB_CPUS_FLOOR_CENTI, JOB_MEMORY_CEILING_MIB, JOB_MEMORY_FLOOR_MIB, formatCpus, formatMemory, recordedJobSize } from "./job-size.mjs";
|
|
68
|
+
import { HOST_CPUS_MAX, JOB_CPUS_CEILING_CENTI, JOB_CPUS_FLOOR_CENTI, JOB_MEMORY_CEILING_MIB, JOB_MEMORY_FLOOR_MIB, cpuCeilingCenti, formatCpus, formatMemory, recordedJobSize, wholeCpusOf } from "./job-size.mjs";
|
|
63
69
|
|
|
64
70
|
/** How far back a suggestion reads: 30 days. */
|
|
65
71
|
export const SUGGEST_WINDOW_DAYS = 30;
|
|
@@ -93,6 +99,18 @@ export const MEMORY_HELD = Object.freeze(["cap", "largest", "no-cap"]);
|
|
|
93
99
|
export const MEMORY_CAP_MISSING = Object.freeze(["unread", "none-holds", "off"]);
|
|
94
100
|
/** The facts a suggestion may carry with no call: memory `pressure`, CPU `ceiling`. */
|
|
95
101
|
export const SIZE_FACTS = Object.freeze(["pressure", "ceiling"]);
|
|
102
|
+
/**
|
|
103
|
+
* Issue #865: the venues whose job containers get the host's CPU ceiling as `--cpus`, the same for every job
|
|
104
|
+
* (job-size.mjs `containerSizing`, on the docker and podman builders alike), so a job's tools count at least the ceiling
|
|
105
|
+
* (`seenCpusOf`) and never its size. OpenShell's venues are not here: their `--cpu` is the job's size itself
|
|
106
|
+
* (backend-openshell.mjs `sizeArgs`), so on cgroup v2 a tool there reads the size rounded down from one CPU up, and
|
|
107
|
+
* under one CPU (0.25 to 0.99) the gateway's whole runtime, whose CPU count the record does not hold (its
|
|
108
|
+
* `capacity.cpus` is this host's runtime's, and the gateway may be another machine). That last case is a residual,
|
|
109
|
+
* named in `DES-JOB-SIZE-IN-THE-ENV`, not a clause said on a guess. Restated, since this module imports nothing heavy, and held
|
|
110
|
+
* to backends.mjs by a test: every venue is in this list or in `OPENSHELL_BACKENDS`, so a new venue fails it until
|
|
111
|
+
* someone says which it is.
|
|
112
|
+
*/
|
|
113
|
+
export const CEILING_VENUES = Object.freeze(["local", "podman"]);
|
|
96
114
|
|
|
97
115
|
const MIB = 1024 * 1024;
|
|
98
116
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -117,9 +135,60 @@ function rank(sorted, pct) {
|
|
|
117
135
|
|
|
118
136
|
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
|
119
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Issue #865: the whole CPUs a run's tools read through `os.availableParallelism()`, as its record gives it, or null. The
|
|
140
|
+
* `--cpus` ceiling comes from the record's `capacity` (`cpus`, the runtime's CPU count, and `cpuCenti`, the host's CPU
|
|
141
|
+
* budget, each judged again here) through the worker's own `cpuCeilingCenti`, on a venue in `CEILING_VENUES` only, and
|
|
142
|
+
* becomes a count by `seenCpusOf`. Null for any other venue, a record with no readable `capacity` (every record before
|
|
143
|
+
* issue #599, and every refusal before a slot), a capacity that names neither, and a ceiling under one CPU on a record
|
|
144
|
+
* whose runtime count is not known.
|
|
145
|
+
* The record holds what the host offered at admission, not the argv itself, so this is the ceiling the worker computes
|
|
146
|
+
* from those facts. Where the budget's read of the runtime gave no CPU count, the record holds the worker's own count
|
|
147
|
+
* instead (index.mjs `capacityNow`), which on Docker Desktop is the Mac's and not the VM's, so the value is then an
|
|
148
|
+
* estimate of what the tools read. Accepted, rather than a record field for the argv's own `--cpus`: the clause is
|
|
149
|
+
* information beside a raise that does not depend on it, and that read failing while a job still ran is rare.
|
|
150
|
+
*/
|
|
151
|
+
function seenOf(record) {
|
|
152
|
+
if (!CEILING_VENUES.includes(record.backend)) return null;
|
|
153
|
+
const c = record.capacity;
|
|
154
|
+
if (c === null || typeof c !== "object" || Array.isArray(c)) return null;
|
|
155
|
+
const cpus = Number.isSafeInteger(c.cpus) && c.cpus >= 1 && c.cpus <= HOST_CPUS_MAX ? c.cpus : null;
|
|
156
|
+
const budget = Number.isSafeInteger(c.cpuCenti) && c.cpuCenti >= 1 && c.cpuCenti <= HOST_CPUS_MAX * 100 ? c.cpuCenti : null;
|
|
157
|
+
const ceilingCenti = cpuCeilingCenti(cpus, budget);
|
|
158
|
+
return ceilingCenti === null ? null : seenCpusOf(ceilingCenti, cpus);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Issue #865: the libuv the rule in `seenCpusOf` was read at, the one the job image's Node bundles (the Node
|
|
163
|
+
* image/Dockerfile notes its base resolves to). The next minor release, 1.52.0, changed it
|
|
164
|
+
* (`uv__get_cgroupv2_constrained_cpu` answers 1 for a quota under one CPU, where this release answers 0 and the caller
|
|
165
|
+
* skips it), so the image job compares the built image's `process.versions.uv` and `process.versions.node` with this
|
|
166
|
+
* and with that note (.github/scripts/libuv-pin-check.mjs, in "the job image holds its contract") and fails on any
|
|
167
|
+
* other, saying to read the rule again at the new libuv before moving this.
|
|
168
|
+
*/
|
|
169
|
+
export const SEEN_CPUS_LIBUV = "1.51.0";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Issue #865: the fewest whole CPUs a tool counts through `os.availableParallelism()` in a container with a `--cpus`
|
|
173
|
+
* ceiling in hundredths, or null, where `runtimeCpus` is the runtime's own count (the container's affinity mask holds all
|
|
174
|
+
* of it, so `nproc` reads it). A LOWER BOUND, since what libuv reads depends on the host's cgroup version, which the
|
|
175
|
+
* record does not hold. Read at libuv `SEEN_CPUS_LIBUV`: on cgroup v2, `uv__get_constrained_cpu` (src/unix/linux.c)
|
|
176
|
+
* sets the quota to `cpu.max`'s limit over its period in INTEGER division, and `uv_available_parallelism`
|
|
177
|
+
* (src/unix/core.c) takes it only `if (quota > 0 && quota < rc)`, where `rc` is the affinity count: a ceiling of 2.5
|
|
178
|
+
* reads as 2 (ROUNDED DOWN, where `PI_JOB_SIZE_CPUS` rounds the size up), and one under one CPU gives a quota of 0,
|
|
179
|
+
* which is skipped, so the tool reads the WHOLE runtime. On cgroup v1, `uv__cgroup1_find_cpu_controller` turns
|
|
180
|
+
* `N:cpu,cpuacct:/path` into a `/sys/fs/cgroup/cpuacct:/path` that does not exist, so the read fails and the tool reads
|
|
181
|
+
* the whole runtime at any ceiling. So a tool counts at least the ceiling rounded down, and under one CPU the whole
|
|
182
|
+
* runtime: null when the record does not say how many that is.
|
|
183
|
+
*/
|
|
184
|
+
function seenCpusOf(ceilingCenti, runtimeCpus) {
|
|
185
|
+
if (ceilingCenti >= 100) return Math.floor(ceilingCenti / 100);
|
|
186
|
+
return Number.isSafeInteger(runtimeCpus) && runtimeCpus >= 1 ? runtimeCpus : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
120
189
|
/**
|
|
121
190
|
* One record as a suggestion reads it, or null when it is not evidence: `{ at, size, memPeak, oom, wallUsec, cpuUsec,
|
|
122
|
-
* throttledUsec, memFullUsec }`. `memPeak` (bytes, clamped to the record's own limit) is null when absent or not a
|
|
191
|
+
* throttledUsec, memFullUsec, seenCpus }`. `memPeak` (bytes, clamped to the record's own limit) is null when absent or not a
|
|
123
192
|
* count; `wallUsec` is null unless the span is readable; `cpuUsec` is null unless it can be read with a wall time (CPU
|
|
124
193
|
* time clamped to the CPU ceiling over the wall), and `throttledUsec` likewise on its own (clamped to the wall): cgroup v2
|
|
125
194
|
* writes `throttled_usec` in `cpu.stat` only where the cpu controller is enabled, so a run without it is still a CPU
|
|
@@ -145,7 +214,7 @@ function evidenceOf(record, project, nowMs) {
|
|
|
145
214
|
// unclamped: it is only ever compared (exactly, in BigInt) with 1% of the wall, and a stall past the wall is above it
|
|
146
215
|
const memFullUsec = wallUsec !== null && isCount(r.memFullUsec) ? r.memFullUsec : null;
|
|
147
216
|
if (memPeak === null && cpu.cpuUsec === null) return null;
|
|
148
|
-
return { at, size, memPeak, oom: record.reason === "oom-killed", wallUsec, ...cpu, memFullUsec };
|
|
217
|
+
return { at, size, memPeak, oom: record.reason === "oom-killed", wallUsec, ...cpu, memFullUsec, seenCpus: seenOf(record) };
|
|
149
218
|
}
|
|
150
219
|
|
|
151
220
|
/** a x b > c x d, exactly. */
|
|
@@ -164,6 +233,25 @@ function heldToCap(wanted, current, cap) {
|
|
|
164
233
|
return { suggested: wanted, held: null };
|
|
165
234
|
}
|
|
166
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Issue #865: what CPU count the killed runs' tools counted at least, against their size, or null: over the
|
|
238
|
+
* `oom-killed` runs that decide a raise (the same ones `ooms` counts), those where the fewest CPUs their tools could
|
|
239
|
+
* count (`seenOf`) is more than their size rounded up, which is what `PI_JOB_SIZE_CPUS` tells them. At or below it nothing can be said:
|
|
240
|
+
* a pool sized from the variable may be the pool the ceiling gave. `{ runs, seenCpus, seenMaxCpus, sizeCenti,
|
|
241
|
+
* sizeVaries }`: how many such runs, the fewest and the most CPUs their tools counted at least (equal when every run
|
|
242
|
+
* gives the same bound), and the smallest CPU size among them and whether their sizes differ. Either can vary alone
|
|
243
|
+
* (one host's ceiling under projects of several sizes, or one size across hosts), and the words say a range for that
|
|
244
|
+
* one only.
|
|
245
|
+
*/
|
|
246
|
+
function oomCeilingOf(relevant) {
|
|
247
|
+
const over = relevant.filter((e) => e.oom && e.seenCpus !== null && e.seenCpus > wholeCpusOf(e.size.cpuCenti));
|
|
248
|
+
if (over.length === 0) return null;
|
|
249
|
+
const seen = over.map((e) => e.seenCpus);
|
|
250
|
+
const sizes = over.map((e) => e.size.cpuCenti);
|
|
251
|
+
const sizeCenti = Math.min(...sizes);
|
|
252
|
+
return { runs: over.length, seenCpus: Math.min(...seen), seenMaxCpus: Math.max(...seen), sizeCenti, sizeVaries: sizes.some((n) => n !== sizeCenti) };
|
|
253
|
+
}
|
|
254
|
+
|
|
167
255
|
function suggestMemory(runs, current, cap) {
|
|
168
256
|
const measured = runs.filter((e) => e.memPeak !== null);
|
|
169
257
|
const relevant = measured.filter((e) => e.size.memMiB >= current);
|
|
@@ -176,7 +264,7 @@ function suggestMemory(runs, current, cap) {
|
|
|
176
264
|
// at the limit (90% of it or more: peak x 10 >= limit x 9) AND stalled for memory more than 1% of the wall.
|
|
177
265
|
const pressured = relevant.filter((e) => !productAbove(e.size.memMiB * MIB, 9, e.memPeak, 10) && e.memFullUsec !== null && productAbove(e.memFullUsec, 100, e.wallUsec, 1)).length;
|
|
178
266
|
// `smaller`: the measured runs below the current size, which a chart draws and this verdict does not count.
|
|
179
|
-
const evidence = { samples, smaller: measured.length - samples, p95MiB: p95 === null ? null : Math.ceil(p95 / MIB), maxPeakMiB: maxPeak === null ? null : Math.ceil(maxPeak / MIB), ooms, largestOomMiB: largestOom, pressured };
|
|
267
|
+
const evidence = { samples, smaller: measured.length - samples, p95MiB: p95 === null ? null : Math.ceil(p95 / MIB), maxPeakMiB: maxPeak === null ? null : Math.ceil(maxPeak / MIB), ooms, largestOomMiB: largestOom, pressured, oomCeiling: oomCeilingOf(relevant) };
|
|
180
268
|
const fact = pressured > 0 ? "pressure" : null;
|
|
181
269
|
const out = (reason, suggested, more = {}) => ({ current, suggested: suggested === null || suggested === current ? null : suggested, reason, evidence, fact, held: null, wanted: null, ...more });
|
|
182
270
|
if (ooms > 0) {
|
|
@@ -419,12 +507,33 @@ export function notEnoughRunsText(dim, sizeText) {
|
|
|
419
507
|
return `not enough runs at ${sizeText(dim.current)} (${samples}${smaller})`;
|
|
420
508
|
}
|
|
421
509
|
|
|
510
|
+
/**
|
|
511
|
+
* Issue #865: the CPU count the killed runs' tools counted at least, in words, or "" when the evidence carries none
|
|
512
|
+
* (`oomCeilingOf`): `size per-CPU worker pools from PI_JOB_SIZE_CPUS: the killed run's tools counted at least 15 CPUs
|
|
513
|
+
* at a size of 2 CPUs`. "At least", since the count is a lower bound (`seenCpusOf`). A word of its own
|
|
514
|
+
* (`memoryCeiling`), not part of the OOM words: every surface prints it AFTER the suggestion, a held raise and a refusal
|
|
515
|
+
* (doctor ahead of its two facts; the console's card as the last of the suggestion's notes, before the card's advisory
|
|
516
|
+
* note, with the call's block below the notes; the panel and the confirm after their facts), so where a surface cuts a
|
|
517
|
+
* line (doctor publishes a label at most `DOCTOR_LABEL_MAX` long) the facts go first, then this, never the call, a held
|
|
518
|
+
* raise or a refusal. Short for the same cap, and the variable first, so a cut keeps the pointer as long as it can. A
|
|
519
|
+
* pointer, never a call: the raise is the same either way.
|
|
520
|
+
*/
|
|
521
|
+
function oomCeilingWords(me) {
|
|
522
|
+
const o = me?.oomCeiling;
|
|
523
|
+
if (o === null || typeof o !== "object" || !Number.isSafeInteger(o.runs) || o.runs < 1 || !Number.isSafeInteger(o.seenCpus)) return "";
|
|
524
|
+
const ooms = Number.isSafeInteger(me.ooms) && me.ooms >= o.runs ? me.ooms : o.runs;
|
|
525
|
+
const whose = ooms === 1 ? "the killed run's tools" : o.runs === ooms ? "the killed runs' tools" : `the tools of ${o.runs} of the ${ooms} killed runs`;
|
|
526
|
+
const seen = `${o.seenCpus}${Number.isSafeInteger(o.seenMaxCpus) && o.seenMaxCpus > o.seenCpus ? ` to ${o.seenMaxCpus}` : ""} CPUs`;
|
|
527
|
+
const size = o.sizeVaries === true ? `sizes from ${cpusText(o.sizeCenti)}` : `a size of ${cpusText(o.sizeCenti)}`;
|
|
528
|
+
return `size per-CPU worker pools from PI_JOB_SIZE_CPUS: ${whose} counted at least ${seen} at ${size}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
422
531
|
/**
|
|
423
532
|
* The evidence of a suggestion in words, for doctor, the panel and the insights page alike: `{ memory, cpu, memoryHeld,
|
|
424
|
-
* memoryFact, cpuFact }`, each a short clause (empty when it does not apply), such as `2 runs ended
|
|
425
|
-
* largest size killed 4g)` or `p95 peak 2560m, largest 3g, over 24 runs`. Ids and numbers only. A fact
|
|
426
|
-
* information and a held raise as what the host offers
|
|
427
|
-
* host's budget.
|
|
533
|
+
* memoryFact, cpuFact, memoryCeiling }`, each a short clause (empty when it does not apply), such as `2 runs ended
|
|
534
|
+
* oom-killed (the largest size killed 4g)` or `p95 peak 2560m, largest 3g, over 24 runs`. Ids and numbers only. A fact
|
|
535
|
+
* is worded as information and a held raise as what the host offers, and `memoryCeiling` (issue #865) points to a
|
|
536
|
+
* variable: none of them is a call, and none advises growing a host's budget.
|
|
428
537
|
*/
|
|
429
538
|
export function suggestionEvidence(suggestion) {
|
|
430
539
|
const m = suggestion?.memory ?? {};
|
|
@@ -457,5 +566,6 @@ export function suggestionEvidence(suggestion) {
|
|
|
457
566
|
memoryHeld: heldWords[m.held] ?? "",
|
|
458
567
|
memoryFact: m.fact === "pressure" ? `${me.pressured} of ${plural(me.samples, "run")} reached the memory limit while stalled for memory more than 1% of their time` : "",
|
|
459
568
|
cpuFact: c.fact === "ceiling" ? `the median run was held back ${ce.throttledPct}% of its time by this host's CPU ceiling (its CPU budget, shared by every job), which a job's cpus do not change` : "",
|
|
569
|
+
memoryCeiling: m.reason === "oom-killed" ? oomCeilingWords(me) : "",
|
|
460
570
|
};
|
|
461
571
|
}
|
package/src/touch-outbox.mjs
CHANGED
|
@@ -54,6 +54,11 @@ export async function trackTouchAction({ store, outbox = null, event, act, succe
|
|
|
54
54
|
const writer = outbox ?? { begin: (e) => store.begin(e), applied: (intent) => store.write(intent.event), abort: (intent) => store.abort(intent.event) };
|
|
55
55
|
const intent = await writer.begin(event);
|
|
56
56
|
let result;
|
|
57
|
+
// Aborted on any throw, also one whose answer was lost (`valkeyMayStillRun`, issue #867): a cancel's removal that then
|
|
58
|
+
// lands is a stop not counted, so the stops count can be one short (the rate is unaffected, since stops never enter
|
|
59
|
+
// its denominator). REJECTED: keeping the intent pending as unknown. Nothing resolves it (a rerun finds no job, the CLI
|
|
60
|
+
// has no outbox, `repair()` only begins it again), so the store reads `touch-actions-unresolved` with its coverage reset
|
|
61
|
+
// to now, the Zero touch page reads unavailable, and every later tracked action renews its 30-day TTL.
|
|
57
62
|
try { result = await act(); } catch (error) { await writer.abort(intent); throw error; }
|
|
58
63
|
if (!succeeded(result)) { await writer.abort(intent); return result; }
|
|
59
64
|
const tracked = await writer.applied(intent);
|