@edgehero/pi-dispatch 5.2.0 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@ import { AbstractConnector, Command, Redis } from "ioredis";
2
2
  import { Socket, connect as netConnect } from "node:net";
3
3
  import { connect as tlsConnect } from "node:tls";
4
4
  import { valkeyAuthRefusal } from "./valkey-auth.mjs";
5
- import { valkeySchemeOf } from "./podman-stack.mjs";
6
- import { decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
5
+ import { valkeySchemeOf, valkeyTarget } from "./podman-stack.mjs";
6
+ import { DEFAULT_VALKEY_URL, decodedUserinfo, judgeValkeyAtStart as judgeEndpointAtStart, judgedEndpoint, killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyDbRangeSentence, valkeyRefusal, valkeyUrlFor, valkeyUrlProblem } from "./valkey-endpoint.mjs";
7
7
 
8
8
  // Re-exported for the receiver and the admin, which import this module from the package (issues #464 and #468).
9
9
  export { killSwitchValkeyUrls, defaultValkeyContext, urlShown, useValkeyContext, valkeyClientContext, valkeyContextFromKeys, VALKEY_CONTEXT_KEYS, valkeyPasswordFor, valkeyUrlFor, valkeyUrlProblem };
@@ -89,6 +89,18 @@ if (!Redis.prototype[SCRUBS]) {
89
89
  */
90
90
  export const DB_REFUSED = Symbol.for("pi-dispatch.valkey-db-refused");
91
91
  const SELECT_HOOK = Symbol.for("pi-dispatch.valkey-select-refusal");
92
+
93
+ /**
94
+ * The database a client holds a refusal of (`DB_REFUSED`), as `{ db }` (null where the client no longer says which), or
95
+ * null when it holds none. Issue #845: the refusal is held for the client's life, so only a restart of a long-lived
96
+ * process tries the database again, and the console says so on every page from this.
97
+ */
98
+ export function refusedDatabaseOf(client) {
99
+ if (!client?.[DB_REFUSED]) return null;
100
+ const db = client.condition?.select ?? client.options?.db;
101
+ return { db: Number.isSafeInteger(db) ? db : null };
102
+ }
103
+
92
104
  /** Whether `err` is a server's refusal of a SELECT for a database it does not have. */
93
105
  function isSelectRangeError(err) {
94
106
  return Boolean(err) && typeof err === "object" && String(err.command?.name ?? "").toLowerCase() === "select" && /DB index is out of range|invalid DB index/i.test(String(err.message ?? ""));
@@ -172,16 +184,30 @@ export function givesUp(connection) {
172
184
  *
173
185
  * `freshContext` is for a persistent client of a long-running surface that names no context: each connect judges by the
174
186
  * context as it is then, not as it was at construction, so an operator's fix of the deployment's `.env` reaches a client
175
- * that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on. The password
176
- * is still the one read at construction, since ioredis has settled it before the connector runs.
187
+ * that has been refused since it started. Off by default, so a worker's judgement stays the one it booted on. Its password
188
+ * is read again before each connect too (issue #845, `freshPassword` below), so a password rotated in that `.env`, or one
189
+ * a bad line kept from being read at start, is what the next connect sends. Not its URL: a changed `VALKEY_URL` is
190
+ * another Valkey, which a running client is never moved to (the console says it needs a restart for that instead).
191
+ *
192
+ * `commandTimeoutMs` (issue #867) is a bound on each command of a `failFast` client, for a one-shot BullMQ queue that
193
+ * must not wait for as long as a Valkey that is connected and stopped answering (`kill -STOP`, a partition) stays so:
194
+ * `failFast` refuses while nothing is connected, and a command already sent had no bound. A command past it rejects with
195
+ * ioredis' "Command timed out"; one that was sent may still run when Valkey wakes, so its error says so
196
+ * (`valkeyMayStillRun`, the hook below), and it is never sent again on a later connection. It needs `failFast`: that
197
+ * client has no offline queue, and on one that has, ioredis sends a command that timed out there once Valkey returns.
198
+ * `makeRedisClient` takes such a command out of its own queue (`BoundedRedis`), but BullMQ builds this client itself, so
199
+ * nothing could. Never for a Worker: its blocking reads wait longer than any bound by design, and its connection is a
200
+ * persistent one, which this refuses.
177
201
  */
178
- export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false } = {}) {
202
+ export function parseConnection(url, { failFast = false, servername = null, context = null, judge = null, withoutPassword = false, freshContext = false, commandTimeoutMs = null } = {}) {
179
203
  const u = new URL(url);
180
204
  // PR #478's gate: a path that is not a database number was `db: NaN`, and the SELECT ioredis then sent failed outside
181
205
  // every caller's await (an unhandled rejection). Refused here, where every client of the project is made, as the
182
206
  // configuration refusal it is; `judgeValkeyAtStart` says it first at each start.
183
207
  const dbProblem = valkeyUrlProblem(url);
184
208
  if (dbProblem) throw valkeyRefusal(dbProblem);
209
+ const bounded = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0;
210
+ if (bounded && !failFast) throw new TypeError("parseConnection: a command bound (commandTimeoutMs) is for a failFast client only, which has no offline queue to send a command that timed out from later");
185
211
  const where = context ?? defaultValkeyContext();
186
212
  // Issue #468: the password this client sends, by `valkeyPasswordFor`'s rule (the URL's own, else VALKEY_PASSWORD from
187
213
  // the environment, else from the deployment `.env` for a loopback host). Here and nowhere else, so every client of the
@@ -194,7 +220,9 @@ export function parseConnection(url, { failFast = false, servername = null, cont
194
220
  // reports. `context` is where the client stands (`valkeyClientContext`), the process's own when not given.
195
221
  Connector: JudgedConnector,
196
222
  // `judge` is a test seam only: the function asked for the address (judgedEndpoint when not given).
197
- [JUDGE]: { url, context: where, ...(judge ? { judge } : {}), ...(freshContext && !context ? { freshContext: true } : {}), ...(failFast ? { failFast: true } : {}) },
223
+ // `freshPassword` (issue #845): a client that judges by the context as it is at each connect sends the password that
224
+ // context gives then, unless it was built to send none (`withoutPassword`, a probe of what another account sees).
225
+ [JUDGE]: { url, context: where, ...(judge ? { judge } : {}), ...(freshContext && !context ? { freshContext: true, ...(withoutPassword ? {} : { freshPassword: true }) } : {}), ...(failFast ? { failFast: true } : {}), ...(bounded ? { commandBound: true } : {}) },
198
226
  // WHATWG URL keeps an IPv6 literal's brackets in `hostname` (`[::1]`), and net.connect then looks up the name
199
227
  // "[::1]", which never resolves, so a `redis://[::1]:6379` VALKEY_URL never connected (issue #464, measured on
200
228
  // Fedora 44: ETIMEDOUT). The address is the part inside them.
@@ -216,7 +244,42 @@ export function parseConnection(url, { failFast = false, servername = null, cont
216
244
  retryStrategy: (attempts) => (attempts > 2 ? null : 200), // give up after ~2 tries
217
245
  }
218
246
  : {}),
247
+ // ioredis' resend of what was on a connection that died is off with the bound: the caller was told such a command
248
+ // failed, and a resend on the next connection would make it run after that.
249
+ ...(bounded ? { commandTimeout: commandTimeoutMs, autoResendUnfulfilledCommands: false } : {}),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * What `BoundedRedis` does for its own clients, done for a client `parseConnection` built with a command bound (issue
255
+ * #867), which BullMQ constructs itself as a plain ioredis client, so it cannot be a subclass. On the prototype, like the
256
+ * scrub above, and inert for every client without the bound (a worker's, a receiver's, every `makeRedisClient` client).
257
+ *
258
+ * Two things. The command's timer is unref'd, since ioredis' is ref'd and a one-shot that has said its last line must not
259
+ * wait out a bound on a command nobody awaits. And a command that was WRITTEN (it is the newest entry of the client's
260
+ * command queue right after the send; without an offline queue, one that was not is refused at once and never sent) and
261
+ * then rejected with anything but Valkey's own reply carries `valkeyMayStillRun`: it reached a socket, so Valkey may run
262
+ * it when it wakes, or ran it before its answer was lost. Measured against the pinned ioredis: a SET past the bound on a
263
+ * `kill -STOP`ed Valkey rejects with "Command timed out" and is applied once the process is continued. Besides the
264
+ * timeout that is "Connection is closed.", for a command on a connection whose retries ran out. A refusal this project
265
+ * raised itself (`valkeyRefused`, a database the server does not have) is no lost answer.
266
+ */
267
+ const COMMAND_BOUND_HOOK = Symbol.for("pi-dispatch.valkey-command-bound");
268
+ if (!Redis.prototype[COMMAND_BOUND_HOOK]) {
269
+ const sendCommand = Redis.prototype.sendCommand;
270
+ Redis.prototype.sendCommand = function sendCommandWithBound(command, stream) {
271
+ const answer = sendCommand.call(this, command, stream);
272
+ if (this.options?.[JUDGE]?.commandBound !== true) return answer;
273
+ command._commandTimeoutTimer?.unref?.();
274
+ if (this.commandQueue?.peekBack?.()?.command !== command) return answer;
275
+ answer?.catch?.((err) => {
276
+ if (!err || typeof err !== "object" || err.name === "ReplyError" || err.valkeyRefused) return;
277
+ if (err.message === "Command timed out") err.code ??= "ETIMEDOUT";
278
+ err.valkeyMayStillRun = true;
279
+ });
280
+ return answer;
219
281
  };
282
+ Redis.prototype[COMMAND_BOUND_HOOK] = true;
220
283
  }
221
284
 
222
285
  /**
@@ -382,6 +445,60 @@ export function authRefusalFor(state, url, context) {
382
445
  */
383
446
  const JUDGE = "piDispatchValkeyJudge";
384
447
 
448
+ /** The connector's method that reads a `freshPassword` client's password again (`JudgedConnector`, the hook below it). */
449
+ const FRESH_PASSWORD = Symbol.for("pi-dispatch.valkey-fresh-password");
450
+
451
+ /** Set on a connector whose last refresh declined a `.env` password other than the one its client holds (see there). */
452
+ const PASSWORD_DECLINED = Symbol.for("pi-dispatch.valkey-password-declined");
453
+
454
+ /**
455
+ * Whether two Valkey URLs name one Valkey server, by `valkeyTarget`'s host and port (the scheme alias, a database path
456
+ * and a trailing `/` aside): a password belongs to the server. False for one that does not parse. `localhost` and
457
+ * `127.0.0.1` are two names here, as for `valkeyEndpointTag`: which address a name reaches is the judge's to say.
458
+ */
459
+ function sameValkeyServer(a, b) {
460
+ const x = valkeyTarget(a);
461
+ const y = valkeyTarget(b);
462
+ return !x.error && !y.error && x.host === y.host && x.port === y.port;
463
+ }
464
+
465
+ /**
466
+ * What a `freshPassword` client's refresh does with `context` as it is now (`JudgedConnector`'s method below has the
467
+ * rules): `{ take: true, password }` to send `password` (null: none) from the next connect on; `{ take: false, declined }`
468
+ * to keep the one it holds (`held`), where `declined` is true when the `.env` gives another password than `held` for a
469
+ * Valkey the client does not dial as it dials it, false when nothing is being held back, and null when the context
470
+ * cannot tell (it could not be read, finds no file, or is another deployment's). Pure: one rule for the refresh before
471
+ * each connect and for `passwordDeclinedOf`, so the two never disagree about what is held back.
472
+ */
473
+ function freshPasswordVerdict(judge, context, held) {
474
+ if (!context || context.error || context.absent === true) return { take: false, declined: null };
475
+ if ((context.envPath ?? null) !== (judge?.context?.envPath ?? null)) return { take: false, declined: null };
476
+ const { password, from } = valkeyPasswordFor(judge.url, context);
477
+ const fromFile = from !== "VALKEY_URL" && from !== "the environment";
478
+ if (fromFile && !sameValkeyServer(context.url?.file ?? DEFAULT_VALKEY_URL, judge.url)) return { take: false, declined: (password ?? null) !== (held ?? null) };
479
+ return { take: true, password: password ?? null };
480
+ }
481
+
482
+ /**
483
+ * Issue #845, review of PR #878: whether a `freshPassword` client holds back a password the deployment's `.env` gives,
484
+ * other than the one it holds, because the file does not name the Valkey the client dials as it dials it. True only
485
+ * while a reconnect has declined one (recorded by the refresh, which runs on connect alone) AND the `.env` as it is now
486
+ * still gives such a password (`freshPasswordVerdict` over the context read now): a file put back to the held password,
487
+ * or changed to name the client's Valkey, clears it at once, with no reconnect, and so does a file that cannot be read
488
+ * now (the console says that instead). Reads the client's context once; touches no Valkey.
489
+ */
490
+ export function passwordDeclinedOf(client) {
491
+ const connector = client?.connector;
492
+ if (connector?.[PASSWORD_DECLINED] !== true) return false;
493
+ const judge = connector.options?.[JUDGE];
494
+ if (judge?.freshPassword !== true) return false;
495
+ try {
496
+ return freshPasswordVerdict(judge, defaultValkeyContext(), connector.options.password).declined === true;
497
+ } catch {
498
+ return false;
499
+ }
500
+ }
501
+
385
502
  /**
386
503
  * ioredis' connector for every client of this project (issue #464, gate round 2 follow-up): on each connect it asks
387
504
  * `judgedEndpoint` for the address, then dials that literal (with a `rediss:` URL's name kept as the TLS servername),
@@ -408,6 +525,57 @@ export class JudgedConnector extends AbstractConnector {
408
525
  return !this[DB_REFUSED];
409
526
  }
410
527
 
528
+ /**
529
+ * Issue #845: a `freshPassword` client's password, as `valkeyPasswordFor` reads it from the context as it is now, put
530
+ * where ioredis reads it for the connect about to start (the hook below). Here and not in `connect()`: by the time
531
+ * ioredis asks the connector for a stream it has already built that connect's AUTH from `options.password`. The options
532
+ * are the client's own object (ioredis hands its `options` to the Connector it constructs), so this is what the client
533
+ * sends. A password from VALKEY_URL's userinfo comes back as it is, so it stays fixed. The last password is kept by a
534
+ * context that:
535
+ * - throws, or names an `error` (a `.env` that cannot be read, or a line in it the service's loader reads
536
+ * differently): it cannot say which password the file holds, and dropping the one that worked would refuse a client
537
+ * that is fine;
538
+ * - finds no file (`absent`): a `.env` gone, for good or for the moment an editor replaces it, is not a file that
539
+ * sets no password, so it does not drop one either (the console says to put the file back before a restart);
540
+ * - would answer from the `.env` while that file does not name the Valkey this client dials: a password the file
541
+ * gives (or the lack of one) belongs to the Valkey the file's VALKEY_URL names, the default one when it names
542
+ * none, and a held client never moves to another Valkey. Review of PR #878, measured with two Valkeys: one
543
+ * deployment moving to another Valkey changes both lines together, a symlinked deployment folder repointed reads
544
+ * another deployment's file through the same path, and a shell that exports VALKEY_URL keeps the client on its URL
545
+ * whatever the file says; in each the new Valkey's password went to the old one, which refused it, and the pages
546
+ * lost the old Valkey too. The check is against the URL the client dials, not the context's own choice of URL
547
+ * (`valkeyUrlFor`, which prefers this shell's and so never moved), and by the server each names (`sameValkeyServer`:
548
+ * `valkeyTarget`'s host and port, so a scheme alias, a database path or a trailing `/` is the same Valkey, while
549
+ * `localhost` and `127.0.0.1` stay two names, as `valkeyEndpointTag` keeps them). A rotation keeps the file's URL,
550
+ * so it is taken, and so is a password a bad line hid at start once the line is fixed, when the file names the
551
+ * client's Valkey. A password declined here that is not the one the client holds is recorded (`PASSWORD_DECLINED`,
552
+ * `passwordDeclinedOf`), so the console names VALKEY_PASSWORD as needing a restart rather than going on refused in
553
+ * silence (a shell that exports `redis://localhost:6440` over a file's `redis://127.0.0.1:6440`, say);
554
+ * - is read from another `.env` path than the one the client was built with: the pi extension's context follows the
555
+ * deployment pointer, and that alone says the file is another deployment's.
556
+ * A password from this process's environment, or from VALKEY_URL's own userinfo, is not the file's, and is taken as it
557
+ * comes. A context that passes all of these and gives no password drops it, as a restart would.
558
+ */
559
+ [FRESH_PASSWORD]() {
560
+ const judge = this.options?.[JUDGE];
561
+ if (judge?.freshPassword !== true) return;
562
+ let context;
563
+ try {
564
+ context = defaultValkeyContext();
565
+ } catch {
566
+ return;
567
+ }
568
+ const verdict = freshPasswordVerdict(judge, context, this.options.password);
569
+ if (!verdict.take) {
570
+ // Recorded only where the context could tell; `passwordDeclinedOf` reads it against the file as it is then.
571
+ if (verdict.declined !== null) this[PASSWORD_DECLINED] = verdict.declined;
572
+ return;
573
+ }
574
+ this[PASSWORD_DECLINED] = false;
575
+ if (verdict.password) this.options.password = verdict.password;
576
+ else delete this.options.password;
577
+ }
578
+
411
579
  connect() {
412
580
  const { options } = this;
413
581
  // A database the server refused stays refused: no reconnect goes ready on another one.
@@ -445,6 +613,39 @@ export class JudgedConnector extends AbstractConnector {
445
613
  /** How `JudgedConnector` dials, net and tls: a seam, so a test sees the options a pinned connect is made with. */
446
614
  JudgedConnector.dial = { net: netConnect, tls: tlsConnect };
447
615
 
616
+ /**
617
+ * Where a `freshPassword` client's password is read again (issue #845): before EVERY connect, its first and each
618
+ * reconnect, on the prototype like the hooks above, so a client ioredis or BullMQ duplicates keeps it too. Measured
619
+ * against the pinned ioredis 5.11.1 (`worker/test/valkey-clients.test.mjs` holds each line): `connect()` runs
620
+ * `_connect()`, which builds the connect's AUTH (`this.condition.auth`) from `this.options.password` before it asks the
621
+ * connector for a stream; a refused AUTH (WRONGPASS) and a refused ready check (NOAUTH) go to `recoverFromFatalError`,
622
+ * which disconnects to reconnect, and the reconnect timer calls `self.connect()`. So a rotated password reaches a client
623
+ * that has been refused since the rotation at its next try, by its own retry strategy, with no rebuild.
624
+ *
625
+ * Delegated to the client's connector, so the context read is the one of the copy of this module that built the client
626
+ * (a bundle carries its own copy beside the shared ioredis, and the first copy loaded installs this hook). Inert for every
627
+ * other client. A throw here would escape ioredis' reconnect timer, which does not catch one, so it is caught: the last
628
+ * password is kept.
629
+ *
630
+ * REJECTED: rebuilding the client when its password changes, which every holder of one (the console's audit copy, the
631
+ * fleet reader, a page's reads) would have to learn to swap; wrapping `connect` on each client `makeRedisClient` makes,
632
+ * which misses a duplicate and a client BullMQ builds from `parseConnection`'s options; and reading the password in
633
+ * `JudgedConnector.connect`, which is too late for that connect's AUTH (above).
634
+ */
635
+ const FRESH_PASSWORD_HOOK = Symbol.for("pi-dispatch.valkey-fresh-password-hook");
636
+ if (!Redis.prototype[FRESH_PASSWORD_HOOK]) {
637
+ const connect = Redis.prototype.connect;
638
+ Redis.prototype.connect = function connectWithFreshPassword(...args) {
639
+ try {
640
+ this.connector?.[FRESH_PASSWORD]?.();
641
+ } catch {
642
+ // The last password stays.
643
+ }
644
+ return connect.apply(this, args);
645
+ };
646
+ Redis.prototype[FRESH_PASSWORD_HOOK] = true;
647
+ }
648
+
448
649
  /**
449
650
  * Whether `connection` (options for BullMQ, or an ioredis client) connects through `JudgedConnector`. `makeQueue` and the
450
651
  * worker refuse any other, so no Queue or Worker of this project can be built on an unjudged connection.
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
 
@@ -2191,7 +2191,8 @@ export async function collectChecks(shellVars, seams) {
2191
2191
  const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: stagedProbes.readFile, fileExists: stagedProbes.fileExists, readDir: stagedProbes.readDir });
2192
2192
  const groups = new Map();
2193
2193
  for (const f of flows) {
2194
- 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]));
2195
2196
  if (!groups.has(key)) groups.set(key, { ...f, labels: [], triggers: [] });
2196
2197
  groups.get(key).labels.push(f.label);
2197
2198
  groups.get(key).triggers.push(f.index);
@@ -2209,7 +2210,7 @@ export async function collectChecks(shellVars, seams) {
2209
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",
2210
2211
  });
2211
2212
  // No tier can hold it, and a name that fails the charset is not published.
2212
- 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 });
2213
2214
  continue;
2214
2215
  }
2215
2216
  const probes = flowProbes({ fileExists, fs: flowFs });
@@ -2219,7 +2220,7 @@ export async function collectChecks(shellVars, seams) {
2219
2220
  // trigger that withholds the packages did not, and another trigger's unreadable folder says nothing of this one's.
2220
2221
  const blind = probes.blind() || (g.packages !== false && stagedProbes.blind());
2221
2222
  const tier = found.tier === "none" && blind ? "unknown" : found.tier;
2222
- 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 });
2223
2224
  if (resolved) {
2224
2225
  checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
2225
2226
  } else {
@@ -6883,18 +6884,12 @@ function readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTable
6883
6884
  // two triggers naming the same flow with different skillsDirs are two different questions.
6884
6885
  // The label is how a line names its trigger: cron entries by their id, id-less webhook
6885
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.
6886
6890
  flows: triggers
6887
- .map((t, index) => ({
6888
- index,
6889
- label: triggerLabel(t, index),
6890
- flow: t.run.flow,
6891
- kind: t.run.kind,
6892
- folder: typeof t.run.folder === "string" ? t.run.folder : null,
6893
- skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
6894
- packages: t.run.packages !== false,
6895
- excludeExtensions: Array.isArray(t.run.excludeExtensions) ? t.run.excludeExtensions : [],
6896
- }))
6897
- .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),
6898
6893
  // Issue #800: each trigger's run.excludeExtensions, for the line that says a name is on neither side of this host.
6899
6894
  excludedNames: triggers.flatMap((t, index) => (Array.isArray(t.run.excludeExtensions) ? [{ label: triggerLabel(t, index), names: t.run.excludeExtensions, packages: t.run.packages !== false }] : [])),
6900
6895
  // Issue #501: every trigger that sets run.maxCostUsd, with the label the cap check names it by. The value
@@ -9804,7 +9799,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
9804
9799
  const words = suggestionEvidence(s);
9805
9800
  const size = `${formatMemory(current.memMiB)}, ${cpusText(current.cpuCenti)}`;
9806
9801
  const call = suggestionCall(s, limits);
9807
- 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);
9808
9808
  const factTail = facts.length > 0 ? `; ${facts.join("; ")}` : "";
9809
9809
  const held = s.memory.suggested === null && s.memory.held !== null;
9810
9810
  if (call === null && !held) {
@@ -9828,7 +9828,12 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
9828
9828
  const note = totals.length > 0 ? ` (note: ${totals.join(" and ")}; its budget is off or unknown, so the worker admits it)` : "";
9829
9829
  const raise = (s.memory.suggested ?? 0) > current.memMiB || (s.cpu.suggested ?? 0) > current.cpuCenti;
9830
9830
  const suggests = parts.length > 0 ? `its runs in the last ${SUGGEST_WINDOW_DAYS} days suggest ${parts.join(" and ")}` : "";
9831
- 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}`;
9832
9837
  // a size that would never fit is never offered as a call: applying it would turn a refusal into the same refusal.
9833
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)`;
9834
9839
  const never = over.length > 0 ? "no call is offered: a job of that size would never fit this host" : "";
@@ -9839,7 +9844,7 @@ export function sizeSuggestionChecks({ projects = [], limits = [], env = {}, rec
9839
9844
  } else if (raise || over.length > 0 || totals.length > 0) {
9840
9845
  checks.push({ ok: false, warn: true, label, fix: apply || never });
9841
9846
  } else {
9842
- 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}` });
9843
9848
  }
9844
9849
  }
9845
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.
@@ -49,6 +49,11 @@ const API_ROOT = "https://api.github.com";
49
49
  const USER_AGENT = "pi-dispatch-setup-github";
50
50
  // The repo homepage, shown on the App's about page — informational, not a trust anchor.
51
51
  const HOMEPAGE = "https://github.com/edgehero/pi-dispatch";
52
+ // The webhook URL of an App made with --no-webhook (issue #874). GitHub requires one even for an inactive hook. A
53
+ // `.invalid` name (RFC 6761) never resolves, so nothing GitHub sends there reaches any host: not the ping an inactive
54
+ // hook still got at creation (in the measurement that `buildManifest`'s comment cites), and not an event if someone
55
+ // ticks Active later without changing the URL. A real host would get both, and a domain can change owner.
56
+ export const NO_WEBHOOK_URL = "https://no-webhook.invalid/";
52
57
  // GitHub app names are GLOBALLY unique and capped at 34 characters.
53
58
  const NAME_MAX = 34;
54
59
 
@@ -363,7 +368,11 @@ function printSummary(say, summary, { noWebhook }) {
363
368
  say("\nNext:\n");
364
369
  if (noWebhook) {
365
370
  say(" the App was created with its webhook INACTIVE (--no-webhook) — the polling-ready shape.\n");
366
- say(" Polling delivery is a follow-up; until it lands, forge triggers for this App will not fire on their own.\n");
371
+ say(" start the poller so events reach the queue: `pi-dispatch-receiver poll`. Under GITHUB_AUTH_SOURCE=app it\n");
372
+ say(" watches the repositories of the installation GITHUB_APP_INSTALLATION_ID names; POLL_REPOS names a list of\n");
373
+ say(" your own instead. See docs/polling.md.\n");
374
+ say(` the App's webhook URL is a placeholder, ${NO_WEBHOOK_URL}, which nothing can receive. Moving to webhooks\n`);
375
+ say(" later takes a webhook secret and an order of steps: docs/polling.md, \"Moving to webhooks later\".\n");
367
376
  } else {
368
377
  say(" start the receiver so deliveries have somewhere to land: `pi-dispatch-receiver`\n");
369
378
  say(" (or, where the folder holds deploy/docker-compose.yml, its receiver profile), reachable at the --webhook-url you gave GitHub.\n");
@@ -402,8 +411,12 @@ export function sanitizeAppName(raw) {
402
411
  * The manifest GitHub converts into the App — the narrowest shape this system needs
403
412
  * (SECURITY.md's auth-source ladder): write on exactly the three surfaces jobs touch, metadata read
404
413
  * because the API requires it, private, and only the event families the triggers file can name.
405
- * `hook_attributes` carries the receiver URL, or `active: false` under --no-webhook — the
406
- * no-public-URL shape the polling transport will consume.
414
+ * `hook_attributes` carries the receiver URL, or under --no-webhook an INACTIVE hook at `NO_WEBHOOK_URL`. GitHub
415
+ * refuses a manifest whose `hook_attributes` has no `url`, inactive or not ("url" wasn't supplied, issue #874), and one
416
+ * that leaves `hook_attributes` out while it subscribes events ("Hook url cannot be blank"). What it accepts was
417
+ * measured against github.com on 2026-11-09 by another project's throwaway Apps: a `.invalid` URL with
418
+ * `active: false` was accepted, only `localhost` and `127.0.0.1` were refused as not reachable over the public
419
+ * Internet, and the App with the inactive hook recorded one delivery, its creation's ping, and no event after it.
407
420
  *
408
421
  * `pull_request_review` (issue #66) is subscribed for every new App, armed trigger or not. That is the
409
422
  * posture `pull_request` already has for a label-only deployment: an unsubscribed event cannot be turned
@@ -415,7 +428,7 @@ export function buildManifest({ name, redirectUrl, webhookUrl }) {
415
428
  name,
416
429
  url: HOMEPAGE,
417
430
  redirect_url: redirectUrl,
418
- hook_attributes: webhookUrl ? { url: webhookUrl } : { active: false },
431
+ hook_attributes: webhookUrl ? { url: webhookUrl } : { url: NO_WEBHOOK_URL, active: false },
419
432
  public: false,
420
433
  default_permissions: { contents: "write", pull_requests: "write", issues: "write", metadata: "read" },
421
434
  default_events: ["issues", "issue_comment", "pull_request", "pull_request_review"],
@@ -14,9 +14,11 @@
14
14
  * charset, on the way in AND on the way out, since another host wrote what a reader parses. An overlay extension's first
15
15
  * path segment and whether it is a file or a folder; a skill's folder name; a server's name, transport, where it is
16
16
  * (`mcpServerWhere`: a url's host and port, or a command's base name, never a header, an argument or a value), exposure,
17
- * whether it is on, and the variable NAMES it references; a package's name and version; a trigger's position, flow name and
18
- * the tier it resolved in. No path, no value and no tool name: what a server or an extension offers is known only once it
19
- * runs, and the run's own record says that. These are the same facts the viewer's Connections page shows; a model's
17
+ * whether it is on, and the variable NAMES it references; a package's name and version; a trigger's position, flow name,
18
+ * the tier it resolved in and a short hex digest of the trigger fields that answer was judged from (`flowBasisOf`). No path,
19
+ * no value and no tool name: what a server or an extension offers is known only once it runs, and the run's own record says
20
+ * that. The digest holds a path (`run.folder`, `run.skillsDir`) only as a hash cut to 8 hex digits, which names nothing a
21
+ * reader can read back. These are the same facts the viewer's Connections page shows; a model's
20
22
  * projection (`modelConnections`) carries counts in place of `where` and the key names, and one of these rows gets the
21
23
  * same treatment when a tool reads it.
22
24
  *
@@ -27,6 +29,7 @@
27
29
  * folders on the host and runs nothing: a frontmatter `name:` rename is invisible, and what a run really loaded is its
28
30
  * own record's `loaded`. Where a read cannot say, it says so (`unread`), never an empty list that reads as none.
29
31
  */
32
+ import { createHash } from "node:crypto";
30
33
  import * as nodeFs from "node:fs";
31
34
  import { join, resolve, sep } from "node:path";
32
35
  import { DOCTOR_HOSTS, DOCTOR_HOSTS_MAX, DOCTOR_RESULTS_TTL_MS } from "./doctor-results.mjs";
@@ -73,6 +76,52 @@ const KEY_NAME_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
73
76
  const WHERE_RE = /^[A-Za-z0-9._:[\]-]{1,255}$/;
74
77
  const INDEX_MAX = 1_000_000;
75
78
 
79
+ /**
80
+ * The trigger fields `flowTierOf` reads, by the names its first argument gives them (issue #869). A flow row's `basis` is a
81
+ * digest of exactly these, so a test holds this list to that argument's own names: a field `flowTierOf` came to read that
82
+ * this list left out would let an edit to it keep the console answering for the file as it was.
83
+ */
84
+ export const FLOW_INPUT_FIELDS = Object.freeze(["flow", "folder", "skillsDir", "packages", "excludeExtensions"]);
85
+ /**
86
+ * How long a basis is, in hex digits: 32 bits. A collision costs one stale answer read as current, which is what every edit
87
+ * cost before the basis, and the triggers file has no author but the operator, so nobody gains by aiming for one. Per edit the
88
+ * odds are one in about four billion. REJECTED: a longer digest, which a flow row carries 128 times over in a record that already
89
+ * trims its flow rows at its worst (`FIT_ORDER`), so each digit would put a trigger's answer out of the record on such a host.
90
+ */
91
+ export const FLOW_BASIS_HEX = 8;
92
+ const FLOW_BASIS_RE = new RegExp(`^[0-9a-f]{${FLOW_BASIS_HEX}}$`);
93
+
94
+ /**
95
+ * What `flowTierOf` is asked of a trigger as the loader parsed it (`parseTriggers`' entry): doctor's flow lines and its published
96
+ * rows ask it this, and nothing else does. A field that is not there reads as `flowTierOf`'s own default. A disarmed one-shot is
97
+ * the loader's sentinel, whose `run` is empty, so it names no flow and doctor publishes no row for it.
98
+ */
99
+ export function flowInputOf(trigger) {
100
+ const run = isObject(trigger?.run) ? trigger.run : {};
101
+ return {
102
+ flow: typeof run.flow === "string" ? run.flow : null,
103
+ folder: typeof run.folder === "string" ? run.folder : null,
104
+ skillsDir: typeof run.skillsDir === "string" ? run.skillsDir : null,
105
+ packages: run.packages !== false,
106
+ excludeExtensions: Array.isArray(run.excludeExtensions) ? run.excludeExtensions.filter((name) => typeof name === "string") : [],
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The basis of a flow row (issue #869): a digest of what `flowTierOf` was asked of this trigger, from the trigger as the loader
112
+ * parsed it. Doctor writes it beside each answer, and the console computes it from its own parse of the file it reads, through
113
+ * this one function, so the two cannot compute it two ways. Where they differ, the answer was judged for another version of the file.
114
+ * `flowTierOf` only asks whether a name is in `excludeExtensions`, so the list's order and a repeat change nothing here either.
115
+ * REJECTED: leaving the list out under `"packages": false`, where `flowTierOf` does not look at it. An edit there would then keep
116
+ * the answer, at the price of a copy of `flowTierOf`'s branches here that would have to move with them, and the cost of not doing
117
+ * it is an answer read as unknown until doctor runs on the file as it is, the safe direction.
118
+ */
119
+ export function flowBasisOf(trigger) {
120
+ const input = flowInputOf(trigger);
121
+ const canonical = FLOW_INPUT_FIELDS.map((field) => (field === "excludeExtensions" ? [...new Set(input[field])].sort() : input[field]));
122
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, FLOW_BASIS_HEX);
123
+ }
124
+
76
125
  /** A first path segment: the shape a run's record holds, less the two a folder listing never returns and a path could. */
77
126
  const extensionOf = (row) => (isObject(row) && typeof row.name === "string" && EXTENSION_NAME_RE.test(row.name) && row.name !== "." && row.name !== ".." && LOADS_EXTENSION_KINDS.includes(row.kind) ? { name: row.name, kind: row.kind } : null);
78
127
  const skillOf = (name) => (typeof name === "string" && SKILL_NAME_RE.test(name) ? name : null);
@@ -83,7 +132,10 @@ const flowOf = (row) => {
83
132
  if (flow === null && row.flow !== null) return null;
84
133
  const pkg = row.package === null ? null : isPackageName(row.package) ? row.package : undefined;
85
134
  if (pkg === undefined || (pkg !== null && row.tier !== "package")) return null;
86
- return { trigger: row.trigger, flow, tier: row.tier, package: pkg, repo: row.repo };
135
+ // Absent is a row from a release before issue #869, which a reader answers from as it did. Any other shape is refused, never
136
+ // read as absent: that would put a row of unknown basis back on the older rule, which answers whatever the file now says.
137
+ if (row.basis !== undefined && (typeof row.basis !== "string" || !FLOW_BASIS_RE.test(row.basis))) return null;
138
+ return { trigger: row.trigger, flow, tier: row.tier, package: pkg, repo: row.repo, ...(row.basis === undefined ? {} : { basis: row.basis }) };
87
139
  };
88
140
  /** One server's row, or null, with how many of its keys its cap left out. */
89
141
  const serverOf = (row) => {
@@ -346,6 +398,8 @@ export function hostsAgree(rows) {
346
398
  * resolved, checked, unknown the words of doctor's own line, which name folders and so are never published
347
399
  *
348
400
  * `repoFlowAtHead(folder, flow)` answers `present`, `absent` or anything else for not known. `staged` is `readStagedSkills`.
401
+ * The first argument is `flowInputOf`'s, and its names are `FLOW_INPUT_FIELDS`: a new one goes in both, or a flow row's basis
402
+ * would not change when it does (`worker/test/host-loads.test.mjs` reads them off this signature).
349
403
  */
350
404
  export async function flowTierOf({ flow, folder = null, skillsDir = null, packages = true, excludeExtensions = [] }, { repoFlowAtHead, fileExists, overlay = null, staged = { skills: [], unenumerable: [] } }) {
351
405
  let tier = null;
package/src/job-size.mjs CHANGED
@@ -106,6 +106,33 @@ export function codemodeWorkersOf(memMiB) {
106
106
  return Math.max(1, Math.floor(memMiB / (2 * CODEMODE_WORKER_HEAP_MIB)));
107
107
  }
108
108
 
109
+ /**
110
+ * Issue #865: a size's CPUs as a whole count, rounded UP (`1.5` is 2, `0.25` is 1), for a tool's `-j` or worker flag,
111
+ * which takes no fraction. Up, not down, so no size reads as 0 and a size between two counts gets the larger one: it is
112
+ * a pool size under a CPU WEIGHT, and the job may already use idle cores beyond its size (the `--cpus` ceiling above).
113
+ */
114
+ export function wholeCpusOf(cpuCenti) {
115
+ return Math.ceil(cpuCenti / 100);
116
+ }
117
+
118
+ /**
119
+ * Issue #865: the job's size as its own processes can read it, `{ PI_JOB_SIZE_CPUS, PI_JOB_SIZE_MEMORY_MIB }`, both
120
+ * decimal strings. A job's CPU size is a weight that nothing inside the container can see: `os.availableParallelism()`
121
+ * reads the cgroup's `cpu.max`, which is the host ceiling (`--cpus`, the same for every job), and `nproc` reads the
122
+ * affinity mask, which is the whole runtime. So a tool that starts one worker per CPU it sees (jest and vitest by
123
+ * default) starts one per CEILING CPU, all inside the size's memory, which is the hard bound. These two values are what
124
+ * a flow, an image or the agent's commands size such a pool from instead.
125
+ *
126
+ * INFORMATION, NOT A BOUND: the size is resolved at pickup and enforced by the container's flags; nothing reads these
127
+ * back. One function, so the job's closed env map (`env-allowlist.mjs`) and the operator's sandbox shell
128
+ * (`sandbox.mjs`) cannot spell them two ways. Not `PI_JOB_CPUS` and `PI_JOB_MEMORY`: those name the DEPLOYMENT's default
129
+ * size in `.env`, and a project row can give a job another one.
130
+ */
131
+ export function jobSizeEnv(size) {
132
+ assertJobSize(size);
133
+ return { PI_JOB_SIZE_CPUS: String(wholeCpusOf(size.cpuCenti)), PI_JOB_SIZE_MEMORY_MIB: String(size.memMiB) };
134
+ }
135
+
109
136
  /** The scope prefix of a project row, as `scoped-limits.mjs` spells it (`PROJECT_SCOPE_PREFIX`; a test holds them equal). */
110
137
  const PROJECT_ROW_PREFIX = "project:";
111
138