@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "5.2.0",
3
+ "version": "5.2.1",
4
4
  "type": "module",
5
5
  "description": "The pi-dispatch worker and CLI: go from one AI coding agent to many in the background, running the pi coding agent as a self-hosted service, one locked down Docker or Podman container per job, with spend caps checked before anything is spent, plus init, up, doctor and service install.",
6
6
  "keywords": [
package/src/auth-cli.mjs CHANGED
@@ -2,7 +2,9 @@ import { createInterface } from "node:readline/promises";
2
2
  import { loadConfig } from "./config.mjs";
3
3
  import { configuredSubscriptions } from "./subscription-config.mjs";
4
4
  import { subscriptionIdentity } from "./subscription-runtime.mjs";
5
- import { makeRedisClient } from "./connection.mjs";
5
+ import { makeRedisClient, urlShown, VALKEY_COMMAND_TIMEOUT_MS } from "./connection.mjs";
6
+ import { valkeyDownHint } from "./valkey-auth.mjs";
7
+ import { boundedText } from "./cancel-cli.mjs";
6
8
  import { openBrowser } from "./open-browser.mjs";
7
9
  import { authError } from "./auth-store.mjs";
8
10
  import { OPENAI_LOGIN_INSTRUCTIONS } from "./oauth-openai.mjs";
@@ -22,6 +24,26 @@ export function authSelectAnswer(question, answer) {
22
24
  return input;
23
25
  }
24
26
 
27
+ /**
28
+ * The line for a Valkey failure (issue #867), or null when `error` is no Valkey failure: Valkey's own refusal (a reply),
29
+ * a configuration refusal this project raised (`valkeyRefused`), or a command that got no answer (a bound that fired, or
30
+ * one that reached a Valkey and lost its answer). `seen` is the client's last `error` event, which says why a client
31
+ * that never connected got none ("nothing answers VALKEY_URL ...") where the timeout says only that it got none. After a
32
+ * login or a logout the failure is the status read's, since neither sends anything to Valkey. `casLost` says the
33
+ * trial's compare-and-set itself was sent and its answer lost, so the grant may have landed and is not said to have
34
+ * failed; a read lost before it means the compare-and-set was never sent, and nothing is said of a grant.
35
+ */
36
+ export function authValkeyLine(error, { command, url, seen = null, casLost = false }) {
37
+ if (error?.valkeyRefused === true) return `Subscription authentication: ${boundedText(error.message)}`;
38
+ const refused = error?.name === "ReplyError";
39
+ // ioredis' own timeout by its words, not by `ETIMEDOUT` alone, which a provider's request can carry too.
40
+ if (!refused && error?.message !== "Command timed out" && error?.valkeyMayStillRun !== true) return null;
41
+ const lead = `Subscription authentication: ${command === "login" || command === "logout" ? `the ${command} finished, but ` : ""}Valkey at ${urlShown(url)}`;
42
+ if (refused) return `${lead} refused ${boundedText(typeof error?.command?.name === "string" ? error.command.name.toUpperCase() : "a command")} (${boundedText(error.message)}).`;
43
+ const trial = command === "trial" && casLost === true ? " The trial grant may or may not have been made." : "";
44
+ return `${lead} did not answer (${boundedText(seen ?? error?.message)}).${trial} Run \`pi-dispatch auth status\` once it answers: ${valkeyDownHint(url)}`;
45
+ }
46
+
25
47
  export async function runAuth(args, { env = process.env, write = (value) => process.stdout.write(value), writeErr = (value) => process.stderr.write(value), configure = configuredSubscriptions, configFn = loadConfig, redisFn = makeRedisClient, interaction = null, now = Date.now, terminalInput = process.stdin, terminalOutput = process.stdout, createPrompt = createInterface, openBrowserFn = openBrowser } = {}) {
26
48
  const [command = "status", id, ...rest] = args;
27
49
  if (!["status", "login", "logout", "usage", "trial"].includes(command) || rest.length > 0 || (command === "status" ? id !== undefined : !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id ?? ""))) {
@@ -30,10 +52,40 @@ export async function runAuth(args, { env = process.env, write = (value) => proc
30
52
  }
31
53
  let redis;
32
54
  let prompt;
55
+ let config;
56
+ // The client's last error event since it was last ready, for the line a Valkey failure ends with: cleared when it goes
57
+ // ready, so a line after a long login does not quote an outage that ended during it.
58
+ let seen = null;
59
+ let valkeyFailed = false;
60
+ // Whether the one write `auth` sends, a quota compare-and-set (EVAL), was sent and lost its answer, so the line says a
61
+ // trial grant may or may not have been made only then, never of a read lost before the write was sent.
62
+ let casLost = false;
33
63
  try {
34
- const config = configFn(env);
64
+ config = configFn(env);
35
65
  if (!config.authConnectionsFile) { writeErr("Set PI_AUTH_CONNECTIONS_FILE to an operator-reviewed subscription connection policy.\n"); return 2; }
36
- redis = redisFn(config.valkeyUrl);
66
+ // BOUNDED (issue #867): every command this client sends is a short read or one compare-and-set of a quota row, so one
67
+ // that waits past VALKEY_COMMAND_TIMEOUT_MS is a Valkey that is gone or stopped answering. Without the bound `auth
68
+ // status` and `auth usage` waited for as long as it stayed so (measured: `auth usage` on a closed port printed 18 stack
69
+ // traces in 8 s, one per reconnect attempt, while its read sat in ioredis' offline queue for as long as Valkey stayed
70
+ // gone). The bound is on each command, never on the client's life, so a login
71
+ // that waits minutes on a browser is not cut short: the login itself sends nothing to Valkey, only the status read
72
+ // after it. `disconnectTimeoutMs: 0`, as `cancel`'s probe, so the socket of a Valkey that stopped answering does not
73
+ // hold the process past its last line.
74
+ redis = redisFn(config.valkeyUrl, { commandTimeoutMs: VALKEY_COMMAND_TIMEOUT_MS, disconnectTimeoutMs: 0 });
75
+ // Without a listener ioredis prints a stack trace per reconnect attempt, for as long as an outage outlasts this call.
76
+ redis?.on?.("error", (err) => {
77
+ seen = err?.message ?? String(err);
78
+ });
79
+ redis?.on?.("ready", () => {
80
+ seen = null;
81
+ });
82
+ if (typeof redis?.eval === "function") {
83
+ const evaluate = redis.eval.bind(redis);
84
+ redis.eval = (...args) => Promise.resolve(evaluate(...args)).catch((err) => {
85
+ if (err?.valkeyMayStillRun === true) casLost = true;
86
+ throw err;
87
+ });
88
+ }
37
89
  const service = configure({ config, redis, now });
38
90
  if (!interaction) interaction = {
39
91
  async notify(event) {
@@ -70,17 +122,36 @@ export async function runAuth(args, { env = process.env, write = (value) => proc
70
122
  const logout = { id, state: "disconnected", remoteRevocationConfirmed: result?.remoteRevocationConfirmed === true };
71
123
  let status;
72
124
  try { status = await service.runtime.status(); }
73
- catch { status = { state: "unavailable", partial: true, connections: [] }; }
125
+ catch (error) {
126
+ status = { state: "unavailable", partial: true, connections: [] };
127
+ // The logout is done (it sends nothing to Valkey) and its evidence printed below; a Valkey failure of the status
128
+ // read is still said as the one line, and the client is dropped rather than asked to QUIT (issue #867).
129
+ const line = authValkeyLine(error, { command, url: config.valkeyUrl, seen });
130
+ if (line !== null) {
131
+ valkeyFailed = true;
132
+ writeErr(`${line}\n`);
133
+ }
134
+ }
74
135
  result = { ...status, logout };
75
136
  }
76
137
  write(`${JSON.stringify(result, (key, value) => ["accountGroup", "generation", "accountDigest", "credential", "registration"].includes(key) ? undefined : value, 2)}\n`);
77
138
  return result?.ok === false || result?.kind === "unavailable" ? 2 : 0;
78
139
  } catch (error) {
140
+ // A Valkey failure is one plain line naming Valkey, exit 1 as the transient failure it is: "auth-unavailable" and
141
+ // "Run auth status" said nothing of Valkey, and `auth status` needs the same Valkey.
142
+ const line = config?.valkeyUrl ? authValkeyLine(error, { command, url: config.valkeyUrl, seen, casLost }) : null;
143
+ if (line !== null) {
144
+ valkeyFailed = true;
145
+ writeErr(`${line}\n`);
146
+ return 1;
147
+ }
79
148
  const code = typeof error?.code === "string" && /^(?:auth|subscription)-[a-z-]+$/.test(error.code) ? error.code : "auth-unavailable";
80
149
  writeErr(`Subscription authentication: ${code}. Run auth status on the connection owner host.\n`);
81
150
  return error?.transient === true ? 1 : 2;
82
151
  } finally {
83
152
  prompt?.close();
84
- try { await redis?.quit(); } catch { redis?.disconnect?.(); }
153
+ // After a Valkey failure QUIT would wait out one more bound on a Valkey that stopped answering, for nothing.
154
+ if (valkeyFailed) redis?.disconnect?.();
155
+ else try { await redis?.quit(); } catch { redis?.disconnect?.(); }
85
156
  }
86
157
  }
@@ -22,6 +22,12 @@
22
22
  * can be one waiting to retry after a failed attempt, which usually wrote a run record and may have left a retained
23
23
  * sandbox, and "it never ran" was printed about it. `ranBefore` (cancel-state.mjs, shared with the panel) words it from the
24
24
  * job's attempt count.
25
+ *
26
+ * Every command it sends has a bound (issue #846 for its own client, issue #867 for its queues), so a Valkey that is
27
+ * connected and stopped answering ends the verb with a line rather than holding it for as long as it stays so. The line
28
+ * says what the point it stopped at allows: the lookup changes nothing about the job (each queue writes its own meta row
29
+ * on connect, as every BullMQ queue does), so nothing was changed; a removal or a hold's delete that was sent and not
30
+ * answered may or may not have been made, and `cancel` again settles it once Valkey answers.
25
31
  */
26
32
 
27
33
  import { CANCEL_REQ_TTL_MS, cancelAckKey, cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
@@ -53,6 +59,16 @@ function valkeyFault(error, shown) {
53
59
  return refusedByValkey(error) ? `Valkey at ${shown} refused ${boundedText(command)}: ${boundedText(error?.message)}` : `Valkey at ${shown} stopped answering (${boundedText(error?.message)})`;
54
60
  }
55
61
 
62
+ /** A bound in words: whole seconds, or milliseconds below one (a test's bound read "0s"). */
63
+ function spoken(ms) {
64
+ return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms} ms`;
65
+ }
66
+
67
+ /** Whether `error` is a command that got no answer: a bound that fired, or one sent whose answer was lost. */
68
+ function unanswered(error) {
69
+ return error?.code === "ETIMEDOUT" || error?.valkeyMayStillRun === true;
70
+ }
71
+
56
72
  /**
57
73
  * The job's state, read under the same bound as every other command here: `{ state }`, or `{ unread }` with why it could
58
74
  * not be read. Issue #846: a read that failed was `"unknown"`, which is also what BullMQ says of a job that left its
@@ -64,7 +80,7 @@ async function boundedState(job, timeoutMs) {
64
80
  return await Promise.race([
65
81
  Promise.resolve(job.getState()).then((state) => ({ state })),
66
82
  new Promise((resolve) => {
67
- timer = setTimeout(() => resolve({ unread: `no answer within ${Math.round(timeoutMs / 1000)}s` }), timeoutMs);
83
+ timer = setTimeout(() => resolve({ unread: `no answer within ${spoken(timeoutMs)}` }), timeoutMs);
68
84
  }),
69
85
  ]);
70
86
  } catch (error) {
@@ -74,6 +90,22 @@ async function boundedState(job, timeoutMs) {
74
90
  }
75
91
  }
76
92
 
93
+ /**
94
+ * `promise`'s answer, or a rejection once `ms` passed without one, marked as no answer (`ETIMEDOUT`, `lookupBound`). Each
95
+ * queue's lookup has this bound of its own, as one step (issue #867): BullMQ connects inside the queue, and on a Valkey
96
+ * that takes the connection and never answers, ioredis' handshake (CLIENT SETINFO, then the ready check's INFO) waits
97
+ * one command bound each, on each of the three connections `failFast` tries, so the per-command bound alone ended a
98
+ * lookup after about six of them (measured: 6.4 s at a 1 s bound). A lookup changes nothing about the job, so what it
99
+ * leaves running changes nothing either, and its queue is dropped on the way out (`closeWithin`).
100
+ */
101
+ function answeredWithin(promise, ms) {
102
+ let timer;
103
+ return new Promise((resolve, reject) => {
104
+ timer = setTimeout(() => reject(Object.assign(new Error(`no answer within ${spoken(ms)}`), { code: "ETIMEDOUT", lookupBound: true })), ms);
105
+ Promise.resolve(promise).then(resolve, reject);
106
+ }).finally(() => clearTimeout(timer));
107
+ }
108
+
77
109
  /**
78
110
  * Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
79
111
  * so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
@@ -87,7 +119,7 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
87
119
  return fail("a job id is required: pi-dispatch cancel <jobId> (ids are in `pi-dispatch status`, the panel, and the run log)");
88
120
  }
89
121
  // Lazy imports, the pause verb's shape: a mistyped id must not load bullmq before it can be refused.
90
- const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS } = await import("./connection.mjs");
122
+ const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS, urlShown } = await import("./connection.mjs");
91
123
  // The bound on each command and on the state reads: the console's, unless a test asks for another.
92
124
  const bound = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : VALKEY_COMMAND_TIMEOUT_MS;
93
125
  const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
@@ -122,7 +154,16 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
122
154
  // printed nothing until the operator gave up, and on a partition its DEL never reached the catch below.
123
155
  const probe = mkRedis(url, { commandTimeoutMs: bound, disconnectTimeoutMs: 0 });
124
156
  probe.on?.("error", () => {});
157
+ // Whether a connection to Valkey was ever made (issue #867, the review): a lookup that got no answer from a Valkey that
158
+ // took the connection says it stopped answering; one where nothing ever took it (an address that drops the connect, a
159
+ // host that is off) says it could not be reached, with the hint, as before #867. The probe connects first, so it tells.
160
+ let connected = false;
161
+ probe.on?.("connect", () => {
162
+ connected = true;
163
+ });
125
164
  const queues = [];
165
+ // Whether the job was found: until then nothing about the job changed (issue #867), so a failure says nothing was changed.
166
+ let found = false;
126
167
  let requested = false;
127
168
  let requestedAt = null;
128
169
  try {
@@ -138,14 +179,19 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
138
179
  // Constructed INSIDE the try (the pause loop's leak posture); first queue that knows the id wins.
139
180
  // Job ids are delivery GUIDs, `local-<hex>` or `repeat:<id>:<millis>`, so a cross-queue duplicate
140
181
  // is not a state this project can produce.
182
+ // BOUNDED (issue #867), each command at the probe's bound (`parseConnection`'s `commandTimeoutMs`) and each queue's
183
+ // lookup as one step (`answeredWithin`): `failFast` alone refuses while nothing is connected, and a command a Valkey
184
+ // took and never answered (`kill -STOP`, a partition) held the verb for as long as it stayed so, in the lookup and in
185
+ // the state read and the remove of `removeQueuedJob`, which go through this queue too.
141
186
  let job = null;
142
187
  let owner = null;
143
188
  for (const name of names) {
144
- const q = mkQueue(parseConn(url, { failFast: true }), { name });
189
+ const q = mkQueue(parseConn(url, { failFast: true, commandTimeoutMs: bound }), { name });
145
190
  queues.push(q);
146
- job = await q.getJob(jobId);
191
+ job = await answeredWithin(q.getJob(jobId), bound);
147
192
  if (job) {
148
193
  owner = q;
194
+ found = true;
149
195
  break;
150
196
  }
151
197
  }
@@ -164,6 +210,26 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
164
210
  write(`cancelled ${jobId}: it was held on its wait condition, and ${ranBefore(queued)}\n`);
165
211
  return 0;
166
212
  }
213
+ // Issue #867: a write of the held sequence that was sent and not answered may still run when Valkey wakes, so the line
214
+ // names which write that was and says it may or may not have been made. The cleared line below would say the job
215
+ // "could not be removed" of a removal that may yet land, and its hold "was cleared" of a delete that may not.
216
+ const shown = urlShown(url);
217
+ // Issue #867 (the review): a hold that could not be read refuses, so a held job is never removed by the plain path,
218
+ // which strands its hold as a panel row. Nothing was sent. The outcome comes before the failure's words, which are
219
+ // the only part bounded, so a long id never cuts it off.
220
+ if (typeof queued.holdUnread === "string") {
221
+ if (queued.noAnswer !== undefined) return fail(`Valkey at ${shown} stopped answering (${boundedText(queued.noAnswer)}) while cancel read whether ${jobId} is held, so nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
222
+ return fail(`could not read whether ${jobId} is held on its wait condition, so nothing was changed: ${boundedText(queued.holdUnread)}`);
223
+ }
224
+ if (queued.invalid !== undefined && queued.cleared !== true && queued.noAnswer !== undefined) {
225
+ return fail(`could not cancel held job ${jobId}: Valkey at ${shown} did not answer (${boundedText(queued.noAnswer)}), so nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
226
+ }
227
+ if (queued.invalid !== undefined && queued.cleared === true && queued.unanswered === "hold") {
228
+ return fail(`Valkey at ${shown} stopped answering (${boundedText(queued.invalid)}) after the delete of held job ${jobId}'s hold was sent, so the hold may or may not be gone, and the job itself was not removed. Once Valkey answers, run \`pi-dispatch cancel ${jobId}\` again to remove it`);
229
+ }
230
+ if (queued.invalid !== undefined && queued.cleared === true && queued.unanswered === "job") {
231
+ return fail(`Valkey at ${shown} stopped answering (${boundedText(queued.invalid)}) after the removal of held job ${jobId} was sent: its hold was cleared, and the job may or may not have been removed. ${settleLater(jobId)}`);
232
+ }
167
233
  // A removal that failed after the hold's keys were deleted changed something: the job lost its hold and was not removed,
168
234
  // so the line says so and how to finish, never only the error (which reads as "nothing happened"). Every error a
169
235
  // line carries is bounded (`boundedText`): it is another module's words, of any length and with any bytes.
@@ -176,6 +242,12 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
176
242
  return 0;
177
243
  }
178
244
  const state = queued.state;
245
+ // Issue #867: a remove that was sent and not answered, with the job not read back in any of BullMQ's lists (it was
246
+ // read as "unknown", or not read at all), may have run: never "nothing was changed", which the re-read below would
247
+ // say of a state it cannot read. A job read back in a list was not removed (its remove is one script), and goes on.
248
+ if (queued.raced && queued.error?.valkeyMayStillRun === true && state === "unknown") {
249
+ return fail(`${valkeyFault(queued.error, shown)} after the removal of ${jobId} was sent, so it may or may not have been removed. ${settleLater(jobId)}`);
250
+ }
179
251
  // Issue #846: `removeQueuedJob` reads a state it could not read as "unknown" (the console's sequence shares it), which
180
252
  // BullMQ also says of a job that left its queue. Asked again under the bound, so a read that failed says so.
181
253
  if (state === "unknown") {
@@ -240,7 +312,7 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
240
312
  return 0;
241
313
  }
242
314
  if (error?.valkeyRefused) return fail(error.message);
243
- const shown = (await import("./connection.mjs")).urlShown(url);
315
+ const shown = urlShown(url);
244
316
  // Issue #846: after the request went out, what is said is what became of it. The old line said only "could not reach
245
317
  // Valkey ... is it running?", also when Valkey answered with a refusal, and never that a request may still stand.
246
318
  if (requested && error?.valkeyMayStillRun) {
@@ -252,7 +324,16 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
252
324
  return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and its withdrawal was not answered, so it may stand until ${until}, ${Math.round(CANCEL_REQ_TTL_MS / 1000)}s after it was written: a worker that reads it before then stops the job. ${then.charAt(0).toUpperCase()}${then.slice(1)}`);
253
325
  }
254
326
  if (refusedByValkey(error)) return fail(`${valkeyFault(error, shown)}; nothing was changed`);
255
- return fail(`could not reach Valkey at ${shown}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
327
+ // Issue #867: the lookup changes nothing about the job, so a failure there changed nothing, and the line says so: a
328
+ // Valkey that took a connection and stopped answering as that, with when to ask again; one never reached (nothing took
329
+ // a connection, so the lookup's own bound fired, or the queue gave up) as before #867, with the hint. A lost answer
330
+ // (`valkeyMayStillRun`) was on a socket, so it was reached.
331
+ const reached = connected || error?.valkeyMayStillRun === true;
332
+ if (!found && unanswered(error) && reached) return fail(`${valkeyFault(error, shown)} while cancel looked for ${jobId}, so nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
333
+ const hint = (await import("./valkey-auth.mjs")).valkeyDownHint(url);
334
+ const words = error?.lookupBound === true && !reached ? `no connection within ${spoken(bound)}` : error?.message;
335
+ if (!found) return fail(`could not reach Valkey at ${shown} while cancel looked for ${jobId}, so nothing was changed: ${hint}\n ${boundedText(words)}`);
336
+ return fail(`could not reach Valkey at ${shown}: ${hint}\n ${boundedText(error?.message)}`);
256
337
  } finally {
257
338
  probe.disconnect?.();
258
339
  // BOUNDED (issue #846): `close()` waits for the answer to QUIT, so on a Valkey that stopped answering the verb could hold
@@ -262,6 +343,14 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
262
343
  }
263
344
  }
264
345
 
346
+ /**
347
+ * What to do about a removal that was sent and not answered (issue #867): run the verb again once Valkey answers, which
348
+ * finds the job and removes it, or says no queue holds it. Not `pi-dispatch status`, whose counts name no job.
349
+ */
350
+ function settleLater(jobId) {
351
+ return `Once Valkey answers, run \`pi-dispatch cancel ${jobId}\` again: it removes the job if it is still queued, and says so if it is gone`;
352
+ }
353
+
265
354
  /** Close `queue`, or drop its connection when the close is not answered within `ms`. Never throws. */
266
355
  async function closeWithin(queue, ms) {
267
356
  let timer;
@@ -143,17 +143,38 @@ export function ranBefore(job) {
143
143
 
144
144
  /**
145
145
  * Whether the hold is still there, read back after its delete failed: its CLOCK (`since`), the field that makes the hash
146
- * a hold, since the worker's own counters can re-create the key without it. False when it cannot be read.
146
+ * a hold, since the worker's own counters can re-create the key without it. True or false as read, null when it cannot
147
+ * be read (issue #867: a hold read back gone is known gone, one not read back is not known either way).
147
148
  */
148
149
  async function holdInPlace(redis, jobId) {
149
- if (typeof redis?.hexists !== "function") return false;
150
+ if (typeof redis?.hexists !== "function") return null;
150
151
  try {
151
152
  return (await redis.hexists(jobKey(jobId), "since")) === 1;
152
153
  } catch {
153
- return false;
154
+ return null;
154
155
  }
155
156
  }
156
157
 
158
+ /**
159
+ * Whether a command's failure means it was never sent: a bounded client's timeout that came while the command still
160
+ * waited for a connection (`makeRedisClient`'s `BoundedRedis` takes such a command out of its queue, so it is never sent
161
+ * later either), told apart from one that reached a socket by the missing `valkeyMayStillRun`.
162
+ */
163
+ function neverSent(err) {
164
+ return err?.code === "ETIMEDOUT" && err?.message === "Command timed out" && err?.valkeyMayStillRun !== true;
165
+ }
166
+
167
+ /**
168
+ * Whether a failure is Valkey giving no answer (issue #867): a bounded client's timeout, or a command it sent whose answer
169
+ * was lost (`valkeyMayStillRun`). An answer that changed nothing carries the failure's words as `noAnswer`, so a caller can
170
+ * say Valkey did not answer, and when to ask again, rather than quote it as a refusal; a caller that does not read it
171
+ * says what it said before.
172
+ */
173
+ function noAnswer(err) {
174
+ return err?.code === "ETIMEDOUT" || err?.valkeyMayStillRun === true;
175
+ }
176
+ const noAnswerOf = (err) => (noAnswer(err) ? { noAnswer: err?.message ?? String(err) } : {});
177
+
157
178
  /**
158
179
  * Remove a job that is held on a `run.waitFor` condition: hold keys first, then the job (issue #230's
159
180
  * sequence, issue #287's shared home). This is the inner body of the admin's `cancelHeldJob`, moved here
@@ -165,8 +186,14 @@ async function holdInPlace(redis, jobId) {
165
186
  * been sent for deletion (most often `remove` refusing a job a worker locked in between): the hold is gone, or partly
166
187
  * gone, while the job's own removal failed, which a caller must never word as "nothing was changed". A first delete
167
188
  * that failed answers `{ invalid }` only when the hold is read back in place afterwards (Valkey refused the command:
168
- * NOPERM, READONLY, MISCONF); a hold not read back, or a read that fails, keeps the mark, since a refusal can answer a
169
- * resend of a delete that already ran. Never throws.
189
+ * NOPERM, READONLY, MISCONF), or when it was never sent (`neverSent`); a hold not read back, or a read that fails, keeps
190
+ * the mark, since a refusal can answer a resend of a delete that already ran. Never throws.
191
+ * Issue #867: a cleared answer whose failure was a lost answer (`valkeyMayStillRun`: the command reached a Valkey that
192
+ * then did not answer, so it may still run) says which write that was, `unanswered`: "hold" for the hold's first delete,
193
+ * when the hold could not be read back either (the hold may or may not be gone, and the job was not asked to go), "job"
194
+ * for the job's own removal (the hold is gone, and the job may or may not be). A job whose state cannot be read is
195
+ * refused as that, never as one that left the hold. An answer that changed nothing because Valkey gave none (a read past
196
+ * its bound, a delete never sent) carries the failure's words as `noAnswer`.
170
197
  * Writes no run record of its own (INT-RUN-HISTORY-FILE-CONTRACT records terminal states of runs, and a removed hold
171
198
  * is none); `attemptsMade` is what the job had done before, for the caller's sentence (`ranBefore`, issue #477): a job
172
199
  * held again on a retry has made attempts, and whatever they recorded stays. Once the job is removed, its model attempt
@@ -177,6 +204,9 @@ async function holdInPlace(redis, jobId) {
177
204
  export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dropDefer = false, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
178
205
  // Set as the first hold key's delete is sent, before it is answered: a delete lost in transit may still have landed.
179
206
  let cleared = false;
207
+ // The write in flight, for `unanswered`: the hold's first delete, then the job's removal; null between them, where a
208
+ // failure leaves the hold known gone and the job not asked.
209
+ let step = null;
180
210
  try {
181
211
  const hash = await redis.hgetall(jobKey(jobId));
182
212
  // A hold is a hash with a CLOCK, not merely a non-empty hash: the worker's own counters create this
@@ -188,7 +218,12 @@ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dr
188
218
  // while the job wakes, runs and completes -- and bullmq will happily `remove()` a completed job. That
189
219
  // would answer `applied: true` to an operator who approved a dialog reading "It will never run", for
190
220
  // a job whose run record is already on disk.
191
- const state = await job.getState().catch(() => null);
221
+ let unread = null;
222
+ const state = await job.getState().catch((err) => ((unread = err), null));
223
+ // Issue #867: a read that failed (on a bounded queue, one Valkey did not answer) says nothing of where the job is,
224
+ // so it is no job that "has already left the hold": nothing was sent, and that is what is said, before the
225
+ // failure's own words, which a caller may cut.
226
+ if (unread !== null) return { invalid: `the state of job ${jobId} could not be read, so nothing was changed: ${unread?.message ?? String(unread)}`, ...noAnswerOf(unread) };
192
227
  if (state !== "delayed" && state !== "waiting" && state !== "prioritized") {
193
228
  return { invalid: `job ${jobId} is ${state ?? "in an unknown state"}, not waiting -- it has already left the hold` };
194
229
  }
@@ -196,28 +231,40 @@ export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dr
196
231
  // mid-sequence, an orphaned hash would keep a row on the panel for a job that no longer exists; an
197
232
  // orphaned JOB is merely a job that still runs, which is the state the operator was already in.
198
233
  cleared = true;
234
+ step = "hold";
199
235
  try {
200
236
  await redis.del(jobKey(jobId));
201
237
  } catch (err) {
202
238
  // A failed DEL may still have run: ioredis resends an unanswered command after a reconnect, and the refusal
203
239
  // (an ACL without DEL, a read-only replica, a failed save) can be the answer to that resend. So the hold is
204
240
  // read back, and only a hold still in place makes "nothing was changed" true; a read that fails keeps the mark.
205
- if (await holdInPlace(redis, jobId)) cleared = false;
241
+ // A DEL never sent changed nothing either, whatever the read says. One read back gone ran.
242
+ const inPlace = await holdInPlace(redis, jobId);
243
+ if (inPlace === true || neverSent(err)) cleared = false;
244
+ else if (inPlace === false) step = null;
206
245
  throw err;
207
246
  }
247
+ step = null;
208
248
  await redis.srem(HELD_SET, jobId).catch(() => {});
209
249
  if (hash.dedupId) {
210
250
  const holder = await redis.get(leaseKey(hash.dedupId));
211
251
  if (holder === jobId) await redis.del(leaseKey(hash.dedupId));
212
252
  }
213
253
  const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
214
- const removal = interventionOrigin === "agent" ? (await job.remove(), { ok: true }) : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: async () => { await job.remove(); return { ok: true }; }, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
254
+ const remove = async () => {
255
+ step = "job";
256
+ await job.remove();
257
+ return { ok: true };
258
+ };
259
+ const removal = interventionOrigin === "agent" ? await remove() : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: remove, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
260
+ step = null;
215
261
  await Promise.all([dropDefer ? dropDeferRecord(redis, jobId, timeoutMs) : null, dropAttemptOf(redis, queue, jobId, timeoutMs)]);
216
262
  // Issue #477: what the job had done, for the caller's sentence (`ranBefore`); read off the job just removed.
217
263
  return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
218
264
  } catch (err) {
219
265
  const invalid = err?.message ?? String(err);
220
- return cleared ? { invalid, cleared: true } : { invalid };
266
+ if (!cleared) return { invalid, ...noAnswerOf(err) };
267
+ return { invalid, cleared: true, ...(err?.valkeyMayStillRun === true && step !== null ? { unanswered: step } : {}) };
221
268
  }
222
269
  }
223
270
 
@@ -238,8 +285,10 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
238
285
  * any other state -> nothing is changed.
239
286
  *
240
287
  * A HOLD IS JUDGED FIRST: a held job IS delayed, and the plain path would strand its `wait:*` keys as a panel row for a
241
- * job that no longer exists. A hash that cannot be read is not a hold, as the CLI has always read it. `state` is the
242
- * state the caller already read, or null to read it here.
288
+ * job that no longer exists. So a hash that cannot be read refuses, nothing changed (issue #867): it was read as no hold,
289
+ * and a read that timed out on a Valkey that then woke removed a held job by the plain path, leaving its hash and its
290
+ * `wait:held` member as a panel row for a job that was gone. A client with no `hgetall` (a bare test wiring) has no hold
291
+ * to read. `state` is the state the caller already read, or null to read it here.
243
292
  *
244
293
  * `guard` (optional; the CLI passes none) is a caller's last check, asked with `job` right before the removal is sent:
245
294
  * before `removeHeldJob` for a held job, and after the state check, before `remove()`, otherwise. It answers null to go
@@ -253,10 +302,13 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
253
302
  * (issue #812), beside the record, so the two deletes share one bound: here for a waiting job, inside `removeHeldJob`
254
303
  * (`dropDefer`) for a held one. Returns, and never throws:
255
304
  * `{ ok: true, held: true, jobId, attemptsMade }` a held job, removed;
256
- * `{ invalid }` `removeHeldJob`'s own refusal of a held job, nothing changed;
257
- * `{ invalid, cleared: true }` `removeHeldJob` failed after its hold's keys were deleted: the
305
+ * `{ invalid, holdUnread, noAnswer? }` the hold's hash could not be read (`holdUnread`, the failure's
306
+ * words; `noAnswer` when Valkey gave none): nothing was sent;
307
+ * `{ invalid, noAnswer? }` `removeHeldJob`'s own refusal of a held job, nothing changed;
308
+ * `{ invalid, cleared: true, unanswered? }` `removeHeldJob` failed after its hold's keys were deleted: the
258
309
  * hold is gone and the job's own removal failed (never "nothing
259
- * was changed");
310
+ * was changed"), or a write of it was not answered
311
+ * (`unanswered`, removeHeldJob's);
260
312
  * `{ refused, error? }` the guard's answer (`refused`), or its throw (`refused` null,
261
313
  * `error` the throw); nothing was sent;
262
314
  * `{ notQueued: true, state }` not waiting to start (`active`, `completed`, `unknown`...);
@@ -264,17 +316,24 @@ export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized",
264
316
  * `{ raced: true, state, error }` `remove()` threw (most often: a worker took the job between
265
317
  * the read and the remove, or it is a job scheduler's next
266
318
  * run, which BullMQ refuses to remove); `state` is read again.
319
+ * An `error` with `valkeyMayStillRun` was sent and not answered,
320
+ * so the remove may yet run (issue #867).
267
321
  * `attemptsMade` is the job's own count for the caller's sentence (`ranBefore`), 0 when it is not a positive whole number.
268
322
  */
269
323
  export async function removeQueuedJob({ redis, queue, job, jobId, state = null, timeoutMs = 2_000, guard = null, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
270
- const hash = await Promise.resolve()
324
+ let holdUnread = null;
325
+ const hash = typeof redis?.hgetall !== "function" ? null : await Promise.resolve()
271
326
  .then(() => redis.hgetall(jobKey(jobId)))
272
- .catch(() => null);
327
+ .catch((err) => ((holdUnread = err), null));
328
+ if (holdUnread !== null) {
329
+ const words = holdUnread?.message ?? String(holdUnread);
330
+ return { invalid: `could not read whether job ${jobId} is held on a wait condition, so nothing was changed: ${words}`, holdUnread: words, ...noAnswerOf(holdUnread) };
331
+ }
273
332
  if (hash?.since) {
274
333
  const stop = await guarded(guard, job);
275
334
  if (stop !== null) return stop;
276
335
  const res = await removeHeldJob({ redis, queue, jobId, timeoutMs, dropDefer: true, touchStore, touchOutbox, now, interventionOrigin });
277
- if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true } : { invalid: res.invalid };
336
+ if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true, ...(res.unanswered ? { unanswered: res.unanswered } : {}) } : { invalid: res.invalid, ...(res.noAnswer !== undefined ? { noAnswer: res.noAnswer } : {}) };
278
337
  return { ok: true, held: true, jobId: res.jobId, attemptsMade: res.attemptsMade, ...(res.touchWarning ? { touchWarning: res.touchWarning } : {}) };
279
338
  }
280
339
  if (!job) return { notQueued: true, state: "unknown" };