@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 +1 -1
- package/src/auth-cli.mjs +76 -5
- package/src/cancel-cli.mjs +95 -6
- package/src/cancel-state.mjs +76 -17
- package/src/connection.mjs +207 -6
- package/src/doctor.mjs +23 -18
- 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/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.2.
|
|
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,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 ${
|
|
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 =
|
|
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
|
-
|
|
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;
|
package/src/cancel-state.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
242
|
-
*
|
|
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 }`
|
|
257
|
-
*
|
|
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
|
-
|
|
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" };
|