@edgehero/pi-dispatch 5.0.0 → 5.2.0

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/README.md CHANGED
@@ -43,6 +43,7 @@ with at least one commit, and `run` refuses any other folder before it queues an
43
43
  |---|---|
44
44
  | `init` | write `.env` and the config files into this folder (never overwrites) |
45
45
  | `up` | one pass that asks before each container action: image, Valkey, egress proxy, `init`, `doctor` |
46
+ | `console` | get into the web console: starts its service if it stopped, then opens your browser signed in |
46
47
  | `doctor [--fix] [--live] [--openshell] [--secrets] [--no-publish]` | check the whole setup; `--live` reads the isolation guarantees back from real containers; each run publishes its checks (paths, URLs and secret values cut out) for the console and the panel, unless `--no-publish` |
47
48
  | `worker` | run jobs from the queue |
48
49
  | `run <folder> --task "..."` | queue one job against a local folder |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "5.0.0",
3
+ "version": "5.2.0",
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": [
@@ -54,6 +54,7 @@
54
54
  "./git-hardening": "./src/git-hardening.mjs",
55
55
  "./http-body": "./src/http-body.mjs",
56
56
  "./console-tokens": "./src/console-tokens.mjs",
57
+ "./console-devices": "./src/console-devices.mjs",
57
58
  "./console-probe": "./src/console-probe.mjs",
58
59
  "./console-audit": "./src/console-audit.mjs",
59
60
  "./console-answer": "./src/console-answer.mjs",
@@ -207,8 +208,8 @@
207
208
  "start": "node src/cli.mjs worker"
208
209
  },
