@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 +1 -1
- package/src/auth-cli.mjs +76 -5
- package/src/cancel-cli.mjs +193 -16
- package/src/cancel-state.mjs +85 -20
- package/src/connection.mjs +214 -6
- package/src/doctor-bundles.mjs +6 -2
- package/src/doctor-model-proposals.mjs +4 -2
- package/src/doctor.mjs +225 -45
- package/src/env-allowlist.mjs +9 -1
- package/src/github-app-setup.mjs +17 -4
- package/src/host-loads.mjs +58 -4
- package/src/job-size.mjs +27 -0
- package/src/json-duplicates.mjs +53 -42
- package/src/mcp-preflight.mjs +2 -2
- package/src/pause-windows.mjs +19 -3
- package/src/reason-catalog.mjs +1 -1
- package/src/redact-secrets.mjs +4 -0
- package/src/reserved-env.mjs +4 -0
- package/src/sandbox-cli.mjs +9 -3
- package/src/sandbox-deployment.mjs +38 -5
- package/src/sandbox-prepare.mjs +16 -2
- package/src/sandbox.mjs +10 -3
- package/src/service-env.mjs +4 -3
- package/src/size-suggest.mjs +118 -8
- package/src/touch-outbox.mjs +5 -0
- package/src/valkey-endpoint.mjs +9 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "5.1
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/cancel-cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|