@edgehero/pi-dispatch 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: go from one AI coding agent to many in the background, running the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
@@ -24,7 +24,7 @@
24
24
  * job's attempt count.
25
25
  */
26
26
 
27
- import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
27
+ import { CANCEL_REQ_TTL_MS, cancelAckKey, cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
28
28
 
29
29
  /** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
30
30
  const FLEET_READ_TIMEOUT_MS = 2_000;
@@ -42,11 +42,43 @@ export function boundedText(text) {
42
42
  return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
43
43
  }
44
44
 
45
+ /** Whether `error` is Valkey's own refusal (a reply), as against no answer at all: the two are different sentences. */
46
+ function refusedByValkey(error) {
47
+ return error?.name === "ReplyError";
48
+ }
49
+
50
+ /** What went wrong, as the start of a line: Valkey refused a command (it answered), or it did not answer. */
51
+ function valkeyFault(error, shown) {
52
+ const command = typeof error?.command?.name === "string" ? error.command.name.toUpperCase() : "a command";
53
+ return refusedByValkey(error) ? `Valkey at ${shown} refused ${boundedText(command)}: ${boundedText(error?.message)}` : `Valkey at ${shown} stopped answering (${boundedText(error?.message)})`;
54
+ }
55
+
56
+ /**
57
+ * The job's state, read under the same bound as every other command here: `{ state }`, or `{ unread }` with why it could
58
+ * not be read. Issue #846: a read that failed was `"unknown"`, which is also what BullMQ says of a job that left its
59
+ * queue, so a job still running was reported as gone "and nothing was changed".
60
+ */
61
+ async function boundedState(job, timeoutMs) {
62
+ let timer;
63
+ try {
64
+ return await Promise.race([
65
+ Promise.resolve(job.getState()).then((state) => ({ state })),
66
+ new Promise((resolve) => {
67
+ timer = setTimeout(() => resolve({ unread: `no answer within ${Math.round(timeoutMs / 1000)}s` }), timeoutMs);
68
+ }),
69
+ ]);
70
+ } catch (error) {
71
+ return { unread: error?.message ?? String(error) };
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
45
77
  /**
46
78
  * Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
47
79
  * so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
48
80
  */
49
- export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now } = {}) {
81
+ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now, commandTimeoutMs = null, closeTimeoutMs = 1000 } = {}) {
50
82
  const fail = (message) => {
51
83
  errWrite(`error: ${message}\n`);
52
84
  return 1;
@@ -55,7 +87,9 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
55
87
  return fail("a job id is required: pi-dispatch cancel <jobId> (ids are in `pi-dispatch status`, the panel, and the run log)");
56
88
  }
57
89
  // Lazy imports, the pause verb's shape: a mistyped id must not load bullmq before it can be refused.
58
- const { parseConnection, makeRedisClient } = await import("./connection.mjs");
90
+ const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS } = await import("./connection.mjs");
91
+ // The bound on each command and on the state reads: the console's, unless a test asks for another.
92
+ const bound = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : VALKEY_COMMAND_TIMEOUT_MS;
59
93
  const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
60
94
  const { readLiveHosts } = await import("./host-registry.mjs");
61
95
  const parseConn = parseConnectionFn ?? parseConnection;
@@ -83,10 +117,14 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
83
117
  });
84
118
  const refused = await refusalOf(url);
85
119
  if (refused) return fail(refused);
86
- const probe = mkRedis(url);
120
+ // BOUNDED (issue #846): each command waits at most VALKEY_COMMAND_TIMEOUT_MS for its answer, and one that timed out is
121
+ // never sent again. The unbounded client this was waited for as long as Valkey was gone: killed during the ack poll it
122
+ // printed nothing until the operator gave up, and on a partition its DEL never reached the catch below.
123
+ const probe = mkRedis(url, { commandTimeoutMs: bound, disconnectTimeoutMs: 0 });
87
124
  probe.on?.("error", () => {});
88
125
  const queues = [];
89
126
  let requested = false;
127
+ let requestedAt = null;
90
128
  try {
91
129
  // The job could sit on the shared queue or on any host's own (issue #57): find it the way pause
92
130
  // spans them, and fail OPEN but LOUDLY on a degraded registry read, exactly as the kill switch does.
@@ -138,6 +176,12 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
138
176
  return 0;
139
177
  }
140
178
  const state = queued.state;
