@edgehero/pi-dispatch 5.1.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.
@@ -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);
@@ -58,7 +58,7 @@ export function defaultValkeyContext() {
58
58
  }
59
59
 
60
60
  /**
61
- * Where a client stands (issue #464, gate round 3): `{ envPath, shared, rootRefused, error, password }`.
61
+ * Where a client stands (issue #464, gate round 3): `{ envPath, shared, rootRefused, error, absent, password, url }`.
62
62
  *
63
63
  * The owner decision itself does not depend on this (gate round 3's simpler rule): on Linux EVERY client refuses a
64
64
  * listener held by a uid that is neither this account's euid, nor one of its subordinate uids, nor root, from any cwd.
@@ -86,10 +86,12 @@ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), pl
86
86
  const envPath = join(cwd, ".env");
87
87
  let content = null;
88
88
  let error = null;
89
+ let absent = false;
89
90
  try {
90
91
  content = readEnv(envPath);
91
92
  } catch (err) {
92
- if (err?.code !== "ENOENT") error = `${envPath} could not be read (${err?.code ?? err?.message})`;
93
+ if (err?.code === "ENOENT") absent = true;
94
+ else error = `${envPath} could not be read (${err?.code ?? err?.message})`;
93
95
  }
94
96
  const fileKeys = {};
95
97
  if (content !== null) {
@@ -104,7 +106,7 @@ export function valkeyClientContext({ env = process.env, cwd = process.cwd(), pl
104
106
  if (keys.error) error ??= keys.error;
105
107
  else for (const k of [VALKEY_SHARED_KEY, VALKEY_PASSWORD_KEY, "VALKEY_URL"]) if (typeof keys.keys[k] === "string") fileKeys[k] = keys.keys[k];
106
108
  }
107
- return valkeyContextFromKeys({ env, envPath, fileKeys, error, platform, rootRefused });
109
+ return valkeyContextFromKeys({ env, envPath, fileKeys, error, absent, platform, rootRefused });
108
110
  }
109
111
 
110
112
  /**
@@ -138,9 +140,10 @@ export const VALKEY_CONTEXT_KEYS = Object.freeze(["VALKEY_URL", VALKEY_PASSWORD_
138
140
  * A context from keys a caller has already read out of the deployment's `.env` (`fileKeys`, the `VALKEY_CONTEXT_KEYS`
139
141
  * it supplied) and this process's environment. `valkeyClientContext` reads the file itself; the admin panel passes what
140
142
  * issue #471's resolver read, so the panel has one `.env` reader. `error` is a reason the file could not be read, as
141
- * `valkeyClientContext`'s.
143
+ * `valkeyClientContext`'s. `absent` (issue #845) is true when nothing is at `envPath`: no file, which is not a file that
144
+ * sets nothing, so a client that reads its password again at each connect keeps the one it has (`freshPassword`).
142
145
  */
143
- export function valkeyContextFromKeys({ env = process.env, envPath = null, fileKeys = {}, error = null, platform = process.platform, rootRefused } = {}) {
146
+ export function valkeyContextFromKeys({ env = process.env, envPath = null, fileKeys = {}, error = null, absent = false, platform = process.platform, rootRefused } = {}) {
144
147
  const fileVenue = typeof fileKeys.PI_BACKENDS === "string" ? { PI_BACKENDS: fileKeys.PI_BACKENDS } : {};
145
148
  const shared = valkeySharedOn(fileKeys[VALKEY_SHARED_KEY]);
146
149
  const filePassword = fileKeys[VALKEY_PASSWORD_KEY] || null;
@@ -156,7 +159,7 @@ export function valkeyContextFromKeys({ env = process.env, envPath = null, fileK
156
159
  const engine = typeof env.PI_OPENSHELL_ENGINE === "string" ? env.PI_OPENSHELL_ENGINE : fileKeys.PI_OPENSHELL_ENGINE;
157
160
  const derived = platform === "linux" && ((venues.podmanUsed && !venues.localUsed) || openshellSetupOf(venues, { PI_OPENSHELL_ENGINE: engine }).valkeyEngine === "podman");
158
161
  const envUrl = typeof env.VALKEY_URL === "string" ? env.VALKEY_URL : null;
159
- return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } };
162
+ return { envPath, shared, rootRefused: typeof rootRefused === "boolean" ? rootRefused : derived, error, absent: absent === true, password: { environment: envPassword, file: filePassword }, url: { environment: envUrl, file: fileUrl } };
160
163
  }
161
164
 
162
165
  /**