@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.
package/src/doctor.mjs CHANGED
@@ -143,7 +143,7 @@ import { assignCheckIds, buildDoctorRecord, doctorIntervalHours, publishRedactor
143
143
  import { olderStateFindings as olderStateFindingsReal, VALKEY_CONTAINER } from "./older-state.mjs";
144
144
  import { DOCTOR_PUBLISH_SKIPPED_PREFIX } from "./doctor-spawner.mjs";
145
145
  import { extensionsNotHere } from "./loaded-record.mjs";
146
- import { buildHostLoads, flowProbes, flowTierOf, mcpRowsOf, pathIsThere, readOverlayExtensionRows, readOverlaySkillNames } from "./host-loads.mjs";
146
+ import { buildHostLoads, FLOW_INPUT_FIELDS, flowBasisOf, flowInputOf, flowProbes, flowTierOf, mcpRowsOf, pathIsThere, readOverlayExtensionRows, readOverlaySkillNames } from "./host-loads.mjs";
147
147
 
148
148
  const NODE_FLOOR = [22, 19]; // pi's engine floor (22.19.0)
149
149
 
@@ -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
@@ -2055,7 +2191,8 @@ export async function collectChecks(shellVars, seams) {
2055
2191
  const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: stagedProbes.readFile, fileExists: stagedProbes.fileExists, readDir: stagedProbes.readDir });
2056
2192
  const groups = new Map();
2057
2193
  for (const f of flows) {
2058
- const key = JSON.stringify([f.flow, f.folder, f.skillsDir, f.packages, f.excludeExtensions]);
2194
+ // Keyed by every field `flowTierOf` reads, so a group shares one answer and one basis.
2195
+ const key = JSON.stringify(FLOW_INPUT_FIELDS.map((field) => f[field]));
2059
2196
  if (!groups.has(key)) groups.set(key, { ...f, labels: [], triggers: [] });
2060
2197
  groups.get(key).labels.push(f.label);
2061
2198
  groups.get(key).triggers.push(f.index);
@@ -2073,7 +2210,7 @@ export async function collectChecks(shellVars, seams) {
2073
2210
  fix: "a flow name must match the skill charset (lowercase alphanumerics, - and _, 64 max), or no tier can ever hold it -- fix run.flow",
2074
2211
  });
2075
2212
  // No tier can hold it, and a name that fails the charset is not published.
2076
- for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: null, tier: "none", package: null, repo: "unknown" });
2213
+ for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: null, tier: "none", package: null, repo: "unknown", basis: g.basis });
2077
2214
  continue;
2078
2215
  }
2079
2216
  const probes = flowProbes({ fileExists, fs: flowFs });