179
+ // Issue #846: `removeQueuedJob` reads a state it could not read as "unknown" (the console's sequence shares it), which
180
+ // BullMQ also says of a job that left its queue. Asked again under the bound, so a read that failed says so.
181
+ if (state === "unknown") {
182
+ const again = await boundedState(job, bound);
183
+ if (again.unread !== undefined) return fail(`could not read the state of ${jobId} (${boundedText(again.unread)}), so whether it is running is not known; nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
184
+ }
141
185
  // The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
142
186
  // the state is re-read and an active job falls through to the active path rather than failing an operator whose
143
187
  // job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
@@ -147,7 +191,8 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
147
191
 
148
192
  if (state === "active") {
149
193
  requested = true;
150
- const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, ...(sleep ? { sleep } : {}) });
194
+ requestedAt = now();
195
+ const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, now, onWritten: () => (requestedAt = now()), ...(sleep ? { sleep } : {}) });
151
196
  if (res.ack !== undefined) {
152
197
  write(`cancel accepted by ${res.ack === "" ? "the worker" : boundedText(res.ack)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
153
198
  return 0;
@@ -155,8 +200,11 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
155
200
  // Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
156
201
  // lands in that moment is never acknowledged and the job is back in the queue. Re-read, so the words are true.
157
202
  // Gate round 3: by the state, since a job that finished or left the queue cannot be removed by a second cancel.
158
- const after = await job.getState().catch(() => "unknown");
203
+ const read = await boundedState(job, bound);
159
204
  const waited = `no worker acknowledged within ${Math.round(ackTimeoutMs / 1000)}s`;
205
+ // Issue #846: a state that could not be read is said as that, never as BullMQ's "unknown" (a job that left).
206
+ if (read.unread !== undefined) return fail(`${waited}, and the job's state could not be read (${boundedText(read.unread)}), so whether it still runs is not known. The cancel request was withdrawn, so nothing was changed: run \`pi-dispatch status\`, and \`pi-dispatch cancel ${jobId}\` again if it still runs`);
207
+ const after = read.state;
160
208
  if (after === "waiting" || after === "delayed" || after === "prioritized" || after === "paused") return fail(`${waited}: the job went back to the queue before its worker read the cancel (it is now ${after}), and nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again to remove it`);
161
209
  if (after === "completed" || after === "failed") return fail(`${waited}: the job finished (${after}) before its worker read the cancel, and nothing was changed`);
162
210
  if (after !== "active") return fail(`${waited}: the job is no longer in the queue (its state reads ${after}), and nothing was changed`);
@@ -170,18 +218,58 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
170
218
  // del would sit in ioredis's retry queue and hold this process open long past the error message.
171
219
  // The bound timer is CLEARED when the del wins the race (discoverHostQueues' own rule): cli.mjs
172
220
  // sets exitCode rather than exiting, so a stray pending timer is a second of pure hang.
221
+ // What the withdrawing DEL ANSWERED decides the sentence (issue #846, the second adversary round): 1, it was taken back;
222
+ // 0, it was already gone, because its worker read it (and may have acknowledged) or it expired; no answer, not known.
223
+ // Reading "answered" as "withdrawn" said "nothing was changed" of a cancel its worker had taken and acted on, when a
224
+ // poll was lost to a Valkey that died and came back within the bound.
225
+ const within = (promise, ms) => {
226
+ let timer;
227
+ return Promise.race([Promise.resolve(promise).then((value) => ({ value }), (error) => ({ lost: true, error })), new Promise((r) => (timer = setTimeout(() => r({ lost: true }), ms)))]).finally(() => clearTimeout(timer));
228
+ };
229
+ let withdrawal = null;
230
+ let acked = null;
173
231
  if (requested) {
174
- let bound;
175
- await Promise.race([
176
- Promise.resolve(probe.del?.(cancelReqKey(jobId))).catch(() => {}),
177
- new Promise((r) => (bound = setTimeout(r, 1000))),
178
- ]);
179
- clearTimeout(bound);
232
+ withdrawal = await within(probe.del?.(cancelReqKey(jobId)), 1000);
233
+ if (withdrawal.value !== undefined && Number(withdrawal.value) === 0) {
234
+ const ack = await within(probe.get?.(cancelAckKey(jobId)), 1000);
235
+ if (typeof ack.value === "string") acked = ack.value;
236
+ }
180
237
  }
181
- return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
238
+ if (acked !== null) {
239
+ write(`cancel accepted by ${acked === "" ? "the worker" : boundedText(acked)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
240
+ return 0;
241
+ }
242
+ if (error?.valkeyRefused) return fail(error.message);
243
+ const shown = (await import("./connection.mjs")).urlShown(url);
244
+ // Issue #846: after the request went out, what is said is what became of it. The old line said only "could not reach
245
+ // Valkey ... is it running?", also when Valkey answered with a refusal, and never that a request may still stand.
246
+ if (requested && error?.valkeyMayStillRun) {
247
+ const removed = withdrawal?.value !== undefined ? Number(withdrawal.value) : null;
248
+ if (removed !== null && removed > 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written. The request was withdrawn again, so nothing was changed, unless its worker read it in that moment: run \`pi-dispatch status\` to see whether ${jobId} still runs`);
249
+ if (removed === 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and the request was gone when the CLI went to withdraw it: its worker may have read it, or it expired, and no acknowledgment was found. Run \`pi-dispatch status\` to see whether ${jobId} still runs`);
250
+ const until = new Date((requestedAt ?? now()) + CANCEL_REQ_TTL_MS).toISOString();
251
+ const then = refusedByValkey(error) || refusedByValkey(withdrawal?.error) ? "run `pi-dispatch status` to see whether it did" : "run `pi-dispatch status` once Valkey answers to see whether it did";
252
+ return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and its withdrawal was not answered, so it may stand until ${until}, ${Math.round(CANCEL_REQ_TTL_MS / 1000)}s after it was written: a worker that reads it before then stops the job. ${then.charAt(0).toUpperCase()}${then.slice(1)}`);
253
+ }
254
+ if (refusedByValkey(error)) return fail(`${valkeyFault(error, shown)}; nothing was changed`);
255
+ return fail(`could not reach Valkey at ${shown}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
182
256
  } finally {
183
257
  probe.disconnect?.();
184
- for (const q of queues) await q.close().catch(() => {});
258
+ // BOUNDED (issue #846): `close()` waits for the answer to QUIT, so on a Valkey that stopped answering the verb could hold
259
+ // its process after its last line. Past the bound the queue is dropped (`disconnect`, no QUIT; ioredis may still take
260
+ // its own disconnect timeout to let the socket go), which nothing here needs an answer to.
261
+ await Promise.all(queues.map((q) => closeWithin(q, closeTimeoutMs)));
185
262
  }
186
263
  }
187
264
 
265
+ /** Close `queue`, or drop its connection when the close is not answered within `ms`. Never throws. */
266
+ async function closeWithin(queue, ms) {
267
+ let timer;
268
+ const closed = await Promise.race([
269
+ Promise.resolve(queue.close()).then(() => true, () => true),
270
+ new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms))),
271
+ ]);
272
+ clearTimeout(timer);
273
+ if (!closed) await Promise.resolve(queue.disconnect?.()).catch(() => {});
274
+ }
275
+
@@ -86,12 +86,18 @@ export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
86
86
  * already own a could-not-reach-Valkey message, and swallowing here would turn an unreachable Valkey into
87
87
  * a lying "no worker acknowledged".
88
88
  *
89
- * `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
89
+ * `pollMs`/`sleep` are injectable so tests never wait on a wall clock. The wait ends at `ackTimeoutMs` by whichever runs
90
+ * out first: the sleeps it took, or the time `now` says has passed (issue #846). Counting sleeps alone, a GET that waited
91
+ * out a Valkey restart in the client's queue was not counted at all, so a 10 s wait could last as long as the outage did.
90
92
  */
91
- export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
93
+ export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onWritten = null }) {
92
94
  const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
93
95
  const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
94
96
  await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
97
+ // When the request's TTL started, for a caller that names until when it may stand (the CLI, issue #846), and the start
98
+ // of the wait for its acknowledgment, so a slow SET is not counted against it.
99
+ onWritten?.();
100
+ const deadline = now() + ackTimeoutMs;
95
101
  for (let waited = 0; ; waited += pollMs) {
96
102
  let host;
97
103
  try {
@@ -102,7 +108,7 @@ export async function requestCancel({ redis, jobId, interventionOrigin = "human"
102
108
  throw err;
103
109
  }
104
110
  if (host !== null && host !== undefined) return { ack: host };
105
- if (waited + pollMs > ackTimeoutMs) break;
111
+ if (waited + pollMs > ackTimeoutMs || now() >= deadline) break;
106
112
  await sleep(pollMs);
107
113
  }
108
114
  // Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
@@ -250,6 +250,13 @@ class BoundedRedis extends Redis {
250
250
  }
251
251
  }
252
252
 
253
+ /**
254
+ * How long one command of a short-lived client may wait for its answer (`commandTimeoutMs`), in the offline queue or on a
255
+ * Valkey that stopped answering: the console's own clients wait as long, and `pi-dispatch cancel` (issue #846) takes it
256
+ * from here, so a cancel against a Valkey that went away ends with a sentence rather than waiting as long as it is gone.
257
+ */
258
+ export const VALKEY_COMMAND_TIMEOUT_MS = 5_000;
259
+
253
260
  /**
254
261
  * A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
255
262
  * `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