209
210
  "dependencies": {
210
- "@earendil-works/pi-ai": "1.5.0",
211
- "@earendil-works/pi-tui": "1.5.0",
211
+ "@earendil-works/pi-ai": "1.5.2",
212
+ "@earendil-works/pi-tui": "1.5.2",
212
213
  "@octokit/auth-app": "8.2.0",
213
214
  "@octokit/rest": "22.0.1",
214
215
  "bullmq": "5.80.4",
@@ -24,7 +24,7 @@
24
24
  * job's attempt count.
25
25
  */
26
26
 
27
- import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
27
+ import { CANCEL_REQ_TTL_MS, cancelAckKey, cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
28
28
 
29
29
  /** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
30
30
  const FLEET_READ_TIMEOUT_MS = 2_000;
@@ -42,11 +42,43 @@ export function boundedText(text) {
42
42
  return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
43
43
  }
44
44
 
45
+ /** Whether `error` is Valkey's own refusal (a reply), as against no answer at all: the two are different sentences. */
46
+ function refusedByValkey(error) {
47
+ return error?.name === "ReplyError";
48
+ }
49
+
50
+ /** What went wrong, as the start of a line: Valkey refused a command (it answered), or it did not answer. */
51
+ function valkeyFault(error, shown) {
52
+ const command = typeof error?.command?.name === "string" ? error.command.name.toUpperCase() : "a command";
53
+ return refusedByValkey(error) ? `Valkey at ${shown} refused ${boundedText(command)}: ${boundedText(error?.message)}` : `Valkey at ${shown} stopped answering (${boundedText(error?.message)})`;
54
+ }
55
+
56
+ /**
57
+ * The job's state, read under the same bound as every other command here: `{ state }`, or `{ unread }` with why it could
58
+ * not be read. Issue #846: a read that failed was `"unknown"`, which is also what BullMQ says of a job that left its
59
+ * queue, so a job still running was reported as gone "and nothing was changed".
60
+ */
61
+ async function boundedState(job, timeoutMs) {
62
+ let timer;
63
+ try {
64
+ return await Promise.race([
65
+ Promise.resolve(job.getState()).then((state) => ({ state })),
66
+ new Promise((resolve) => {
67
+ timer = setTimeout(() => resolve({ unread: `no answer within ${Math.round(timeoutMs / 1000)}s` }), timeoutMs);
68
+ }),
69
+ ]);
70
+ } catch (error) {
71
+ return { unread: error?.message ?? String(error) };
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
45
77
  /**
46
78
  * Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
47
79
  * so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
48
80
  */
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 } = {}) {
81
+ 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
82
  const fail = (message) => {
51
83
  errWrite(`error: ${message}\n`);
52
84
  return 1;
@@ -55,7 +87,9 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
55
87
  return fail("a job id is required: pi-dispatch cancel <jobId> (ids are in `pi-dispatch status`, the panel, and the run log)");
56
88
  }
57
89
  // 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");
90
+ const { parseConnection, makeRedisClient, VALKEY_COMMAND_TIMEOUT_MS } = await import("./connection.mjs");
91
+ // The bound on each command and on the state reads: the console's, unless a test asks for another.
92
+ const bound = Number.isSafeInteger(commandTimeoutMs) && commandTimeoutMs > 0 ? commandTimeoutMs : VALKEY_COMMAND_TIMEOUT_MS;
59
93
  const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
60
94
  const { readLiveHosts } = await import("./host-registry.mjs");
61
95
  const parseConn = parseConnectionFn ?? parseConnection;
@@ -83,10 +117,14 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
83
117
  });
84
118
  const refused = await refusalOf(url);
85
119
  if (refused) return fail(refused);
86
- const probe = mkRedis(url);
120
+ // BOUNDED (issue #846): each command waits at most VALKEY_COMMAND_TIMEOUT_MS for its answer, and one that timed out is
121
+ // never sent again. The unbounded client this was waited for as long as Valkey was gone: killed during the ack poll it
122
+ // printed nothing until the operator gave up, and on a partition its DEL never reached the catch below.
123
+ const probe = mkRedis(url, { commandTimeoutMs: bound, disconnectTimeoutMs: 0 });
87
124
  probe.on?.("error", () => {});
88
125
  const queues = [];
89
126
  let requested = false;
127
+ let requestedAt = null;
90
128
  try {
91
129
  // The job could sit on the shared queue or on any host's own (issue #57): find it the way pause
92
130
  // spans them, and fail OPEN but LOUDLY on a degraded registry read, exactly as the kill switch does.
@@ -138,6 +176,12 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
138
176
  return 0;
139
177
  }
140
178
  const state = queued.state;
179
+ // Issue #846: `removeQueuedJob` reads a state it could not read as "unknown" (the console's sequence shares it), which
180
+ // BullMQ also says of a job that left its queue. Asked again under the bound, so a read that failed says so.
181
+ if (state === "unknown") {
182
+ const again = await boundedState(job, bound);
183
+ 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`);
184
+ }
141
185
  // The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
142
186
  // the state is re-read and an active job falls through to the active path rather than failing an operator whose
143
187
  // job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
@@ -147,7 +191,8 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
147
191
 
148
192
  if (state === "active") {
149
193
  requested = true;
150
- const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, ...(sleep ? { sleep } : {}) });
194
+ requestedAt = now();
195
+ const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, now, onWritten: () => (requestedAt = now()), ...(sleep ? { sleep } : {}) });
151
196
  if (res.ack !== undefined) {
152
197
  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
198
  return 0;
@@ -155,8 +200,11 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
155
200
  // Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
156
201
  // lands in that moment is never acknowledged and the job is back in the queue. Re-read, so the words are true.
157
202
  // 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");
203
+ const read = await boundedState(job, bound);
159
204
  const waited = `no worker acknowledged within ${Math.round(ackTimeoutMs / 1000)}s`;
205
+ // Issue #846: a state that could not be read is said as that, never as BullMQ's "unknown" (a job that left).
206
+ 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`);
207
+ const after = read.state;
160
208
  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
209
  if (after === "completed" || after === "failed") return fail(`${waited}: the job finished (${after}) before its worker read the cancel, and nothing was changed`);
162
210
  if (after !== "active") return fail(`${waited}: the job is no longer in the queue (its state reads ${after}), and nothing was changed`);
@@ -170,18 +218,58 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
170
218
  // del would sit in ioredis's retry queue and hold this process open long past the error message.
171
219
  // The bound timer is CLEARED when the del wins the race (discoverHostQueues' own rule): cli.mjs
172
220
  // sets exitCode rather than exiting, so a stray pending timer is a second of pure hang.
221
+ // What the withdrawing DEL ANSWERED decides the sentence (issue #846, the second adversary round): 1, it was taken back;
222
+ // 0, it was already gone, because its worker read it (and may have acknowledged) or it expired; no answer, not known.
223
+ // Reading "answered" as "withdrawn" said "nothing was changed" of a cancel its worker had taken and acted on, when a
224
+ // poll was lost to a Valkey that died and came back within the bound.
225
+ const within = (promise, ms) => {
226
+ let timer;
227
+ 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));
228
+ };
229
+ let withdrawal = null;
230
+ let acked = null;
173
231
  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);