@@ -2083,7 +2220,7 @@ export async function collectChecks(shellVars, seams) {
2083
2220
  // trigger that withholds the packages did not, and another trigger's unreadable folder says nothing of this one's.
2084
2221
  const blind = probes.blind() || (g.packages !== false && stagedProbes.blind());
2085
2222
  const tier = found.tier === "none" && blind ? "unknown" : found.tier;
2086
- for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: g.flow, tier, package: found.package, repo: found.repo });
2223
+ for (const trigger of g.triggers) seams.flowRows.push({ trigger, flow: g.flow, tier, package: found.package, repo: found.repo, basis: g.basis });
2087
2224
  if (resolved) {
2088
2225
  checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
2089
2226
  } else {
@@ -2343,9 +2480,13 @@ export async function collectChecks(shellVars, seams) {
2343
2480
  // offers. The records are read only when there is a project to suggest for, and not at all when the scoped-limits
2344
2481
  // file does not load: every size read without it would be the default, not the project's, and a project that has a
2345
2482
  // row would be told to `dispatch_limit_add` one.
2346
- const sizingProjects = readProjectFacts(env, fileExists).projects;
2483
+ const sizingProjects = readProjectFacts(bootEnv, fileExists).projects;
2347
2484
  if (sizingProjects.length > 0 && !budgetView.error) {
2348
- if (scopedLimitFacts.parseError !== null) {
2485
+ if (scopedLimitFacts.parseError === "unread") {
2486
+ // Issue #859: the `.env` line naming the file could not be read (its own line says why), so which rows the service
2487
+ // has is not known, and a suggestion would tell a project that has one to add it.
2488
+ 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)" });
2489
+ } else if (scopedLimitFacts.parseError !== null) {
2349
2490
  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
2491
  } else {
2351
2492
  const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
@@ -3140,12 +3281,12 @@ export async function collectChecks(shellVars, seams) {
3140
3281
  // A malformed list is its own line below; the worker refuses to boot on it.
3141
3282
  }
3142
3283
  const usdHere = usdFingerprint(dollarsHere, scopedLimitFacts.parseError === null ? scopedLimitFacts.limits : [], envListHere);
3143
- checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
3284
+ if (scopedLimitFacts.parseError !== "unread") checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
3144
3285
  // Issue #499 part C: each host resolves its jobs' project from its OWN projects.json, while the project rows' counters
3145
3286
  // are shared. This host's `fpProjects` from the file the service names, against every peer's published one. SKIPPED
3146
3287
  // when this host's file does not load (PR #569's review): the BOOT_FILES line already fails on it, and comparing
3147
3288
  // "no projects" against healthy peers would send the operator to the wrong host.
3148
- const projectFactsHere = readProjectFacts(env, fileExists);
3289
+ const projectFactsHere = readProjectFacts(bootEnv, fileExists);
3149
3290
  if (projectFactsHere.parseError === null) checks.push(...fleetProjectsChecks(projectsFingerprint(projectFactsHere.projects), peers));
3150
3291
  // Issue #596: a peer that predates version 3 keeps its last good scoped-limits file once this one is version 3, so
3151
3292
  // no edit to the file applies on it. Judged on the DECLARED version, or the derived one if that is higher (gate
@@ -3161,7 +3302,7 @@ export async function collectChecks(shellVars, seams) {
3161
3302
  // Issue #504 part B: one applied split, judged on every host against its own envelope. A host whose envelope digest
3162
3303
  // differs refuses every governed job as `envelope-mismatch`. SKIPPED when this host's file does not load, for the
3163
3304
  // projects check's reason above.
3164
- const envelopeHere = readEnvelopeFacts(env);
3305
+ const envelopeHere = readEnvelopeFacts(bootEnv);
3165
3306
  if (envelopeHere.parseError === null && appliedSplit === null) checks.push(...fleetEnvelopeChecks(envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, peers));
3166
3307
  } else if (fleet.unreachable) {
3167
3308
  // Said, rather than silently absent: "no peers" and "could not ask" are different facts.
@@ -3186,7 +3327,7 @@ export async function collectChecks(shellVars, seams) {
3186
3327
  // whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
3187
3328
  // the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
3188
3329
  if (appliedSplit !== null) {
3189
- const envelopeHere = readEnvelopeFacts(env);
3330
+ const envelopeHere = readEnvelopeFacts(bootEnv);
3190
3331
  if (envelopeHere.parseError === null) checks.push(...appliedSplitChecks(appliedSplit, envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, workerNameOf(declaredWorkerName), peers));
3191
3332
  }
3192
3333
 
@@ -3861,7 +4002,7 @@ export async function collectChecks(shellVars, seams) {
3861
4002
  }
3862
4003
  }
3863
4004
 
3864
- checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
4005
+ 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
4006
  section("secrets");
3866
4007
  // REQ-TRIGGER-SECRETS. Only reported when a trigger actually binds one, on the run.resume block's
3867
4008
  // reasoning below: a deployment that uses no secrets should not be told about a variable it has no
@@ -3997,7 +4138,7 @@ export async function collectChecks(shellVars, seams) {
3997
4138
  // skip reason. Doctor cannot see another host's file, so it warns where the shape allows it: a multi-host worker
3998
4139
  // (PI_WORKER_NAME) with projects and no triggers file of its own.
3999
4140
  if (!anyBundle && typeof env.PI_WORKER_NAME === "string" && env.PI_WORKER_NAME.trim() !== "" && !fileExists(triggersPath(env, cwd))) {
4000
- const fleetProjects = readProjectFacts(env, fileExists);
4141
+ const fleetProjects = readProjectFacts(bootEnv, fileExists);
4001
4142
  if (fleetProjects.parseError === null && fleetProjects.projects.length > 0) {
4002
4143
  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
4144
  }
@@ -4009,7 +4150,7 @@ export async function collectChecks(shellVars, seams) {
4009
4150
  } catch {
4010
4151
  realExecutablePath = null;
4011
4152
  }
4012
- const projectFacts = readProjectFacts(env, fileExists);
4153
+ const projectFacts = readProjectFacts(bootEnv, fileExists);
4013
4154
  let resolveBundle = null;
4014
4155
  if (seams.secrets === true) {
4015
4156
  try {
@@ -4027,6 +4168,7 @@ export async function collectChecks(shellVars, seams) {
4027
4168
  triggersPresent: fileExists(triggersPath(env, cwd)),
4028
4169
  projects: projectFacts.projects,
4029
4170
  projectsError: projectFacts.parseError,
4171
+ projectsUnread: projectFacts.unread === true,
4030
4172
  envRaw: env.PI_SECRET_BUNDLES,
4031
4173
  overlayBundles,
4032
4174
  roots: delimitedList(env.PI_SECRET_RESOLVER_ROOTS),
@@ -4233,7 +4375,7 @@ export async function collectChecks(shellVars, seams) {
4233
4375
  // warning then cries wolf at a correctly configured deployment, and this module's own rule is that a
4234
4376
  // check nobody can silence must never do that.
4235
4377
  //
4236
- // So doctor reads `<cwd>/.env` for EXACTLY the key each check names, and never to configure anything.
4378
+ // So doctor reads `<cwd>/.env` for EXACTLY the boot-file keys (`ENV_FILE_READABLE_KEYS`), and never to configure anything.
4237
4379
  // That narrowing is the whole licence: the project's stance is that nothing parses `.env`
4238
4380
  // (`docs/secrets.md`), and `worker/test/service.test.mjs` pins that a `PI_ENV_SETUP` line in `./.env` is
4239
4381
  // deliberately NOT honoured. Both stay true.
@@ -4243,7 +4385,8 @@ export async function collectChecks(shellVars, seams) {
4243
4385
  // a deployment whose unit exits 1 in a restart loop is not a deployment that is merely unconfigured in
4244
4386
  // this shell. Where the file merely configures what this shell does not, the line still says which
4245
4387
  // 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 });
4388
+ // The read itself (`envFile`) is made once at the top of `collectChecks` since issue #859, so every check that reads
4389
+ // these files, before this block and after it, judges the same reading (`bootFileEnv`).
4247
4390
  // ONE RULE FOR BOTH BOOT FILES, and it asks the worker's own loader rather than a second opinion
4248
4391
  // (issue #384). Before this there were two hand-written copies of a shell-shaped check for
4249
4392
  // `PI_PAUSE_WINDOWS_FILE`, a THIRD rule for a scoped-limits file that does not parse, and no rule at all
@@ -4264,12 +4407,15 @@ export async function collectChecks(shellVars, seams) {
4264
4407
  // regular-file guard and the loaders' own two reads. A test drives a whole deployment through these
4265
4408
  // without a real file, which is how the fixtures below stay honest about content.
4266
4409
  const seamsForLoad = { env, statFile: statSeam, loaderIo: { existsSync: (p) => fileExists(p), readFileSync: readEnvFile ? (p, enc) => asText(readEnvFile(p), enc) : readFileSync } };
4410
+ // Issue #859: the SERVICE's subject is loaded with the boot keys as the service reads them, from the file alone, so an
4411
+ // envelope in `.env` is judged against the projects and limits the service names, never against this shell's.
4412
+ const serviceSeamsForLoad = { ...seamsForLoad, env: boot.service };
4267
4413
  // ONCE PER FILE, not once per key (issue #396). This is a fact about the FILE -- one line the reader
4268
4414
  // cannot model -- and it was announced inside the per-key loop, so a `.env` with one such line produced
4269
4415
  // two near-identical warnings differing only in which key they named. The keys it prevents a verdict
4270
4416
  // about are listed IN the line instead, which is what the reader actually knows.
4271
4417
  //
4272
- // ITS LIMIT, stated because the wording would otherwise imply more: this command reads two keys, so the
4418
+ // ITS LIMIT, stated because the wording would otherwise imply more: this command reads five keys, so the
4273
4419
  // line says what it cannot answer about THOSE. The same hazard may also stop a sourcing shell reaching
4274
4420
  // `WEBHOOK_SECRET`, which the receiver refuses to start without, and nothing here says so -- widening
4275
4421
  // the read is how a narrow reader grows into "load the .env", which `envFileKeys`' own docblock and
@@ -4386,8 +4532,13 @@ export async function collectChecks(shellVars, seams) {
4386
4532
  // boot key was never opened. `fileRaw` is set only for a line this reader vouches for the TEXT of, so
4387
4533
  // there is always a real path here; whether it loads is a fact about the filesystem, not about the
4388
4534
  // rest of the file.
4389
- if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
4390
- const verdict = loadVerdict(spec, fileRaw, cwd, seamsForLoad, platform);
4535
+ // Issue #859: the envelope is judged against the projects and limits the service names; where the `.env` line of one
4536
+ // of those cannot be read, neither can this verdict, and a "no projects" verdict would fail a service that starts.
4537
+ const contextUnread = spec.key === "PI_ENVELOPE_FILE" ? ["PI_PROJECTS_FILE", "PI_SCOPED_LIMITS_FILE"].filter((key) => boot.serviceUnread.has(key)) : [];
4538
+ if (envFile.hazard == null && fileRaw !== undefined && !blankInFile && contextUnread.length > 0) {
4539
+ 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" });
4540
+ } else if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
4541
+ const verdict = loadVerdict(spec, fileRaw, cwd, serviceSeamsForLoad, platform);
4391
4542
  checks.push(
4392
4543
  verdict.ok
4393
4544
  ? { 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 +4614,19 @@ export async function collectChecks(shellVars, seams) {
4463
4614
  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
4615
  });
4465
4616
  }
4617
+
4618
+ // Issue #859: WHICH FILE THE OTHER CHECKS JUDGED, where the two subjects name different ones. A ✗, as a disagreement
4619
+ // about any other service key is (`serviceEnvLines`): the checks then judge files the service does not read, and a
4620
+ // warning let doctor exit 0 on a service its own `.env` stops (a project row the shell's projects hold and the
4621
+ // file's do not passed, while `checkProjectRows` refuses the boot).
4622
+ const differs = boot.differs.find((d) => d.key === spec.key);
4623
+ if (differs !== undefined) {
4624
+ checks.push({
4625
+ ok: false,
4626
+ 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`,
4627
+ fix: `make them agree: change ${join(cwd, ".env")} (what the service reads), or unset ${spec.key} in this shell, then re-run doctor`,
4628
+ });
4629
+ }
4466
4630
  }
4467
4631
 
4468
4632
  // A scope that can only ever be a folder (issue #242's dead-scope rule): a forge repo always contains "/" and never
@@ -4502,14 +4666,14 @@ export async function collectChecks(shellVars, seams) {
4502
4666
  // (start.mjs, `checkProjectRows`), so this is a FAILURE naming the row and the id. Skipped when either file does not
4503
4667
  // load: that is the BOOT_FILES line's, and a zeroed list here would name every project row as dangling.
4504
4668
  if (scopedLimitFacts.parseError === null && scopedLimitFacts.limits.length > 0) {
4505
- const projectFacts = readProjectFacts(env, fileExists);
4669
+ const projectFacts = readProjectFacts(bootEnv, fileExists);
4506
4670
  if (projectFacts.parseError === null) checks.push(...projectRowChecks(scopedLimitFacts.limits, projectFacts.projects, scopedLimitFacts.path));
4507
4671
  }
4508
4672
 
4509
4673
  section("envelope");
4510
4674
  // Issue #504 part B: the envelope's advisories, on the file the service names, when it loads (a file that does not is
4511
4675
  // the BOOT_FILES line's). Warnings only: the worker boots on both.
4512
- const envelopeFactsHere = readEnvelopeFacts(env);
4676
+ const envelopeFactsHere = readEnvelopeFacts(bootEnv);
4513
4677
  if (envelopeFactsHere.envelope) checks.push(...envelopeChecks(envelopeFactsHere.envelope, envelopeFactsHere.projects, envelopeFactsHere.maxCostMicros));
4514
4678
 
4515
4679
  section("console");
@@ -4813,7 +4977,9 @@ export async function collectChecks(shellVars, seams) {
4813
4977
  // Nor XDG_CONFIG_HOME at its own default (`~/.config`, which a login shell often exports): unset, every CLI that
4814
4978
  // reads it falls back to that same directory, so the service runs with the same value (measured on pd-fedora: the
4815
4979
  // 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")));
4980
+ // Issue #859: a boot-file key set in this shell alone (`bootFileEnv`) is the same fact about the same file, so it joins
4981
+ // this line: the checks judged this shell's file, and the service runs without one.
4982
+ 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
4983
  const unit = shellOnly.length > 0 ? serviceUnitFor(seams, cwd) : null;
4818
4984
  if (unit) {
4819
4985
  checks.push({
@@ -5415,6 +5581,7 @@ function parseSecretProfilesSafe(raw) {
5415
5581
  */
5416
5582
  function readScopedLimitFacts(env, fileExists) {
5417
5583
  const none = { limits: [], parseError: null, path: null };
5584
+ if (bootKeyUnread(env, "PI_SCOPED_LIMITS_FILE")) return { ...none, parseError: "unread" };
5418
5585
  const path = env.PI_SCOPED_LIMITS_FILE;
5419
5586
  if (typeof path !== "string" || path.trim() === "") return none;
5420
5587
  if (!fileExists(path)) return { limits: [], parseError: `scoped-limits file does not exist: ${path}`, path };
@@ -5438,8 +5605,12 @@ function readScopedLimitFacts(env, fileExists) {
5438
5605
  /**
5439
5606
  * The projects facts (issue #499 part B): the parsed projects when PI_PROJECTS_FILE is set, `[]` when it is unset (no
5440
5607
  * projects, so every project row dangles), or a parse error when it is set and does not load (the BOOT_FILES line's).
5608
+ * Handed `bootEnv` (issue #859), so the key is the service's where this shell sets none, and `unread` where the `.env`
5609
+ * line that sets it could not be read.
5441
5610
  */
5442
5611
  function readProjectFacts(env, fileExists) {
5612
+ // Issue #859: a `.env` line doctor could not read is a file not known, said by its own line, never "no projects".
5613
+ if (bootKeyUnread(env, "PI_PROJECTS_FILE")) return { projects: [], parseError: "unread", unread: true };
5443
5614
  const path = env.PI_PROJECTS_FILE;
5444
5615
  if (typeof path !== "string" || path.trim() === "") return { projects: [], parseError: null };
5445
5616
  try {
@@ -5695,9 +5866,13 @@ function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs,
5695
5866
  * `envelope` is null when the key is unset.
5696
5867
  */
5697
5868
  function readEnvelopeFacts(env) {
5869
+ if (bootKeyUnread(env, "PI_ENVELOPE_FILE")) return { envelope: null, projects: [], maxCostMicros: null, parseError: "unread" };
5698
5870
  const path = nonEmpty(env.PI_ENVELOPE_FILE);
5699
5871
  if (path === null) return { envelope: null, projects: [], maxCostMicros: null, parseError: null };
5700
5872
  const io = { readFileSync, existsSync };
5873
+ // A REGULAR FILE or nothing, as every other reader of these files here (issue #859): since the `.env` names this path
5874
+ // too, a FIFO there hung doctor in a synchronous read no timeout interrupts.
5875
+ if (!regularOrAbsent(path)) return { envelope: null, projects: [], maxCostMicros: null, parseError: `envelope file is not a regular file: ${path}` };
5701
5876
  try {
5702
5877
  const context = envelopeContextOf(env, io);
5703
5878
  return { envelope: loadEnvelopeAsTheWorker(path, io, env), projects: context.projects, maxCostMicros: context.maxCostMicros, parseError: null };
@@ -5888,6 +6063,7 @@ export function projectRowChecks(limits, projects, path) {
5888
6063
  * module's own by default.
5889
6064
  */
5890
6065
  function readPauseWindowFacts(env, fileExists, versions = {}) {
6066
+ if (bootKeyUnread(env, "PI_PAUSE_WINDOWS_FILE")) return { windows: [], versionNeed: 1, parseError: "unread" };
5891
6067
  const path = env.PI_PAUSE_WINDOWS_FILE;
5892
6068
  if (typeof path !== "string" || path.trim() === "") return { windows: [], versionNeed: 1, parseError: null };
5893
6069
  try {
@@ -6708,18 +6884,12 @@ function readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTable
6708
6884
  // two triggers naming the same flow with different skillsDirs are two different questions.
6709
6885
  // The label is how a line names its trigger: cron entries by their id, id-less webhook
6710
6886
  // entries by raw file position (the admin's trigger:<index> identity).
6887
+ // Issue #869: what `flowTierOf` is asked and the basis each published row carries come from the one module the console
6888
+ // computes its basis with (`flowInputOf`, `flowBasisOf`), from the trigger as the loader parsed it, so the two cannot
6889
+ // read a field two ways. A command trigger and a disarmed one-shot name no flow and drop out here.
6711
6890
  flows: triggers
6712
- .map((t, index) => ({
6713
- index,
6714
- label: triggerLabel(t, index),
6715
- flow: t.run.flow,
6716
- kind: t.run.kind,
6717
- folder: typeof t.run.folder === "string" ? t.run.folder : null,
6718
- skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
6719
- packages: t.run.packages !== false,
6720
- excludeExtensions: Array.isArray(t.run.excludeExtensions) ? t.run.excludeExtensions : [],
6721
- }))
6722
- .filter((f) => typeof f.flow === "string"),
6891
+ .map((t, index) => ({ index, label: triggerLabel(t, index), kind: t.run.kind, ...flowInputOf(t), basis: flowBasisOf(t) }))
6892
+ .filter((f) => f.flow !== null),
6723
6893
  // Issue #800: each trigger's run.excludeExtensions, for the line that says a name is on neither side of this host.
6724
6894
  excludedNames: triggers.flatMap((t, index) => (Array.isArray(t.run.excludeExtensions) ? [{ label: triggerLabel(t, index), names: t.run.excludeExtensions, packages: t.run.packages !== false }] : [])),
6725
6895
  // Issue #501: every trigger that sets run.maxCostUsd, with the label the cap check names it by. The value
@@ -9629,7 +9799,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
9629
9799
  const words = suggestionEvidence(s);
9630
9800
  const size = `${formatMemory(current.memMiB)}, ${cpusText(current.cpuCenti)}`;
9631
9801
  const call = suggestionCall(s, limits);
9632
- const facts = [words.memoryFact, words.cpuFact].filter(Boolean);
9802
+ // Issue #865: `memoryCeiling` rides after the suggestion and BEFORE the two facts: it comes only with an OOM, whose
9803
+ // line is a warning, and a warn or fail label (the first DOCTOR_DETAILED_MAX, 80, of a run) is published cut at
9804
+ // DOCTOR_LABEL_MAX, so what a cut takes first is the facts (information with no call) and then this pointer's tail,
9805
+ // never a held raise, a refusal or a budget note, which come before it, nor the call: a raise carries its call in
9806
+ // its fix, and a lowering, an ok line with no model proposal, is published as its id and level only.
9807
+ const facts = [words.memoryCeiling, words.memoryFact, words.cpuFact].filter(Boolean);
9633
9808
  const factTail = facts.length > 0 ? `; ${facts.join("; ")}` : "";
9634
9809
  const held = s.memory.suggested === null && s.memory.held !== null;
9635
9810
  if (call === null && !held) {
@@ -9653,7 +9828,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
9653
9828
  const note = totals.length > 0 ? ` (note: ${totals.join(" and ")}; its budget is off or unknown, so the worker admits it)` : "";
9654
9829
  const raise = (s.memory.suggested ?? 0) > current.memMiB || (s.cpu.suggested ?? 0) > current.cpuCenti;
9655
9830
  const suggests = parts.length > 0 ? `its runs in the last ${SUGGEST_WINDOW_DAYS} days suggest ${parts.join(" and ")}` : "";
9656
- const label = `project ${id}: size ${size}; ${[heldText ? `in the last ${SUGGEST_WINDOW_DAYS} days ${heldText}` : "", suggests].filter(Boolean).join("; ")}${over.length > 0 ? `, but ${over.join(" and ")}, so a job of it would never fit this host` : ""}${note}${factTail}`;
9831
+ // The label without its facts, so a line that carries its call in the label (a lowering, below) prints the call
9832
+ // right after the suggestion it applies, and the no-call facts after that (#865, review). Reading order in the
9833
+ // terminal only: a lowering is an ok check with no model proposal, which `buildDoctorRecord` (doctor-results.mjs,
9834
+ // INT-DOCTOR-RESULTS-KEY) publishes as its id and level, with no label, so no publish cut ever reaches it.
9835
+ const head = `project ${id}: size ${size}; ${[heldText ? `in the last ${SUGGEST_WINDOW_DAYS} days ${heldText}` : "", suggests].filter(Boolean).join("; ")}${over.length > 0 ? `, but ${over.join(" and ")}, so a job of it would never fit this host` : ""}${note}`;
9836
+ const label = `${head}${factTail}`;
9657
9837
  // a size that would never fit is never offered as a call: applying it would turn a refusal into the same refusal.
9658
9838
  const apply = call === null ? "" : over.length > 0 ? "" : `apply it in the admin panel with ${call} (an operator confirms it; nothing applies a size by itself)`;
9659
9839
  const never = over.length > 0 ? "no call is offered: a job of that size would never fit this host" : "";
@@ -9664,7 +9844,7 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
9664
9844
  } else if (raise || over.length > 0 || totals.length > 0) {
9665
9845
  checks.push({ ok: false, warn: true, label, fix: apply || never });
9666
9846
  } else {
9667
- checks.push({ ok: true, label: `${label}; ${apply.replace(/ \(an operator confirms it; nothing applies a size by itself\)$/, "")}` });
9847
+ checks.push({ ok: true, label: `${head}; ${apply.replace(/ \(an operator confirms it; nothing applies a size by itself\)$/, "")}${factTail}` });
9668
9848
  }
9669
9849
  }
9670
9850
  return checks;
@@ -30,7 +30,7 @@ import { homedir } from "node:os";
30
30
  import { join } from "node:path";
31
31
  import { findEnvKeys } from "@earendil-works/pi-ai/compat";
32
32
  import { getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
33
- import { codemodeWorkersOf, DEFAULT_JOB_SIZE } from "./job-size.mjs";
33
+ import { codemodeWorkersOf, DEFAULT_JOB_SIZE, jobSizeEnv } from "./job-size.mjs";
34
34
  import { egressEnv } from "./egress.mjs";
35
35
  import { RECEIVER_ONLY_VARS, forgeSpec } from "./forges.mjs";
36
36
  import { apiKeyVariable } from "./provider-key.mjs";
@@ -637,6 +637,14 @@ export function buildContainerEnv({ size = DEFAULT_JOB_SIZE, provider, secondary
637
637
  // env-internal PI_CODEMODE_MAX_WORKERS: written into the job's closed env map here, never read from the worker's environment.
638
638
  env.PI_CODEMODE_MAX_WORKERS = String(codemodeWorkersOf(size.memMiB));
639
639
 
640
+ // Issue #865: the job's size, `PI_JOB_SIZE_CPUS` (its CPUs rounded up) and `PI_JOB_SIZE_MEMORY_MIB`, from the same
641
+ // resolved size as the container's flags (`jobSizeEnv`, which the sandbox shell shares). Inside the container a CPU
642
+ // count reads as the host ceiling (`availableParallelism()`) or the whole runtime (`nproc`), never the size, so these
643
+ // are the one place a flow or an image can size a per-CPU worker pool from. Information, not a bound, and read from
644
+ // no payload. Set after forwarding and secrets, for PI_CODEMODE_MAX_WORKERS' reason: both names are reserved in
645
+ // CONTAINER_ENV_NAMES, and this is the backstop if a refusal upstream is ever bypassed.
646
+ Object.assign(env, jobSizeEnv(size));
647
+
640
648
  // Issue #500: the runner sets these in its own environment, for its child processes, and no value from outside
641
649
  // may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
642
650
  // run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.