@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "5.1.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,9 +22,15 @@
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
- import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
33
+ import { CANCEL_REQ_TTL_MS, cancelAckKey, cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
28
34
 
29
35
  /** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
30
36
  const FLEET_READ_TIMEOUT_MS = 2_000;
@@ -42,11 +48,69 @@ export function boundedText(text) {
42
48
  return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
43
49
  }
44
50
 
51
+ /** Whether `error` is Valkey's own refusal (a reply), as against no answer at all: the two are different sentences. */
52
+ function refusedByValkey(error) {
53
+ return error?.name === "ReplyError";
54
+ }
55
+
56
+ /** What went wrong, as the start of a line: Valkey refused a command (it answered), or it did not answer. */
57
+ function valkeyFault(error, shown) {
58
+ const command = typeof error?.command?.name === "string" ? error.command.name.toUpperCase() : "a command";
59
+ return refusedByValkey(error) ? `Valkey at ${shown} refused ${boundedText(command)}: ${boundedText(error?.message)}` : `Valkey at ${shown} stopped answering (${boundedText(error?.message)})`;
60
+ }
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
+
72
+ /**
73
+ * The job's state, read under the same bound as every other command here: `{ state }`, or `{ unread }` with why it could
74
+ * not be read. Issue #846: a read that failed was `"unknown"`, which is also what BullMQ says of a job that left its
75
+ * queue, so a job still running was reported as gone "and nothing was changed".
76
+ */
77
+ async function boundedState(job, timeoutMs) {
78
+ let timer;
79
+ try {
80
+ return await Promise.race([
81
+ Promise.resolve(job.getState()).then((state) => ({ state })),
82
+ new Promise((resolve) => {
83
+ timer = setTimeout(() => resolve({ unread: `no answer within ${spoken(timeoutMs)}` }), timeoutMs);
84
+ }),
85
+ ]);
86
+ } catch (error) {
87
+ return { unread: error?.message ?? String(error) };
88
+ } finally {
89
+ clearTimeout(timer);
90
+ }
91
+ }
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
+
45
109
  /**
46
110
  * Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
47
111
  * so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
48
112
  */
49
- export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now } = {}) {
113
+ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now, commandTimeoutMs = null, closeTimeoutMs = 1000 } = {}) {
50
114
  const fail = (message) => {
51
115
  errWrite(`error: ${message}\n`);
52
116
  return 1;
@@ -55,7 +119,9 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
55
119
  return fail("a job id is required: pi-dispatch cancel <jobId> (ids are in `pi-dispatch status`, the panel, and the run log)");
56
120
  }
57
121
  // Lazy imports, the pause verb's shape: a mistyped id must not load bullmq before it can be refused.
58
- const { parseConnection, makeRedisClient } = await import("./connection.mjs");
122
+ const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS, urlShown } = await import("./connection.mjs");
123
+ // The bound on each command and on the state reads: the console's, unless a test asks for another.
124
+ const bound = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : VALKEY_COMMAND_TIMEOUT_MS;
59
125
  const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
60
126
  const { readLiveHosts } = await import("./host-registry.mjs");
61
127
  const parseConn = parseConnectionFn ?? parseConnection;
@@ -83,10 +149,23 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
83
149
  });
84
150
  const refused = await refusalOf(url);
85
151
  if (refused) return fail(refused);
86
- const probe = mkRedis(url);
152
+ // BOUNDED (issue #846): each command waits at most VALKEY_COMMAND_TIMEOUT_MS for its answer, and one that timed out is
153
+ // never sent again. The unbounded client this was waited for as long as Valkey was gone: killed during the ack poll it
154
+ // printed nothing until the operator gave up, and on a partition its DEL never reached the catch below.
155
+ const probe = mkRedis(url, { commandTimeoutMs: bound, disconnectTimeoutMs: 0 });
87
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
+ });
88
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;
89
167
  let requested = false;