@@ -49,7 +49,7 @@ export function parseSecretBundleProfilesSafe(raw) {
49
49
  * - `realExecutablePath`: the resolver's own probe (secrets.mjs `realExecutable`), or null when pi did not load;
50
50
  * - `resolveBundle`: `(binding) => resolve result`, only with `--secrets`.
51
51
  */
52
- export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
52
+ export async function secretBundleChecks({ bundles = {}, triggersPresent = true, projects = [], projectsError = null, projectsUnread = false, envRaw, overlayBundles = {}, roots = [], realExecutablePath = null, resolveBundle = null } = {}) {
53
53
  const checks = [];
54
54
  const { bundles: envBundles, error } = parseSecretBundleProfilesSafe(envRaw);
55
55
  if (error) {
@@ -91,7 +91,11 @@ export async function secretBundleChecks({ bundles = {}, triggersPresent = true,
91
91
  } else if (!fromEnv && !Object.hasOwn(usableOverlay, b.profile)) {
92
92
  checks.push({ ok: false, label: `secret bundle ${id} names profile ${b.profile}, declared in the settings overlay but not usable`, fix: roots.length === 0 ? "PI_SECRET_RESOLVER_ROOTS is unset, so the worker uses no overlay-declared resolver: set it, or declare the profile in PI_SECRET_BUNDLES" : "the resolver must be an executable file whose real path sits inside PI_SECRET_RESOLVER_ROOTS" });
93
93
  }
94
- if (projectsError !== null) {
94
+ if (projectsUnread) {
95
+ // Issue #859: the `.env` line naming the projects file could not be read (its own line says why), so whether the
96
+ // service's file holds the project is not known: a warning, never the ✗ a file with no such project earns.
97
+ checks.push({ ok: false, warn: true, label: `secret bundle ${id} names project ${b.project}, and whether the projects file the service reads holds it is not known: doctor could not read what the deployment .env sets for PI_PROJECTS_FILE`, fix: "fix what the boot-file line above names, then re-run doctor" });
98
+ } else if (projectsError !== null) {
95
99
  checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, and projects.json does not load`, fix: "fix projects.json (its own line above says why) -- a bundle applies to a project's jobs only once the worker knows the project" });
96
100
  } else if (!projectIds.has(b.project)) {
97
101
  checks.push({ ok: false, label: `secret bundle ${id} names project ${b.project}, which projects.json does not hold`, fix: "add the project to PI_PROJECTS_FILE, or correct the bundle's project -- until then the bundle reaches no job, while the file reads as granting it" });
@@ -77,7 +77,7 @@ export function cheaperRouteChecks(roles, routes, models, endpoints, builtin = g
77
77
  return checks;
78
78
  }
79
79
 
80
- export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints } = {}) {
80
+ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, now = Date.now, read = safeRead, fetch = fetchLifecycle, endpoints: suppliedEndpoints, declaredEndpoints = null } = {}) {
81
81
  let config;
82
82
  try { config = parseProposalConfig(read(join(cwd, "model-proposals.json"))); }
83
83
  catch (error) {
@@ -102,7 +102,9 @@ export async function doctorModelProposalChecks(env, { cwd, credentials = {}, no
102
102
  if (config.routes?.length) try {
103
103
  const parsed = parseModelsJson(read(join(env.PI_GLOBAL_PI_DIR, "models.json")));
104
104
  if (parsed.error) throw new Error("overlay unavailable");
105
- const endpoints = suppliedEndpoints ?? loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd });
105
+ // Issue #859: the endpoints as doctor's other endpoint checks read them (`declaredEndpointsIn`: this shell's file, else
106
+ // the deployment `.env`'s), handed in and asked only here; this shell's alone when a caller hands none.
107
+ const endpoints = suppliedEndpoints ?? (typeof declaredEndpoints === "function" ? declaredEndpoints() : loadModelEndpoints({ modelEndpointsFile: env.PI_MODEL_ENDPOINTS_FILE }, { cwd }));
106
108
  checks.push(...cheaperRouteChecks(roles, config.routes, parsed.value, endpoints));
107
109
  } catch { checks.push({ proposal: { kind: "unavailable" }, ok: false, warn: true, label: "Model proposals: declared route prices or endpoints unavailable", fix: "check the overlay models.json and declared endpoints; no route was changed" }); }
108
110
  return checks;
package/src/doctor.mjs CHANGED
@@ -390,7 +390,8 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
390
390
  // Measured: a CONTAINERS_CONF in it naming a containers.conf with a `conmon_path` made doctor's own `podman info` run
391
391
  // that program. So from such a file no value is taken at all (the venue keys included), and that is a ✗: the service's
392
392
  // loader has no such rule, so the deployment runs on a file someone else chose. The read above still happens, for the
393
- // two-subject pause and limits reading (`envFileKeys`), which only decides what doctor SAYS.
393
+ // BOOT_FILES lines' two-subject reading (`envFileKeys`), which only decides what doctor SAYS; from such a file
394
+ // `bootFileEnv` takes nothing (issue #859, `envUntrusted`), so the other checks judge this shell's values as the ✗ says.
394
395
  const envUntrusted = envText !== null ? envRead.untrusted : null;
395
396
  const envValues = envUntrusted ? null : envText;
396
397
  let envTextUnused = envText !== null;
@@ -454,7 +455,7 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
454
455
  return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from };
455
456
  }
456
457
  : null;
457
- const seams = { setupReadOnly: setupReadOnly === true, ...(subscriptionFactory ? { subscriptionFactory } : {}), ...(modelProposals ? { modelProposals } : {}), secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(readDollarRecords ? { readDollarRecords } : {}), ...(readDollarCounters ? { readDollarCounters } : {}), ...(olderStateFindings ? { olderStateFindings } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, flowFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
458
+ const seams = { setupReadOnly: setupReadOnly === true, ...(subscriptionFactory ? { subscriptionFactory } : {}), ...(modelProposals ? { modelProposals } : {}), secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(readDollarRecords ? { readDollarRecords } : {}), ...(readDollarCounters ? { readDollarCounters } : {}), ...(olderStateFindings ? { olderStateFindings } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, flowFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), ...(envUntrusted ? { envUntrusted: true } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
458
459
  // Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the
459
460
  // same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is
460
461
  // handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env`
@@ -758,11 +759,13 @@ function asText(content, enc) {
758
759
  * The values `<cwd>/.env` sets for NAMED keys, or `{}`. Doctor's first read of a `.env` (issue #357), and the
759
760
  * narrowing is what makes it an addition rather than a reversal. It is NOT the only reader: `up` and `service
760
761
  * install` read the venue keys since #430 (`readStackKeys`), and since #453 doctor reads `SERVICE_ENV_KEYS` below,
761
- * whose values DO steer what doctor spawns, connects to and offers as fixes. For these two keys:
762
+ * whose values DO steer what doctor spawns, connects to and offers as fixes. For these five keys:
762
763
  *
763
764
  * - it reads to decide WHAT DOCTOR SAYS, never to configure anything. No value from here reaches a
764
765
  * config, an argv, a container env, or a fix that writes;
765
- * - the caller passes the exact keys its own message names, so this cannot grow into "load .env";
766
+ * - the keys are the five frozen here and a caller's list can only narrow them (`envFileKeys`), so this cannot grow
767
+ * into "load .env". Since issue #859 what is read for them also reaches the checks that read those files
768
+ * (`bootFileEnv`), still only to decide what doctor says and whether it fails;
766
769
  * - it is best effort, and it says WHICH kind of nothing it has. A file that is not there is `{}` and
767
770
  * changes no message. Something that is there and cannot be read -- a directory, a pipe, a mode this
768
771
  * account cannot open -- is `{ unreadable: true }`, which gets a line of its own, because "the key is
@@ -1154,7 +1157,7 @@ export function envFileKeys(path, keys, { fileExists, readEnvFile, statFile = st
1154
1157
  }
1155
1158
  // The narrowing is STRUCTURAL rather than a convention the caller keeps. A caller's key list can only
1156
1159
  // narrow this further, never widen it: the licence for reading a `.env` at all is that it decides what
1157
- // two named checks SAY, and "the caller passes the right keys" is the kind of rule that holds until the
1160
+ // doctor SAYS about five named files, and "the caller passes the right keys" is the kind of rule that holds until the
1158
1161
  // third check wants the same softening and adds its own key to an array. A `.env` also holds
1159
1162
  // `WEBHOOK_SECRET` and provider keys, and `up.mjs` states the standard for those: a webhook secret in a
1160
1163
  // scrollback is a webhook secret in a pastebin. Add a key here and the addition is the review.
@@ -1255,6 +1258,107 @@ export function envFileKeys(path, keys, { fileExists, readEnvFile, statFile = st
1255
1258
  }
1256
1259
  }
1257
1260
 
1261
+ /**
1262
+ * The boot-file keys `bootFileEnv` resolves for doctor's other checks: every `ENV_FILE_READABLE_KEYS` key but
1263
+ * `PI_MODEL_ENDPOINTS_FILE`, whose checks keep their own rule (`endpointsFileSetting`: this shell's, else the `.env`'s,
1264
+ * and a disagreement is no endpoints), read where they are used.
1265
+ */
1266
+ export const BOOT_FACT_KEYS = Object.freeze(ENV_FILE_READABLE_KEYS.filter((key) => key !== "PI_MODEL_ENDPOINTS_FILE"));
1267
+
1268
+ /**
1269
+ * On the environment `bootFileEnv` returns, the keys whose `.env` value doctor could not read while this shell sets none
1270
+ * (a hazard in the file, a line not in the plain form, a file it could not open). A fact reader says such a file is not
1271
+ * known (`bootKeyUnread`), never that it holds nothing: "no projects" there FAILED a bundle bound to a project, issue
1272
+ * #859's own false ✗ reached another way. A symbol, so no scan of `env.NAME` reads mistakes it for a key.
1273
+ */
1274
+ export const UNREAD_BOOT_KEYS = Symbol("boot-file keys whose .env value could not be read");
1275
+
1276
+ /** Whether `key` is one whose `.env` value doctor could not read (`UNREAD_BOOT_KEYS`). */
1277
+ function bootKeyUnread(env, key) {
1278
+ return env?.[UNREAD_BOOT_KEYS]?.has(key) === true;
1279
+ }
1280
+
1281
+ /**
1282
+ * Issue #859: the boot-file keys (`BOOT_FACT_KEYS`) as doctor's other checks judge them, from this shell and from the one
1283
+ * reading of the deployment `.env` that `envFileKeys` made. Until this, the checks that read the projects, the scoped
1284
+ * limits, the pause windows and the envelope took these keys from this shell alone, and the shell a service deployment's
1285
+ * operator runs doctor from sets none of them: doctor judged a deployment with no projects, FAILED every secret bundle
1286
+ * bound to one as naming a project projects.json does not hold, left the size suggestions out without a word, and
1287
+ * compared "no projects" with healthy peers. Only the BOOT_FILES lines read the file, and they kept what they read.
1288
+ *
1289
+ * These keys stay outside `resolveDoctorEnv` (the BOOT_FILES lines judge two subjects, the service's file and this
1290
+ * shell, and a test pins `SERVICE_ENV_KEYS`), but the precedence is that resolver's: this shell's value where it sets
1291
+ * one, else the file's, and where both are set and name different files that is a ✗ (`differs`), as a disagreement
1292
+ * about any other service key is (`serviceEnvLines`), since the checks then judge files the service does not read. A
1293
+ * key set in this shell alone, beside a `.env` that could be read and does not set it, joins the #471 shell-only line
1294
+ * (`shellOnly`). A BLANK value in this shell has its own ✗ in the BOOT_FILES block and the checks then judge the
1295
+ * service's file. Paths are resolved against the deployment folder, where the service runs and where `loadVerdict`
1296
+ * resolves them, and compared resolved (case-folded on win32).
1297
+ *
1298
+ * What the file gives, and when it gives nothing:
1299
+ * - a value only where `envFileKeys` vouches for its line (plain, no hazard in the file);
1300
+ * - nothing from a `.env` another account can write (`untrusted`): doctor says it took no value from it, and it takes
1301
+ * none here either, so the checks judge this shell's values as that line says;
1302
+ * - "not known" (`UNREAD_BOOT_KEYS`) for a key this shell does not set when the file has a hazard, the key's line is
1303
+ * not in the plain form, or the file could not be read: the BOOT_FILES lines name why, and the checks say the file
1304
+ * is not known rather than empty.
1305
+ *
1306
+ * Read to decide what doctor SAYS, the licence `ENV_FILE_READABLE_KEYS` gives: what this returns goes to the fact readers
1307
+ * and the load verdicts and nowhere else. Returns `{ env, service, differs, shellOnly }`: `env` is `shell` with these keys
1308
+ * as the checks judge them (and `UNREAD_BOOT_KEYS`); `service` the same with the keys as the SERVICE reads them, from the
1309
+ * file alone, or this shell's where the file's line cannot be read (the load verdict of the service's own subject, whose
1310
+ * envelope is judged against the service's projects and limits); `differs` `[{ key, shell, file }]`; `shellOnly` keys.
1311
+ */
1312
+ export function bootFileEnv(shell, envFile, cwd, platform = process.platform, { untrusted = false } = {}) {
1313
+ const win = platform === "win32";
1314
+ const at = (path) => (win ? win32.resolve(cwd, path) : posix.resolve(cwd, path));
1315
+ // ONE FILE NAMED TWO WAYS IS NO DIFFERENCE: where both are there, by what they are (a symlinked folder, `/tmp` and
1316
+ // `/private/tmp` on a Mac, a case-insensitive disk), else by the resolved text, case-folded on Windows.
1317
+ const identity = (path) => {
1318
+ try {
1319
+ const st = statSync(at(path));
1320
+ return `${st.dev}:${st.ino}`;
1321
+ } catch {
1322
+ return null;
1323
+ }
1324
+ };
1325
+ const same = (a, b) => {
1326
+ const ia = identity(a);
1327
+ const ib = ia === null ? null : identity(b);
1328
+ if (ia !== null && ib !== null) return ia === ib;
1329
+ return win ? at(a).toLowerCase() === at(b).toLowerCase() : at(a) === at(b);
1330
+ };
1331
+ // The SERVICE's subject reads the file whoever can write it, as the BOOT_FILES lines do (they only decide what doctor
1332
+ // says); the checks take nothing from an untrusted one.
1333
+ const whole = envFile ?? {};
1334
+ const file = untrusted ? {} : whole;
1335
+ const out = { env: { ...shell }, service: { ...shell }, differs: [], shellOnly: [], serviceUnread: new Set() };
1336
+ const unread = new Set();
1337
+ for (const key of BOOT_FACT_KEYS) {
1338
+ const own = typeof shell[key] === "string" && shell[key].trim() !== "" ? shell[key] : undefined;
1339
+ const lineOf = (f) => (f.hazard == null && typeof f[key] === "string" && f[key] !== "" ? f[key] : undefined);
1340
+ const unreadIn = (f) => f.unreadable === true || f.hazard != null || f.notPlain?.[key] !== undefined || f.insideValue?.[key] !== undefined;
1341
+ const raw = lineOf(file);
1342
+ const cannotRead = unreadIn(file);
1343
+ const sets = raw !== undefined || cannotRead || file.exported?.[key] !== undefined || file.blankInFile?.[key] === true;
1344
+ const serviceRaw = lineOf(whole);
1345
+ if (serviceRaw !== undefined) out.service[key] = at(serviceRaw);
1346
+ else if (unreadIn(whole)) out.serviceUnread.add(key);
1347
+ else delete out.service[key];
1348
+ if (own !== undefined) {
1349
+ out.env[key] = at(own);
1350
+ if (raw !== undefined && !same(own, raw)) out.differs.push({ key, shell: own, file: raw });
1351
+ else if (!sets && !untrusted && Object.keys(file).length > 0) out.shellOnly.push(key);
1352
+ } else if (raw !== undefined) {
1353
+ out.env[key] = at(raw);
1354
+ } else if (cannotRead) {
1355
+ unread.add(key);
1356
+ }
1357
+ }
1358
+ out.env[UNREAD_BOOT_KEYS] = unread;
1359
+ return out;
1360
+ }
1361
+
1258
1362
  /**
1259
1363
  * The files a worker LOADS at boot, and the one place that knows what each is and how to ask. The model endpoints file
1260
1364
  * (issue #503) defaults to the deployment folder's copy where the other three are off when unset.
@@ -1372,6 +1476,19 @@ export function bootFileId(spec, subject = "service") {
1372
1476
  return `${spec.scaffold.replace(/\.json$/, "")}.file${subject === "shell" ? "-shell" : ""}`;
1373
1477
  }
1374
1478
 
1479
+ /**
1480
+ * Whether `path` is a regular file or nothing at all, by the real `statSync`: a FIFO or a device is neither, and a
1481
+ * synchronous read of a FIFO never returns. A stat that fails (absent, or a path only a seam knows) is left to the
1482
+ * loader, which says so in its own words.
1483
+ */
1484
+ function regularOrAbsent(path) {
1485
+ try {
1486
+ return statSync(path).isFile();
1487
+ } catch {
1488
+ return true;
1489
+ }
1490
+ }
1491
+
1375
1492
  /** A key's value when it is a non-empty string, else null: the boot reads an unset key as off. */
1376
1493
  function nonEmpty(value) {
1377
1494
  return typeof value === "string" && value.trim() !== "" ? value : null;
@@ -1396,8 +1513,13 @@ function envelopeContextOf(env, io = {}) {
1396
1513
  return [];
1397
1514
  }
1398
1515
  };
1399
- const projects = quiet(() => loadProjects({ projectsFile: nonEmpty(env.PI_PROJECTS_FILE) }, io));
1400
- const limits = quiet(() => loadScopedLimits({ scopedLimitsFile: nonEmpty(env.PI_SCOPED_LIMITS_FILE) }, io));
1516
+ // A FIFO or a device named here is none, never a read (issue #859): `loadVerdict` guards the envelope's own path, and
1517
+ // these two are read inside the envelope's load, from the `.env` since #859 as well as from this shell.
1518
+ const guarded = (path) => (path !== null && !regularOrAbsent(path) ? () => [] : null);
1519
+ const projectsFile = nonEmpty(env.PI_PROJECTS_FILE);
1520
+ const limitsFile = nonEmpty(env.PI_SCOPED_LIMITS_FILE);
1521
+ const projects = quiet(guarded(projectsFile) ?? (() => loadProjects({ projectsFile }, io)));
1522
+ const limits = quiet(guarded(limitsFile) ?? (() => loadScopedLimits({ scopedLimitsFile: limitsFile }, io)));
1401
1523
  let maxCostMicros = null;
1402
1524
  try {
1403
1525
  const settings = deploymentSettingsOf(env, settingsFilePath(env), (p) => (io.existsSync ?? existsSync)(p));
@@ -1661,6 +1783,13 @@ export async function collectChecks(shellVars, seams) {
1661
1783
  // is this shell's, never the file's. The one thing the worker does read there, auth.json, follows the resolution
1662
1784
  // (`keyAgentDir`).
1663
1785
  const { cwd, spawn, probeValkey, readHosts = defaultReadHosts, fileExists, nodeVersion, platform, agentDir = agentDirFrom(spawnEnv), readHostPiFn = readHostPi, home = safeHomeDir(), underTemp = underOsTempDir, providerOracle = defaultProviderOracle, facts = null, readEnvFile = null, stat: statSeam = statSync, runTimeouts = RUN_TIMEOUTS } = seams;
1786
+ // Issue #859: the deployment `.env`'s reading of the five boot-file keys, made ONCE and here, before the first check that
1787
+ // reads a projects, scoped-limits, pause-windows or envelope file (a test fails a second read). The BOOT_FILES lines
1788
+ // further down judge its two subjects from it, as they did when it was read there; `bootEnv` is what every other
1789
+ // check judges those keys by (`bootFileEnv`), never this shell's value alone.
1790
+ const envFile = envFileKeys(join(cwd, ".env"), BOOT_FILES.map((spec) => spec.key), { fileExists, readEnvFile, platform });
1791
+ const boot = bootFileEnv(env, envFile, cwd, platform, { untrusted: seams.envUntrusted === true });
1792
+ const bootEnv = boot.env;
1664
1793
 
1665
1794
  // The job image as the worker takes it (`config.mjs` `jobImageFrom`: `||`, so an empty value is the default, where
1666
1795
  // doctor's `??` inspected an image named ""), and judged BEFORE doctor inspects or runs it (issue #471) by the one
@@ -1896,10 +2025,17 @@ export async function collectChecks(shellVars, seams) {
1896
2025
  // drives a need above 1 through the real collection, for a store whose table has no row of its own and at a version no row holds yet.
1897
2026
  const versionTables = seams.versionTables ?? {};
1898
2027
  const { watchSources: triggerWatchSources, watchRules: triggerWatchRules, modelRoles: triggerModelRoles, secretBundles: triggerBundles, requiring, waiting, listing, waitProfiles, waitAfters, optingOut, resuming, recovering, replicating, instructing, commands, secreting, onceArmed, onceSpent, secretProfiles, localSecretFolders, secretNames, folders, images, imageRoutes, namedBackends, skillsDirs, forges, repositories, flows, excludedNames, costCaps, modelRuns, triggersVersionNeed, jobDataVersionNeed, parseError, parseNewer: triggersFileIsNewer, path: triggersFilePath } = readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTables);
1899
- const modelRoleProjects = readProjectFacts(env, fileExists).projects;
1900
- const scopedLimitFacts = readScopedLimitFacts(env, fileExists);
1901
- const pauseWindowFacts = readPauseWindowFacts(env, fileExists, versionTables.pauseWindows);
2028
+ const modelRoleProjects = readProjectFacts(bootEnv, fileExists).projects;
2029
+ const scopedLimitFacts = readScopedLimitFacts(bootEnv, fileExists);
2030
+ const pauseWindowFacts = readPauseWindowFacts(bootEnv, fileExists, versionTables.pauseWindows);
1902
2031
  section("triggers");
2032
+ // Issue #859: a boot file the `.env` names on a line doctor could not read is not known, and the checks that read it are
2033
+ // left out rather than judged against nothing; this line says which, once, so none of them goes quiet without a word. A
2034
+ // `.env` that could not be read at all has a line per key already ("cannot be answered here"), so this adds nothing there.
2035
+ const unreadBoot = [...bootEnv[UNREAD_BOOT_KEYS]];
2036
+ if (unreadBoot.length > 0 && envFile.unreadable !== true) {
2037
+ checks.push({ ok: false, warn: true, label: `not judged here, since doctor could not read what the deployment .env sets for ${unreadBoot.join(", ")}: the checks that read ${unreadBoot.length === 1 ? "that file" : "those files"} (a project's model roles, size and bundles, the scoped limits and budgets, the pause windows, the envelope)`, fix: "fix what the boot-file line above names, then re-run doctor" });
2038
+ }
1903
2039
  checks.push(...watchDoctorChecks({ sources: triggerWatchSources, rules: triggerWatchRules, env }));
1904
2040
  // FIRST, and fail rather than warn: every check below this line reads counts that a parse failure
1905
2041
  // zeroed, so a green run here would be reporting on a file nobody could read. The receiver loads this
@@ -2343,9 +2479,13 @@ export async function collectChecks(shellVars, seams) {
2343
2479
  // offers. The records are read only when there is a project to suggest for, and not at all when the scoped-limits
2344
2480
  // file does not load: every size read without it would be the default, not the project's, and a project that has a
2345
2481
  // row would be told to `dispatch_limit_add` one.
2346
- const sizingProjects = readProjectFacts(env, fileExists).projects;
2482
+ const sizingProjects = readProjectFacts(bootEnv, fileExists).projects;
2347
2483
  if (sizingProjects.length > 0 && !budgetView.error) {
2348
- if (scopedLimitFacts.parseError !== null) {
2484
+ if (scopedLimitFacts.parseError === "unread") {
2485
+ // Issue #859: the `.env` line naming the file could not be read (its own line says why), so which rows the service
2486
+ // has is not known, and a suggestion would tell a project that has one to add it.
2487
+ checks.push({ ok: true, label: "size suggestions: off, because the scoped-limits file the service reads is not known (doctor could not read what the deployment .env sets for it)" });
2488
+ } else if (scopedLimitFacts.parseError !== null) {
2349
2489
  checks.push({ ok: true, label: "size suggestions: off until the scoped-limits file loads (a size read without it would be the default, not the project's)" });
2350
2490
  } else {
2351
2491
  const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
@@ -3140,12 +3280,12 @@ export async function collectChecks(shellVars, seams) {
3140
3280
  // A malformed list is its own line below; the worker refuses to boot on it.
3141
3281
  }
3142
3282
  const usdHere = usdFingerprint(dollarsHere, scopedLimitFacts.parseError === null ? scopedLimitFacts.limits : [], envListHere);
3143
- checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
3283
+ if (scopedLimitFacts.parseError !== "unread") checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
3144
3284
  // Issue #499 part C: each host resolves its jobs' project from its OWN projects.json, while the project rows' counters
3145
3285
  // are shared. This host's `fpProjects` from the file the service names, against every peer's published one. SKIPPED
3146
3286
  // when this host's file does not load (PR #569's review): the BOOT_FILES line already fails on it, and comparing
3147
3287
  // "no projects" against healthy peers would send the operator to the wrong host.
3148
- const projectFactsHere = readProjectFacts(env, fileExists);
3288
+ const projectFactsHere = readProjectFacts(bootEnv, fileExists);
3149
3289
  if (projectFactsHere.parseError === null) checks.push(...fleetProjectsChecks(projectsFingerprint(projectFactsHere.projects), peers));
3150
3290
  // Issue #596: a peer that predates version 3 keeps its last good scoped-limits file once this one is version 3, so
3151
3291
  // no edit to the file applies on it. Judged on the DECLARED version, or the derived one if that is higher (gate
@@ -3161,7 +3301,7 @@ export async function collectChecks(shellVars, seams) {
3161
3301
  // Issue #504 part B: one applied split, judged on every host against its own envelope. A host whose envelope digest
3162
3302
  // differs refuses every governed job as `envelope-mismatch`. SKIPPED when this host's file does not load, for the
3163
3303
  // projects check's reason above.
3164
- const envelopeHere = readEnvelopeFacts(env);
3304
+ const envelopeHere = readEnvelopeFacts(bootEnv);
3165
3305
  if (envelopeHere.parseError === null && appliedSplit === null) checks.push(...fleetEnvelopeChecks(envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, peers));
3166
3306
  } else if (fleet.unreachable) {
3167
3307
  // Said, rather than silently absent: "no peers" and "could not ask" are different facts.
@@ -3186,7 +3326,7 @@ export async function collectChecks(shellVars, seams) {
3186
3326
  // whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
3187
3327
  // the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
3188
3328
  if (appliedSplit !== null) {
3189
- const envelopeHere = readEnvelopeFacts(env);
3329
+ const envelopeHere = readEnvelopeFacts(bootEnv);
3190
3330
  if (envelopeHere.parseError === null) checks.push(...appliedSplitChecks(appliedSplit, envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, workerNameOf(declaredWorkerName), peers));
3191
3331
  }
3192
3332
 
@@ -3861,7 +4001,7 @@ export async function collectChecks(shellVars, seams) {
3861
4001
  }
3862
4002
  }
3863
4003
 
3864
- checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
4004
+ checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, declaredEndpoints: () => (seams.declaredEndpoints ?? ((a) => declaredEndpointsIn({ ...a, fs: keylessIo })))({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform }), now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
3865
4005
  section("secrets");
3866
4006
  // REQ-TRIGGER-SECRETS. Only reported when a trigger actually binds one, on the run.resume block's
3867
4007
  // reasoning below: a deployment that uses no secrets should not be told about a variable it has no
@@ -3997,7 +4137,7 @@ export async function collectChecks(shellVars, seams) {
3997
4137
  // skip reason. Doctor cannot see another host's file, so it warns where the shape allows it: a multi-host worker
3998
4138
  // (PI_WORKER_NAME) with projects and no triggers file of its own.
3999
4139
  if (!anyBundle && typeof env.PI_WORKER_NAME === "string" && env.PI_WORKER_NAME.trim() !== "" && !fileExists(triggersPath(env, cwd))) {
4000
- const fleetProjects = readProjectFacts(env, fileExists);
4140
+ const fleetProjects = readProjectFacts(bootEnv, fileExists);
4001
4141
  if (fleetProjects.parseError === null && fleetProjects.projects.length > 0) {
4002
4142
  checks.push({ ok: true, warn: true, label: "this fleet worker has projects but no triggers file, so it applies no project secret bundle: a project another host's triggers.json binds would run here without its environment", fix: "keep the same triggers.json on every host (PI_TRIGGERS_FILE), or declare the bundle profiles here, which makes a missing file a retry instead" });
4003
4143
  }
@@ -4009,7 +4149,7 @@ export async function collectChecks(shellVars, seams) {
4009
4149
  } catch {
4010
4150
  realExecutablePath = null;
4011
4151
  }
4012
- const projectFacts = readProjectFacts(env, fileExists);
4152
+ const projectFacts = readProjectFacts(bootEnv, fileExists);
4013
4153
  let resolveBundle = null;
4014
4154
  if (seams.secrets === true) {
4015
4155
  try {
@@ -4027,6 +4167,7 @@ export async function collectChecks(shellVars, seams) {
4027
4167
  triggersPresent: fileExists(triggersPath(env, cwd)),
4028
4168
  projects: projectFacts.projects,
4029
4169
  projectsError: projectFacts.parseError,
4170
+ projectsUnread: projectFacts.unread === true,
4030
4171
  envRaw: env.PI_SECRET_BUNDLES,
4031
4172
  overlayBundles,
4032
4173
  roots: delimitedList(env.PI_SECRET_RESOLVER_ROOTS),
@@ -4233,7 +4374,7 @@ export async function collectChecks(shellVars, seams) {
4233
4374
  // warning then cries wolf at a correctly configured deployment, and this module's own rule is that a
4234
4375
  // check nobody can silence must never do that.
4235
4376
  //
4236
- // So doctor reads `<cwd>/.env` for EXACTLY the key each check names, and never to configure anything.
4377
+ // So doctor reads `<cwd>/.env` for EXACTLY the boot-file keys (`ENV_FILE_READABLE_KEYS`), and never to configure anything.
4237
4378
  // That narrowing is the whole licence: the project's stance is that nothing parses `.env`
4238
4379
  // (`docs/secrets.md`), and `worker/test/service.test.mjs` pins that a `PI_ENV_SETUP` line in `./.env` is
4239
4380
  // deliberately NOT honoured. Both stay true.
@@ -4243,7 +4384,8 @@ export async function collectChecks(shellVars, seams) {
4243
4384
  // a deployment whose unit exits 1 in a restart loop is not a deployment that is merely unconfigured in
4244
4385
  // this shell. Where the file merely configures what this shell does not, the line still says which
4245
4386
  // process would honour it, and is still a warning.
4246
- const envFile = envFileKeys(join(cwd, ".env"), BOOT_FILES.map((spec) => spec.key), { fileExists, readEnvFile, platform });
4387
+ // The read itself (`envFile`) is made once at the top of `collectChecks` since issue #859, so every check that reads
4388
+ // these files, before this block and after it, judges the same reading (`bootFileEnv`).
4247
4389
  // ONE RULE FOR BOTH BOOT FILES, and it asks the worker's own loader rather than a second opinion
4248
4390
  // (issue #384). Before this there were two hand-written copies of a shell-shaped check for
4249
4391
  // `PI_PAUSE_WINDOWS_FILE`, a THIRD rule for a scoped-limits file that does not parse, and no rule at all
@@ -4264,12 +4406,15 @@ export async function collectChecks(shellVars, seams) {
4264
4406
  // regular-file guard and the loaders' own two reads. A test drives a whole deployment through these
4265
4407
  // without a real file, which is how the fixtures below stay honest about content.
4266
4408
  const seamsForLoad = { env, statFile: statSeam, loaderIo: { existsSync: (p) => fileExists(p), readFileSync: readEnvFile ? (p, enc) => asText(readEnvFile(p), enc) : readFileSync } };
4409
+ // Issue #859: the SERVICE's subject is loaded with the boot keys as the service reads them, from the file alone, so an
4410
+ // envelope in `.env` is judged against the projects and limits the service names, never against this shell's.
4411
+ const serviceSeamsForLoad = { ...seamsForLoad, env: boot.service };
4267
4412
  // ONCE PER FILE, not once per key (issue #396). This is a fact about the FILE -- one line the reader
4268
4413
  // cannot model -- and it was announced inside the per-key loop, so a `.env` with one such line produced
4269
4414
  // two near-identical warnings differing only in which key they named. The keys it prevents a verdict
4270
4415
  // about are listed IN the line instead, which is what the reader actually knows.
4271
4416
  //
4272
- // ITS LIMIT, stated because the wording would otherwise imply more: this command reads two keys, so the
4417
+ // ITS LIMIT, stated because the wording would otherwise imply more: this command reads five keys, so the
4273
4418
  // line says what it cannot answer about THOSE. The same hazard may also stop a sourcing shell reaching
4274
4419
  // `WEBHOOK_SECRET`, which the receiver refuses to start without, and nothing here says so -- widening
4275
4420
  // the read is how a narrow reader grows into "load the .env", which `envFileKeys`' own docblock and
@@ -4386,8 +4531,13 @@ export async function collectChecks(shellVars, seams) {
4386
4531
  // boot key was never opened. `fileRaw` is set only for a line this reader vouches for the TEXT of, so
4387
4532
  // there is always a real path here; whether it loads is a fact about the filesystem, not about the
4388
4533
  // rest of the file.
4389
- if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
4390
- const verdict = loadVerdict(spec, fileRaw, cwd, seamsForLoad, platform);
4534
+ // Issue #859: the envelope is judged against the projects and limits the service names; where the `.env` line of one
4535
+ // of those cannot be read, neither can this verdict, and a "no projects" verdict would fail a service that starts.
4536
+ const contextUnread = spec.key === "PI_ENVELOPE_FILE" ? ["PI_PROJECTS_FILE", "PI_SCOPED_LIMITS_FILE"].filter((key) => boot.serviceUnread.has(key)) : [];
4537
+ if (envFile.hazard == null && fileRaw !== undefined && !blankInFile && contextUnread.length > 0) {
4538
+ checks.push({ id: bootFileId(spec), ok: false, warn: true, label: `${spec.key} is set in ${join(cwd, ".env")} (${envValueShown(fileRaw)}), and whether the service loads it cannot be judged: doctor could not read what that file sets for ${contextUnread.join(" and ")}, which it is judged against`, fix: "fix that line (its own line here says why), then re-run doctor" });
4539
+ } else if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
4540
+ const verdict = loadVerdict(spec, fileRaw, cwd, serviceSeamsForLoad, platform);
4391
4541
  checks.push(
4392
4542
  verdict.ok
4393
4543
  ? { id: bootFileId(spec), ok: true, label: `${spec.key} is set in ${join(cwd, ".env")} (${envValueShown(fileRaw)})${alsoExported === undefined ? "" : ", for this platform's loader,"} and loads: the service reads that file, this shell does not` }
@@ -4463,6 +4613,19 @@ export async function collectChecks(shellVars, seams) {
4463
4613
  fix: `set ${fixLineFor(spec.key, scaffolded)} in .env and restart the worker -- unset means ${spec.unsetMeans}${spec.panelWrites === false ? "" : `, while the admin panel defaults to this same file and reports each ${spec.unit} it writes as applied live`}; delete the file if this deployment has no ${spec.nothing}`,
4464
4614
  });
4465
4615
  }
4616
+
4617
+ // Issue #859: WHICH FILE THE OTHER CHECKS JUDGED, where the two subjects name different ones. A ✗, as a disagreement
4618
+ // about any other service key is (`serviceEnvLines`): the checks then judge files the service does not read, and a
4619
+ // warning let doctor exit 0 on a service its own `.env` stops (a project row the shell's projects hold and the
4620
+ // file's do not passed, while `checkProjectRows` refuses the boot).
4621
+ const differs = boot.differs.find((d) => d.key === spec.key);
4622
+ if (differs !== undefined) {
4623
+ checks.push({
4624
+ ok: false,
4625
+ label: `${spec.key} names different files in this shell (${envValueShown(differs.shell)}) and in ${join(cwd, ".env")} (${envValueShown(differs.file)}): doctor's other checks judged this shell's, which a worker started by hand from this shell runs, while the service runs the file's`,
4626
+ fix: `make them agree: change ${join(cwd, ".env")} (what the service reads), or unset ${spec.key} in this shell, then re-run doctor`,
4627
+ });
4628
+ }
4466
4629
  }
4467
4630
 
4468
4631
  // A scope that can only ever be a folder (issue #242's dead-scope rule): a forge repo always contains "/" and never
@@ -4502,14 +4665,14 @@ export async function collectChecks(shellVars, seams) {
4502
4665
  // (start.mjs, `checkProjectRows`), so this is a FAILURE naming the row and the id. Skipped when either file does not
4503
4666
  // load: that is the BOOT_FILES line's, and a zeroed list here would name every project row as dangling.
4504
4667
  if (scopedLimitFacts.parseError === null && scopedLimitFacts.limits.length > 0) {
4505
- const projectFacts = readProjectFacts(env, fileExists);
4668
+ const projectFacts = readProjectFacts(bootEnv, fileExists);
4506
4669
  if (projectFacts.parseError === null) checks.push(...projectRowChecks(scopedLimitFacts.limits, projectFacts.projects, scopedLimitFacts.path));
4507
4670
  }
4508
4671
 
4509
4672
  section("envelope");
4510
4673
  // Issue #504 part B: the envelope's advisories, on the file the service names, when it loads (a file that does not is
4511
4674
  // the BOOT_FILES line's). Warnings only: the worker boots on both.
4512
- const envelopeFactsHere = readEnvelopeFacts(env);
4675
+ const envelopeFactsHere = readEnvelopeFacts(bootEnv);
4513
4676
  if (envelopeFactsHere.envelope) checks.push(...envelopeChecks(envelopeFactsHere.envelope, envelopeFactsHere.projects, envelopeFactsHere.maxCostMicros));
4514
4677
 
4515
4678
  section("console");
@@ -4813,7 +4976,9 @@ export async function collectChecks(shellVars, seams) {
4813
4976
  // Nor XDG_CONFIG_HOME at its own default (`~/.config`, which a login shell often exports): unset, every CLI that
4814
4977
  // reads it falls back to that same directory, so the service runs with the same value (measured on pd-fedora: the
4815
4978
  // named line otherwise flagged the default).
4816
- const shellOnly = service.shellOnly.filter((k) => !AMBIENT_SERVICE_KEYS.includes(k) && !(k === "XDG_CONFIG_HOME" && typeof home === "string" && env.XDG_CONFIG_HOME === join(home, ".config")));
4979
+ // Issue #859: a boot-file key set in this shell alone (`bootFileEnv`) is the same fact about the same file, so it joins
4980
+ // this line: the checks judged this shell's file, and the service runs without one.
4981
+ const shellOnly = [...service.shellOnly.filter((k) => !AMBIENT_SERVICE_KEYS.includes(k) && !(k === "XDG_CONFIG_HOME" && typeof home === "string" && env.XDG_CONFIG_HOME === join(home, ".config"))), ...boot.shellOnly];
4817
4982
  const unit = shellOnly.length > 0 ? serviceUnitFor(seams, cwd) : null;
4818
4983
  if (unit) {
4819
4984
  checks.push({
@@ -5415,6 +5580,7 @@ function parseSecretProfilesSafe(raw) {
5415
5580
  */
5416
5581
  function readScopedLimitFacts(env, fileExists) {
5417
5582
  const none = { limits: [], parseError: null, path: null };
5583
+ if (bootKeyUnread(env, "PI_SCOPED_LIMITS_FILE")) return { ...none, parseError: "unread" };
5418
5584
  const path = env.PI_SCOPED_LIMITS_FILE;
5419
5585
  if (typeof path !== "string" || path.trim() === "") return none;
5420
5586
  if (!fileExists(path)) return { limits: [], parseError: `scoped-limits file does not exist: ${path}`, path };
@@ -5438,8 +5604,12 @@ function readScopedLimitFacts(env, fileExists) {
5438
5604
  /**
5439
5605
  * The projects facts (issue #499 part B): the parsed projects when PI_PROJECTS_FILE is set, `[]` when it is unset (no
5440
5606
  * projects, so every project row dangles), or a parse error when it is set and does not load (the BOOT_FILES line's).
5607
+ * Handed `bootEnv` (issue #859), so the key is the service's where this shell sets none, and `unread` where the `.env`
5608
+ * line that sets it could not be read.
5441
5609
  */
5442
5610
  function readProjectFacts(env, fileExists) {
5611
+ // Issue #859: a `.env` line doctor could not read is a file not known, said by its own line, never "no projects".
5612
+ if (bootKeyUnread(env, "PI_PROJECTS_FILE")) return { projects: [], parseError: "unread", unread: true };
5443
5613
  const path = env.PI_PROJECTS_FILE;
5444
5614
  if (typeof path !== "string" || path.trim() === "") return { projects: [], parseError: null };
5445
5615
  try {
@@ -5695,9 +5865,13 @@ function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs,
5695
5865
  * `envelope` is null when the key is unset.
5696
5866
  */
5697
5867
  function readEnvelopeFacts(env) {
5868
+ if (bootKeyUnread(env, "PI_ENVELOPE_FILE")) return { envelope: null, projects: [], maxCostMicros: null, parseError: "unread" };
5698
5869
  const path = nonEmpty(env.PI_ENVELOPE_FILE);
5699
5870
  if (path === null) return { envelope: null, projects: [], maxCostMicros: null, parseError: null };
5700
5871
  const io = { readFileSync, existsSync };
5872
+ // A REGULAR FILE or nothing, as every other reader of these files here (issue #859): since the `.env` names this path
5873
+ // too, a FIFO there hung doctor in a synchronous read no timeout interrupts.
5874
+ if (!regularOrAbsent(path)) return { envelope: null, projects: [], maxCostMicros: null, parseError: `envelope file is not a regular file: ${path}` };
5701
5875
  try {
5702
5876
  const context = envelopeContextOf(env, io);
5703
5877
  return { envelope: loadEnvelopeAsTheWorker(path, io, env), projects: context.projects, maxCostMicros: context.maxCostMicros, parseError: null };
@@ -5888,6 +6062,7 @@ export function projectRowChecks(limits, projects, path) {
5888
6062
  * module's own by default.
5889
6063
  */
5890
6064
  function readPauseWindowFacts(env, fileExists, versions = {}) {
6065
+ if (bootKeyUnread(env, "PI_PAUSE_WINDOWS_FILE")) return { windows: [], versionNeed: 1, parseError: "unread" };
5891
6066
  const path = env.PI_PAUSE_WINDOWS_FILE;
5892
6067
  if (typeof path !== "string" || path.trim() === "") return { windows: [], versionNeed: 1, parseError: null };
5893
6068
  try {
@@ -83,8 +83,13 @@ export function pauseWindowsVersionRefusal(doc, { supported = PAUSE_WINDOWS_VERS
83
83
  export function heldPauseWindows(text, path) {
84
84
  if ((text ?? "").trim() === "") return { doc: null, windows: [] };
85
85
  const { value, problem } = heldDocument(text, "windows");
86
- const refused = problem ?? (findDuplicateKey(text) ? "has a key written twice" : null);
87
- if (refused) return { doc: value ?? null, problem: `the pause-windows file ${path} ${refused}, so its windows cannot be kept` };
86
+ // Whenever the text parsed, a problem with its shape included (`"windows": {}`), as the loader judges the duplicate first.
87
+ const doubled = value !== undefined && findDuplicateKey(text) !== null;
88
+ const refused = doubled ? "has a key written twice" : problem ?? null;
89
+ // A key written twice hands back no document (issue #846): the callers ask the document whether a newer release wrote
90
+ // it before they read `problem`, and `{"version":1,"version":2}` would send the operator to upgrade to a release that
91
+ // refuses the file too. Broken, never newer, as the loader reads it and as the triggers writer does.
92
+ if (refused) return { doc: doubled ? null : value ?? null, problem: `the pause-windows file ${path} ${refused}, so its windows cannot be kept` };
88
93
  return { doc: value, windows: Array.isArray(value.windows) ? value.windows : [] };
89
94
  }
90
95
 
@@ -158,7 +163,7 @@ function validateTz(tz, at, path) {
158
163
  * (unknown fields dropped; `fromMin`/`toMin` precomputed). Throws `configError` (fail-loud) on any malformed
159
164
  * entry. `path` is for messages only.
160
165
  *
161
- * The file's `version` is judged FIRST, before the windows (issue #761): a newer file's windows may use a shape this
166
+ * The file's `version` is judged right after the duplicate-key check (issue #846) and before the windows (issue #761): a newer file's windows may use a shape this
162
167
  * build would call malformed, and the true message is "upgrade". A newer file throws a configError with
163
168
  * `.newer === true` (the worker defers jobs rather than misread a pause); a broken `version`, or a window using a field
164
169
  * or a value that needs a higher version than the file declares, throws the ordinary configError. `supported`, `table`
@@ -171,6 +176,17 @@ export function parsePauseWindows(text, path, { supported = PAUSE_WINDOWS_VERSIO
171
176
  } catch (error) {
172
177
  throw configError(`pause-windows file is not valid JSON: ${path} (${error.message})`);
173
178
  }
179
+ // Issue #846: a key written twice is REFUSED, after the parse and before anything else, as the triggers loader refuses
180
+ // one (`triggers.mjs`, issue #313). `JSON.parse` keeps the LAST value, so `{"windows":[...],"windows":[]}` loaded no
181
+ // window while a person reading the file sees two, and a reload swapped a live list down to none with nothing said:
182
+ // quiet hours stopped applying, which costs money in exactly the hours someone believes are paused. Refused, the boot
183
+ // does not start and a reload keeps the last good windows and logs why (`reloadPauseWindows`). The writers have
184
+ // refused such a file since #841 (`heldPauseWindows`), so only a hand edit makes one. BEFORE the version, the triggers
185
+ // loader's order: a doubled `version` is broken, never newer, and a newer release refuses the duplicate as well.
186
+ const duplicate = findDuplicateKey(text);
187
+ if (duplicate) {
188
+ throw configError(`pause-windows file has a duplicate key ${JSON.stringify(duplicate.key)} at ${duplicate.at}: JSON keeps the LAST value, so the file a person reads and the file that applies are not the same: ${path}`);
189
+ }
174
190
  const problem = pauseWindowsVersionRefusal(parsed, { supported });
175
191
  if (problem) {
176
192
  const error = configError(`${problem.message}: ${path}`);