@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.
@@ -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
- * Value position only, and it is not a micro-optimisation: `readString` accumulates about 32 bytes of
79
- * heap per source character, so a single 64MB string value cost 2GB of garbage to decode a value this
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 ch = s[i];
87
- if (ch === '"') {
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
- i += ch === "\\" ? 2 : 1;
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
- if (esc === "u") {
111
- // A lone surrogate is preserved as itself, which is what JSON.parse does too, so a pair
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 out; // unreachable on text that parsed
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 = readString();
181
- if (top.keys.has(key)) return { key, at: [...path, key].join(".") };
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;
@@ -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 credential this deployment keeps for the job itself",
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",
@@ -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's credential a variable of its own."],
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."],
@@ -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",
@@ -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",
@@ -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
- const loaded = await (deps.deploymentEnv ?? sandboxDeploymentEnv)({ dir: values.deployment, env, expectedHost: values["expected-host"] });
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
- function runDeploymentSandbox(argv, env, dir) {
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 = spawn(process.execPath, [fileURLToPath(new URL("./cli.mjs", import.meta.url)), "sandbox", ...argv], { env, ...(dir ? { cwd: dir } : {}), stdio: "inherit" });
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
- /** An explicit deployment handoff is stronger than a generic deployment pointer: it loads sandbox authority only. */
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
- if (resolved.unread?.length || resolved.hazardSkipped?.length) return refuse("a sandbox setting cannot be read the way the deployment service reads it; run pi-dispatch doctor");
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");
@@ -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
@@ -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`, `unreadable` (a reason, when something is at the path and could not be read) and
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
  /**
@@ -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 oom-killed (the
425
- * largest size killed 4g)` or `p95 peak 2560m, largest 3g, over 24 runs`. Ids and numbers only. A fact is worded as
426
- * information and a held raise as what the host offers: none of them is an instruction, and none advises growing a
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
  }
@@ -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);