168
+ let requestedAt = null;
90
169
  try {
91
170
  // The job could sit on the shared queue or on any host's own (issue #57): find it the way pause
92
171
  // spans them, and fail OPEN but LOUDLY on a degraded registry read, exactly as the kill switch does.
@@ -100,14 +179,19 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
100
179
  // Constructed INSIDE the try (the pause loop's leak posture); first queue that knows the id wins.
101
180
  // Job ids are delivery GUIDs, `local-<hex>` or `repeat:<id>:<millis>`, so a cross-queue duplicate
102
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.
103
186
  let job = null;
104
187
  let owner = null;
105
188
  for (const name of names) {
106
- const q = mkQueue(parseConn(url, { failFast: true }), { name });
189
+ const q = mkQueue(parseConn(url, { failFast: true, commandTimeoutMs: bound }), { name });
107
190
  queues.push(q);
108
- job = await q.getJob(jobId);
191
+ job = await answeredWithin(q.getJob(jobId), bound);
109
192
  if (job) {
110
193
  owner = q;
194
+ found = true;
111
195
  break;
112
196
  }
113
197
  }
@@ -126,6 +210,26 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
126
210
  write(`cancelled ${jobId}: it was held on its wait condition, and ${ranBefore(queued)}\n`);
127
211
  return 0;
128
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
+ }
129
233
  // A removal that failed after the hold's keys were deleted changed something: the job lost its hold and was not removed,
130
234
  // so the line says so and how to finish, never only the error (which reads as "nothing happened"). Every error a
131
235
  // line carries is bounded (`boundedText`): it is another module's words, of any length and with any bytes.
@@ -138,6 +242,18 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
138
242
  return 0;
139
243
  }
140
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
+ }
251
+ // Issue #846: `removeQueuedJob` reads a state it could not read as "unknown" (the console's sequence shares it), which
252
+ // BullMQ also says of a job that left its queue. Asked again under the bound, so a read that failed says so.
253
+ if (state === "unknown") {
254
+ const again = await boundedState(job, bound);
255
+ if (again.unread !== undefined) return fail(`could not read the state of ${jobId} (${boundedText(again.unread)}), so whether it is running is not known; nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again once Valkey answers`);
256
+ }
141
257
  // The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
142
258
  // the state is re-read and an active job falls through to the active path rather than failing an operator whose
143
259
  // job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
@@ -147,7 +263,8 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
147
263
 