232
+ withdrawal = await within(probe.del?.(cancelReqKey(jobId)), 1000);
233
+ if (withdrawal.value !== undefined && Number(withdrawal.value) === 0) {
234
+ const ack = await within(probe.get?.(cancelAckKey(jobId)), 1000);
235
+ if (typeof ack.value === "string") acked = ack.value;
236
+ }
180
237
  }
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)}`);
238
+ if (acked !== null) {
239
+ write(`cancel accepted by ${acked === "" ? "the worker" : boundedText(acked)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
240
+ return 0;
241
+ }
242
+ if (error?.valkeyRefused) return fail(error.message);
243
+ const shown = (await import("./connection.mjs")).urlShown(url);
244
+ // Issue #846: after the request went out, what is said is what became of it. The old line said only "could not reach
245
+ // Valkey ... is it running?", also when Valkey answered with a refusal, and never that a request may still stand.
246
+ if (requested && error?.valkeyMayStillRun) {
247
+ const removed = withdrawal?.value !== undefined ? Number(withdrawal.value) : null;
248
+ 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`);
249
+ 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`);
250
+ const until = new Date((requestedAt ?? now()) + CANCEL_REQ_TTL_MS).toISOString();
251
+ 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";
252
+ 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
+ }
254
+ if (refusedByValkey(error)) return fail(`${valkeyFault(error, shown)}; nothing was changed`);
255
+ return fail(`could not reach Valkey at ${shown}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
182
256
  } finally {
183
257
  probe.disconnect?.();
184
- for (const q of queues) await q.close().catch(() => {});
258
+ // BOUNDED (issue #846): `close()` waits for the answer to QUIT, so on a Valkey that stopped answering the verb could hold
259
+ // its process after its last line. Past the bound the queue is dropped (`disconnect`, no QUIT; ioredis may still take
260
+ // its own disconnect timeout to let the socket go), which nothing here needs an answer to.
261
+ await Promise.all(queues.map((q) => closeWithin(q, closeTimeoutMs)));
185
262
  }
186
263
  }
187
264
 
265
+ /** Close `queue`, or drop its connection when the close is not answered within `ms`. Never throws. */
266
+ async function closeWithin(queue, ms) {
267
+ let timer;
268
+ const closed = await Promise.race([
269
+ Promise.resolve(queue.close()).then(() => true, () => true),
270
+ new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms))),
271
+ ]);
272
+ clearTimeout(timer);
273
+ if (!closed) await Promise.resolve(queue.disconnect?.()).catch(() => {});
274
+ }
275
+
@@ -86,12 +86,18 @@ export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
86
86
  * already own a could-not-reach-Valkey message, and swallowing here would turn an unreachable Valkey into
87
87
  * a lying "no worker acknowledged".
88
88
  *
89
- * `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
89
+ * `pollMs`/`sleep` are injectable so tests never wait on a wall clock. The wait ends at `ackTimeoutMs` by whichever runs
90
+ * out first: the sleeps it took, or the time `now` says has passed (issue #846). Counting sleeps alone, a GET that waited
91
+ * out a Valkey restart in the client's queue was not counted at all, so a 10 s wait could last as long as the outage did.
90
92
  */
91
- export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
93
+ export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, onWritten = null }) {
92
94
  const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
93
95
  const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
94
96
  await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
97
+ // When the request's TTL started, for a caller that names until when it may stand (the CLI, issue #846), and the start
98
+ // of the wait for its acknowledgment, so a slow SET is not counted against it.
99
+ onWritten?.();
100
+ const deadline = now() + ackTimeoutMs;
95
101
  for (let waited = 0; ; waited += pollMs) {
96
102
  let host;
97
103
  try {
@@ -102,7 +108,7 @@ export async function requestCancel({ redis, jobId, interventionOrigin = "human"
102
108
  throw err;
103
109
  }
104
110
  if (host !== null && host !== undefined) return { ack: host };
105
- if (waited + pollMs > ackTimeoutMs) break;
111
+ if (waited + pollMs > ackTimeoutMs || now() >= deadline) break;
106
112
  await sleep(pollMs);
107
113
  }
108
114
  // Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
package/src/cli.mjs CHANGED
@@ -27,7 +27,10 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders
27
27
  --secrets resolves each project secret bundle here and prints its variable names, never values;
28
28
  every run publishes its checks to Valkey for the console and the panel (labels with paths
29
29
  and secret values redacted, kept 7 days), and --no-publish skips that
30
+ pi-dispatch console [--name NAME] [--next /setup] [--port N] [--deployment DIR]
31
+ get into the web console: starts its service if it stopped, then opens your browser signed in
30
32
  pi-dispatch up [--console] [--yes] [--adopt-volume] [--restart-proxy] [--refresh-proxy-rules]
33
+ with --console also: [--ask-each] [--no-open] [--name NAME]
31
34
  one consented pass: pull+tag the job image, start Valkey, init, doctor
32
35
  pi-dispatch setup github mint GitHub App credentials in one browser click (App Manifest flow);
33
36
  every write shown first and individually consented — no --yes here
@@ -161,6 +164,11 @@ export async function main(argv = process.argv.slice(2), env = process.env, { wr
161
164
  return runUp(argv.slice(1), { env });
162
165
  }
163
166
 
167
+ if (cmd === "console") {
168
+ const { runConsoleOpen } = await import("./console-open.mjs");
169
+ return runConsoleOpen(argv.slice(1), { env });
170
+ }
171
+
164
172
  if (cmd === "setup") {
165
173
  // Subcommand shape (`setup <forge>`) so future forges can land beside github without a new
166
174
  // top-level verb; an unknown or missing target prints guidance rather than guessing.
@@ -250,6 +250,13 @@ class BoundedRedis extends Redis {
250
250
  }
251
251
  }
252
252
 
253
+ /**
254
+ * How long one command of a short-lived client may wait for its answer (`commandTimeoutMs`), in the offline queue or on a
255
+ * Valkey that stopped answering: the console's own clients wait as long, and `pi-dispatch cancel` (issue #846) takes it
256
+ * from here, so a cancel against a Valkey that went away ends with a sentence rather than waiting as long as it is gone.
257
+ */
258
+ export const VALKEY_COMMAND_TIMEOUT_MS = 5_000;
259
+
253
260
  /**
254
261
  * A raw ioredis client (the budget's INCR/EXPIRE, the registry and fleet reads, doctor's PING). Built from
255
262
  * `parseConnection`'s options, so it connects through `JudgedConnector` like every other client. `lazyConnect` and
@@ -39,7 +39,7 @@ import { escapeControls } from "./projects.mjs";
39
39
 
40
40
  export const CONSOLE_AUDIT_DIR = "console-audit";
41
41
  export const CONSOLE_AUDIT_KEY = "console:audit";
42
- export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started"]);
42
+ export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started", "login-device", "device-remembered", "device-ended"]);
43
43
  export const CONSOLE_AUDIT_OUTCOMES = Object.freeze(["ok", "refused", "failed"]);
44
44
  // A string field's cap, in code points, AFTER its controls are written out: a row stays small enough for the bounded tail
45
45
  // read below whatever a caller hands in (a Host header, a refusal reason), and no field can bury the others.