148
264
  if (state === "active") {
149
265
  requested = true;
150
- const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, ...(sleep ? { sleep } : {}) });
266
+ requestedAt = now();
267
+ const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, now, onWritten: () => (requestedAt = now()), ...(sleep ? { sleep } : {}) });
151
268
  if (res.ack !== undefined) {
152
269
  write(`cancel accepted by ${res.ack === "" ? "the worker" : boundedText(res.ack)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
153
270
  return 0;
@@ -155,8 +272,11 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
155
272
  // Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
156
273
  // lands in that moment is never acknowledged and the job is back in the queue. Re-read, so the words are true.
157
274
  // Gate round 3: by the state, since a job that finished or left the queue cannot be removed by a second cancel.
158
- const after = await job.getState().catch(() => "unknown");
275
+ const read = await boundedState(job, bound);
159
276
  const waited = `no worker acknowledged within ${Math.round(ackTimeoutMs / 1000)}s`;
277
+ // Issue #846: a state that could not be read is said as that, never as BullMQ's "unknown" (a job that left).
278
+ if (read.unread !== undefined) return fail(`${waited}, and the job's state could not be read (${boundedText(read.unread)}), so whether it still runs is not known. The cancel request was withdrawn, so nothing was changed: run \`pi-dispatch status\`, and \`pi-dispatch cancel ${jobId}\` again if it still runs`);
279
+ const after = read.state;
160
280
  if (after === "waiting" || after === "delayed" || after === "prioritized" || after === "paused") return fail(`${waited}: the job went back to the queue before its worker read the cancel (it is now ${after}), and nothing was changed. Run \`pi-dispatch cancel ${jobId}\` again to remove it`);
161
281
  if (after === "completed" || after === "failed") return fail(`${waited}: the job finished (${after}) before its worker read the cancel, and nothing was changed`);
162
282
  if (after !== "active") return fail(`${waited}: the job is no longer in the queue (its state reads ${after}), and nothing was changed`);
@@ -170,18 +290,75 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
170
290
  // del would sit in ioredis's retry queue and hold this process open long past the error message.
171
291
  // The bound timer is CLEARED when the del wins the race (discoverHostQueues' own rule): cli.mjs
172
292
  // sets exitCode rather than exiting, so a stray pending timer is a second of pure hang.
293
+ // What the withdrawing DEL ANSWERED decides the sentence (issue #846, the second adversary round): 1, it was taken back;
294
+ // 0, it was already gone, because its worker read it (and may have acknowledged) or it expired; no answer, not known.
295
+ // Reading "answered" as "withdrawn" said "nothing was changed" of a cancel its worker had taken and acted on, when a
296
+ // poll was lost to a Valkey that died and came back within the bound.
297
+ const within = (promise, ms) => {
298
+ let timer;
299
+ return Promise.race([Promise.resolve(promise).then((value) => ({ value }), (error) => ({ lost: true, error })), new Promise((r) => (timer = setTimeout(() => r({ lost: true }), ms)))]).finally(() => clearTimeout(timer));
300
+ };
301
+ let withdrawal = null;
302
+ let acked = null;
173
303
  if (requested) {
174
- let bound;
175
- await Promise.race([
176
- Promise.resolve(probe.del?.(cancelReqKey(jobId))).catch(() => {}),
177
- new Promise((r) => (bound = setTimeout(r, 1000))),
178
- ]);
179
- clearTimeout(bound);
304
+ withdrawal = await within(probe.del?.(cancelReqKey(jobId)), 1000);
305
+ if (withdrawal.value !== undefined && Number(withdrawal.value) === 0) {
306
+ const ack = await within(probe.get?.(cancelAckKey(jobId)), 1000);
307
+ if (typeof ack.value === "string") acked = ack.value;
308
+ }
180
309
  }
181
- return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
310
+ if (acked !== null) {
311
+ write(`cancel accepted by ${acked === "" ? "the worker" : boundedText(acked)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
312
+ return 0;
313
+ }
314
+ if (error?.valkeyRefused) return fail(error.message);
315
+ const shown = urlShown(url);
316
+ // Issue #846: after the request went out, what is said is what became of it. The old line said only "could not reach
317
+ // Valkey ... is it running?", also when Valkey answered with a refusal, and never that a request may still stand.
318
+ if (requested && error?.valkeyMayStillRun) {
319
+ const removed = withdrawal?.value !== undefined ? Number(withdrawal.value) : null;
320
+ if (removed !== null && removed > 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written. The request was withdrawn again, so nothing was changed, unless its worker read it in that moment: run \`pi-dispatch status\` to see whether ${jobId} still runs`);
321
+ if (removed === 0) return fail(`${valkeyFault(error, shown)} after the cancel request for ${jobId} was written, and the request was gone when the CLI went to withdraw it: its worker may have read it, or it expired, and no acknowledgment was found. Run \`pi-dispatch status\` to see whether ${jobId} still runs`);
322
+ const until = new Date((requestedAt ?? now()) + CANCEL_REQ_TTL_MS).toISOString();
323
+ const then = refusedByValkey(error) || refusedByValkey(withdrawal?.error) ? "run `pi-dispatch status` to see whether it did" : "run `pi-dispatch status` once Valkey answers to see whether it did";
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)}`);
325
+ }
326
+ if (refusedByValkey(error)) return fail(`${valkeyFault(error, shown)}; nothing was changed`);
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)}`);
182
337
  } finally {
183
338
  probe.disconnect?.();
184
- for (const q of queues) await q.close().catch(() => {});
339
+ // BOUNDED (issue #846): `close()` waits for the answer to QUIT, so on a Valkey that stopped answering the verb could hold
340
+ // its process after its last line. Past the bound the queue is dropped (`disconnect`, no QUIT; ioredis may still take
341
+ // its own disconnect timeout to let the socket go), which nothing here needs an answer to.
342
+ await Promise.all(queues.map((q) => closeWithin(q, closeTimeoutMs)));
185
343
  }
186
344
  }
187
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
+
354
+ /** Close `queue`, or drop its connection when the close is not answered within `ms`. Never throws. */
355
+ async function closeWithin(queue, ms) {
356
+ let timer;
357
+ const closed = await Promise.race([
358
+ Promise.resolve(queue.close()).then(() => true, () => true),
359
+ new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms))),
360
+ ]);
361
+ clearTimeout(timer);
362
+ if (!closed) await Promise.resolve(queue.disconnect?.()).catch(() => {});
363
+ }
364
+