@edgehero/pi-dispatch-admin 1.6.1 → 1.7.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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/dist/index.mjs +270 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  > This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
10
10
 
11
+
11
12
  ## How it works
12
13
 
13
14
  Every trigger produces the same job, through the same path: one queue, one container, one budget.
@@ -73,6 +74,13 @@ One command puts a live TUI over the whole deployment:
73
74
  - **AI-operable, with a human gate.** Model-callable tools let an agent change limits (global and scoped) and manage triggers and pause windows, and every **config** write pops an operator confirmation the model cannot answer, refusing outright when no operator is present. Two tools sit outside that gate on purpose: `dispatch_pause` and `dispatch_resume` write durable queue state but are reversible and spend nothing, so they carry no confirm. One more sits outside it and is **not** money-safe: `dispatch_run` enqueues a **paid** run that edits a local folder in place with no undo, bounded instead by six independent limits (the `PI_DISPATCH_RUN_ROOTS` folder allowlist, a committed per-flow `ai-trigger: allow` opt-in read at a pre-agent SHA, a dirty-tree refusal with no force option, no spend knobs on the tool, a per-hour rate limit, and the worker's daily cap). Commands sit outside its reach entirely: `dispatch_run` speaks flows only, and a chained job's request naming a `command` is refused outright, with no opt-in. Read [`SECURITY.md`](https://github.com/edgehero/pi-dispatch/blob/main/SECURITY.md) on that one before you enable it. The bundled `operate-pi-dispatch` skill teaches the agent those gates.
74
75
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
75
76
 
77
+ ## More than one machine
78
+
79
+ The console reads the whole deployment, not one host. The status line names the workers when they have
80
+ been named, `RUN_DETAIL` names the machine that ran a job, the pause switch stops every queue rather than
81
+ the shared one, and the scheduler view spans hosts. On a single host none of that is visible, because
82
+ there is nothing to distinguish. See [`docs/multi-host.md`](../docs/multi-host.md).
83
+
76
84
  ## Install
77
85
 
78
86
  ```bash
package/dist/index.mjs CHANGED
@@ -8326,6 +8326,57 @@ var init_connection = __esm({
8326
8326
  }
8327
8327
  });
8328
8328
 
8329
+ // ../worker/src/host-registry.mjs
8330
+ function bounded(promise, ms = REGISTRY_OP_TIMEOUT_MS) {
8331
+ return new Promise((resolve3, reject) => {
8332
+ const t = setTimeout(() => reject(new Error("registry timeout")), ms);
8333
+ Promise.resolve(promise).then(
8334
+ (v) => (clearTimeout(t), resolve3(v)),
8335
+ (e) => (clearTimeout(t), reject(e))
8336
+ );
8337
+ });
8338
+ }
8339
+ async function readLiveHosts(redis, { now = () => Date.now(), timeoutMs = REGISTRY_OP_TIMEOUT_MS } = {}) {
8340
+ let names;
8341
+ try {
8342
+ names = await bounded(redis.smembers(HOST_SET), timeoutMs);
8343
+ if (names !== null && names !== void 0 && !Array.isArray(names)) throw new Error("smembers did not return a list");
8344
+ } catch (err) {
8345
+ return { unreachable: err?.message ?? "registry unreadable" };
8346
+ }
8347
+ const hosts = [];
8348
+ for (const member of names ?? []) {
8349
+ try {
8350
+ const row = await bounded(redis.hgetall(hostKey(member)), timeoutMs);
8351
+ if (!row || Object.keys(row).length === 0) {
8352
+ await bounded(redis.srem(HOST_SET, member), timeoutMs).catch(() => {
8353
+ });
8354
+ continue;
8355
+ }
8356
+ const beatAt = typeof row.beatAt === "string" && row.beatAt.trim() !== "" ? Number(row.beatAt) : NaN;
8357
+ hosts.push({
8358
+ ...row,
8359
+ name: row.name || member,
8360
+ // Derived rather than stored, so the panel can say "stale 2m" about a row that still lives.
8361
+ // A row whose clock is AHEAD of ours reads as 0 rather than negative: the difference is the
8362
+ // skew between two machines, which is its own signal and not this field's to report.
8363
+ staleMs: Number.isFinite(beatAt) ? Math.max(0, now() - beatAt) : null
8364
+ });
8365
+ } catch {
8366
+ }
8367
+ }
8368
+ hosts.sort((a, b) => String(a.name).localeCompare(String(b.name)));
8369
+ return { hosts };
8370
+ }
8371
+ var HOST_SET, hostKey, REGISTRY_OP_TIMEOUT_MS;
8372
+ var init_host_registry = __esm({
8373
+ "../worker/src/host-registry.mjs"() {
8374
+ HOST_SET = "host:live";
8375
+ hostKey = (name) => `host:h:${name}`;
8376
+ REGISTRY_OP_TIMEOUT_MS = 2e3;
8377
+ }
8378
+ });
8379
+
8329
8380
  // ../worker/src/job-id.mjs
8330
8381
  import { createHash as createHash2 } from "node:crypto";
8331
8382
  function localJobId({ folder, flow, task, minute }) {
@@ -9069,8 +9120,18 @@ var init_triggers = __esm({
9069
9120
 
9070
9121
  // ../worker/src/queue.mjs
9071
9122
  import { Queue } from "bullmq";
9072
- function makeQueue(connection) {
9073
- return new Queue(QUEUE, { connection });
9123
+ function fleetQueueNames(hosts) {
9124
+ const seen = /* @__PURE__ */ new Set();
9125
+ for (const h of hosts ?? []) {
9126
+ if (h?.routes !== true && h?.routes !== "true") continue;
9127
+ const name = h?.name;
9128
+ if (typeof name !== "string" || !WORKER_NAME_RE.test(name)) continue;
9129
+ seen.add(name);
9130
+ }
9131
+ return [QUEUE, ...[...seen].sort().map(hostQueueName)];
9132
+ }
9133
+ function makeQueue(connection, { name = QUEUE } = {}) {
9134
+ return new Queue(name, { connection });
9074
9135
  }
9075
9136
  async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
9076
9137
  const minute = now.toISOString().slice(0, 16);
@@ -9110,7 +9171,37 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
9110
9171
  });
9111
9172
  return id;
9112
9173
  }
9113
- var PR_CLOSE_WORDS, QUEUE, SEMANTIC_WINDOW_MS;
9174
+ async function discoverHostQueues(redis, { timeoutMs = 2e3, count = 500 } = {}) {
9175
+ const prefix = `bull:${QUEUE}@`;
9176
+ const names = /* @__PURE__ */ new Set();
9177
+ try {
9178
+ const deadline = Date.now() + timeoutMs;
9179
+ let cursor = "0";
9180
+ do {
9181
+ let timer;
9182
+ const [next, keys] = await Promise.race([
9183
+ redis.scan(cursor, "MATCH", `${prefix}*:meta`, "COUNT", count),
9184
+ new Promise((_, reject) => {
9185
+ timer = setTimeout(() => reject(new Error("scan timed out")), Math.max(1, deadline - Date.now()));
9186
+ })
9187
+ ]).finally(() => clearTimeout(timer));
9188
+ cursor = next;
9189
+ for (const key of keys ?? []) {
9190
+ const name = String(key).slice(prefix.length, -":meta".length);
9191
+ if (WORKER_NAME_RE.test(name)) names.add(name);
9192
+ }
9193
+ } while (cursor !== "0" && Date.now() < deadline);
9194
+ } catch {
9195
+ return [];
9196
+ }
9197
+ return [...names].sort().map(hostQueueName);
9198
+ }
9199
+ function unionQueueNames(fromRegistry, fromKeyspace) {
9200
+ const seen = /* @__PURE__ */ new Set([...fromRegistry ?? [], ...fromKeyspace ?? []]);
9201
+ seen.delete(QUEUE);
9202
+ return [QUEUE, ...[...seen].sort()];
9203
+ }
9204
+ var PR_CLOSE_WORDS, QUEUE, hostQueueName, WORKER_NAME_RE, SEMANTIC_WINDOW_MS;
9114
9205
  var init_queue = __esm({
9115
9206
  "../worker/src/queue.mjs"() {
9116
9207
  init_job_id();
@@ -9118,6 +9209,8 @@ var init_queue = __esm({
9118
9209
  init_triggers();
9119
9210
  PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
9120
9211
  QUEUE = "pi-jobs";
9212
+ hostQueueName = (worker) => `${QUEUE}@${worker}`;
9213
+ WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
9121
9214
  SEMANTIC_WINDOW_MS = 10 * 60 * 1e3;
9122
9215
  }
9123
9216
  });
@@ -10625,32 +10718,73 @@ function parseNonNegInt(raw, fallback) {
10625
10718
  const n = Number.parseInt(raw, 10);
10626
10719
  return Number.isInteger(n) && n >= 0 && String(n) === String(raw).trim() ? n : fallback;
10627
10720
  }
10628
- async function readQueueState({ url, makeQueueFn = makeQueue, parseConnectionFn = parseConnection } = {}) {
10721
+ async function readQueueState({ url, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10629
10722
  let queue;
10723
+ const queuesToClose = [];
10630
10724
  try {
10631
- queue = makeQueueFn(parseConnectionFn(url, { failFast: true }));
10632
- const pausedState = await queue.isPaused();
10633
- const counts = await queue.getJobCounts("waiting", "active", "paused", "delayed", "failed");
10634
- const workers = await readWorkerCount(queue);
10635
- return { pausedState, counts, workers };
10725
+ const { names, hosts, blind } = await readFleetQueues({ url, redisFn, timeoutMs });
10726
+ const fleet = blind ? { unreachable: blind } : { hosts };
10727
+ const queues = names.map((name) => makeQueueFn(parseConnectionFn(url, { failFast: true }), { name }));
10728
+ queuesToClose.push(...queues);
10729
+ queue = queues[0];
10730
+ const pausedStates = await Promise.all(queues.map((q) => q.isPaused()));
10731
+ const pausedState = pausedStates.every(Boolean);
10732
+ const pausedPartial = pausedStates.some(Boolean) && !pausedState;
10733
+ const perQueue = await Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed")));
10734
+ const counts = perQueue.reduce((acc, c) => {
10735
+ for (const [k, v] of Object.entries(c ?? {})) acc[k] = (acc[k] ?? 0) + (Number(v) || 0);
10736
+ return acc;
10737
+ }, {});
10738
+ const workerCount = await readWorkerCount(queue);
10739
+ const resolved = resolveWorkerCount({ hosts: fleet.hosts ?? [], workerCount: typeof workerCount === "number" ? workerCount : 0 });
10740
+ return { pausedState, ...pausedPartial && { pausedPartial }, counts, workers: resolved.count, workerNames: resolved.names };
10636
10741
  } catch (err) {
10637
10742
  return { unreachable: err?.message ?? String(err) };
10638
10743
  } finally {
10639
- if (queue) await queue.close().catch(() => {
10744
+ for (const q of queuesToClose) await q.close().catch(() => {
10640
10745
  });
10641
10746
  }
10642
10747
  }
10643
- async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConnectionFn = parseConnection } = {}) {
10644
- let queue;
10748
+ async function readFleetQueues({ url, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10749
+ const redis = redisFn(url);
10750
+ redis.on?.("error", () => {
10751
+ });
10752
+ try {
10753
+ const [fleet, existing] = await Promise.all([
10754
+ withTimeout(readLiveHosts(redis, { timeoutMs: Math.max(250, Math.floor(timeoutMs / 2)) }), timeoutMs, { unreachable: "timed out reaching the registry" }),
10755
+ discoverHostQueues(redis, { timeoutMs })
10756
+ ]);
10757
+ return { names: unionQueueNames(fleetQueueNames(fleet?.hosts), existing), blind: fleet?.unreachable ?? null, hosts: fleet?.hosts ?? [] };
10758
+ } catch (err) {
10759
+ return { names: [QUEUE], blind: err?.message ?? String(err), hosts: [] };
10760
+ } finally {
10761
+ try {
10762
+ redis.disconnect?.();
10763
+ } catch {
10764
+ }
10765
+ }
10766
+ }
10767
+ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConnectionFn = parseConnection, redisFn = makeRedisClient, timeoutMs = 2500 } = {}) {
10768
+ const opened = [];
10645
10769
  try {
10646
- queue = makeQueueFn(parseConnectionFn(url, { failFast: true }));
10647
- if (paused) await queue.pause();
10648
- else await queue.resume();
10649
- return { ok: true, paused };
10770
+ const { names, blind } = await readFleetQueues({ url, redisFn, timeoutMs });
10771
+ const done = [];
10772
+ for (const name of names) {
10773
+ const q = makeQueueFn(parseConnectionFn(url, { failFast: true }), { name });
10774
+ opened.push(q);
10775
+ try {
10776
+ if (paused) await q.pause();
10777
+ else await q.resume();
10778
+ } catch (err) {
10779
+ return { unreachable: err?.message ?? String(err), partial: { done, failed: name, error: err?.message ?? String(err) }, blind };
10780
+ }
10781
+ done.push(name);
10782
+ }
10783
+ return { ok: true, paused, queues: done, ...blind ? { blind } : {} };
10650
10784
  } catch (err) {
10651
10785
  return { unreachable: err?.message ?? String(err) };
10652
10786
  } finally {
10653
- if (queue) await queue.close().catch(() => {
10787
+ for (const q of opened) await q.close().catch(() => {
10654
10788
  });
10655
10789
  }
10656
10790
  }
@@ -10725,7 +10859,8 @@ async function enqueueDispatchRun({
10725
10859
  }
10726
10860
  let queue;
10727
10861
  try {
10728
- queue = makeQueueFn(parseConnectionFn(valkeyUrl, { failFast: true }));
10862
+ const workerName = process.env.PI_WORKER_NAME;
10863
+ queue = makeQueueFn(parseConnectionFn(valkeyUrl, { failFast: true }), { ...workerName ? { name: hostQueueName(workerName) } : {} });
10729
10864
  const jobId = await enqueueLocalJob(queue, { folder, flow, task });
10730
10865
  return { ok: true, jobId };
10731
10866
  } catch (err) {
@@ -10872,6 +11007,29 @@ async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeou
10872
11007
  }
10873
11008
  }
10874
11009
  }
11010
+ async function readHosts({ url, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
11011
+ let redis;
11012
+ try {
11013
+ parseConnection(url, { failFast: true });
11014
+ redis = redisFn(url);
11015
+ const res = await withTimeout(readLiveHosts(redis, { now, timeoutMs: Math.max(250, Math.floor(timeoutMs / 2)) }), timeoutMs, { unreachable: "timed out reaching the registry" });
11016
+ if (res.unreachable) return { unreachable: res.unreachable };
11017
+ return { hosts: res.hosts };
11018
+ } catch (err) {
11019
+ return { unreachable: err?.message ?? String(err) };
11020
+ } finally {
11021
+ if (redis) redis.disconnect?.();
11022
+ }
11023
+ }
11024
+ function resolveWorkerCount({ hosts, workerCount }) {
11025
+ if (Array.isArray(hosts) && hosts.length > 0) {
11026
+ const declared = hosts.filter((h) => h?.routes === true || h?.routes === "true");
11027
+ const names = declared.map((h) => h.name).filter(Boolean).sort();
11028
+ return { count: hosts.length, names };
11029
+ }
11030
+ if (typeof workerCount === "number" && workerCount > 0) return { count: workerCount, names: [] };
11031
+ return { count: "unknown", names: [] };
11032
+ }
10875
11033
  async function readHeldJobs({ url, limit = 20, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
10876
11034
  let redis;
10877
11035
  try {
@@ -10990,17 +11148,22 @@ async function readSchedulers({
10990
11148
  url,
10991
11149
  makeQueueFn = makeQueue,
10992
11150
  parseConnectionFn = parseConnection,
11151
+ redisFn = makeRedisClient,
11152
+ timeoutMs = 2500,
10993
11153
  now = Date.now
10994
11154
  } = {}) {
10995
- let queue;
11155
+ const opened = [];
10996
11156
  try {
10997
- queue = makeQueueFn(parseConnectionFn(url, { failFast: true }));
10998
- const list = await queue.getJobSchedulers(0, -1, true);
10999
- return mapSchedulers(list, now());
11157
+ const fleet = await readHosts({ url, redisFn, timeoutMs });
11158
+ const queues = fleetQueueNames(fleet.hosts).map((name) => makeQueueFn(parseConnectionFn(url, { failFast: true }), { name }));
11159
+ opened.push(...queues);
11160
+ const [primary, ...rest] = queues;
11161
+ const lists = [await primary.getJobSchedulers(0, -1, true), ...await Promise.all(rest.map((q) => q.getJobSchedulers(0, -1, true).catch(() => [])))];
11162
+ return mapSchedulers(lists.flat(), now());
11000
11163
  } catch (err) {
11001
11164
  return { unreachable: err?.message ?? String(err) };
11002
11165
  } finally {
11003
- if (queue) await queue.close().catch(() => {
11166
+ for (const q of opened) await q.close().catch(() => {
11004
11167
  });
11005
11168
  }
11006
11169
  }
@@ -11607,6 +11770,7 @@ var init_read_model = __esm({
11607
11770
  init_wait_state();
11608
11771
  init_subscriptions();
11609
11772
  init_connection();
11773
+ init_host_registry();
11610
11774
  init_queue();
11611
11775
  init_flow_gate();
11612
11776
  init_git_dirty();
@@ -39692,7 +39856,9 @@ function renderStatus(queue) {
39692
39856
  const counts = queue.counts ?? {};
39693
39857
  const line = ["waiting", "active", "paused", "delayed", "failed"].map((k) => `${k} ${counts[k] ?? 0}`).join(" ");
39694
39858
  const workers = queue.workers === void 0 ? "unknown" : queue.workers;
39695
- return [`Queue: ${queue.pausedState ? "paused" : "running"}`, ` ${line}`, ` workers: ${workers}`].join("\n");
39859
+ const named = Array.isArray(queue.workerNames) && queue.workerNames.length > 0 ? ` (${queue.workerNames.join(", ")})` : "";
39860
+ const state = queue.pausedPartial ? "PARTIALLY paused" : queue.pausedState ? "paused" : "running";
39861
+ return [`Queue: ${state}`, ` ${line}`, ` workers: ${workers}${named}`].join("\n");
39696
39862
  }
39697
39863
  function renderRuns(runs) {
39698
39864
  if (runs && runs.unreachable) return `Runs: unreachable (${runs.unreachable})`;
@@ -40131,27 +40297,67 @@ async function heldJobs(redis) {
40131
40297
  rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
40132
40298
  return { rows: rows.slice(0, HELD_LIMIT), more: Math.max(0, rows.length - HELD_LIMIT), truncated: ids.length > HELD_HYDRATE_MAX2 };
40133
40299
  }
40134
- function createDashboardDeps(paths) {
40135
- const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
40136
- const redis = makeRedisClient(paths.valkeyUrl);
40300
+ function createDashboardDeps(paths, {
40301
+ makeQueueFn = makeQueue,
40302
+ parseConnectionFn = parseConnection,
40303
+ redisFn = makeRedisClient,
40304
+ readLiveHostsFn = readLiveHosts,
40305
+ discoverHostQueuesFn = discoverHostQueues
40306
+ } = {}) {
40307
+ const queue = makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }));
40308
+ const redis = redisFn(paths.valkeyUrl);
40309
+ const pool = /* @__PURE__ */ new Map([[queue.name, queue]]);
40310
+ let lastNames = [queue.name];
40311
+ let fleetDegraded = null;
40312
+ const fleetQueues = async () => {
40313
+ const fleet = await readLiveHostsFn(redis, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((err) => ({
40314
+ unreachable: err?.message ?? String(err)
40315
+ }));
40316
+ if (fleet?.unreachable || !Array.isArray(fleet?.hosts)) {
40317
+ fleetDegraded = fleet?.unreachable ?? "registry unreadable";
40318
+ } else {
40319
+ fleetDegraded = null;
40320
+ lastNames = fleetQueueNames(fleet.hosts);
40321
+ }
40322
+ for (const name of lastNames) {
40323
+ if (!pool.has(name)) pool.set(name, makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }), { name }));
40324
+ }
40325
+ return lastNames.map((n) => pool.get(n));
40326
+ };
40327
+ const killSwitchQueues = async () => {
40328
+ const existing = await discoverHostQueuesFn(redis, { timeoutMs: FLEET_READ_TIMEOUT_MS });
40329
+ for (const name of unionQueueNames(lastNames, existing)) {
40330
+ if (!pool.has(name)) pool.set(name, makeQueueFn(parseConnectionFn(paths.valkeyUrl, { failFast: true }), { name }));
40331
+ }
40332
+ return unionQueueNames(lastNames, existing).map((n) => pool.get(n));
40333
+ };
40137
40334
  return {
40138
40335
  async fetchSnapshot() {
40139
- const [pausedState, counts, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerList, activeList, stallHash] = await Promise.all([
40140
- queue.isPaused(),
40141
- queue.getJobCounts("waiting", "active", "paused", "delayed", "failed"),
40336
+ const queues = await fleetQueues();
40337
+ const [pausedStates, countsPer, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerLists, activeLists, stallHash] = await Promise.all([
40338
+ Promise.all(queues.map((q) => q.isPaused())),
40339
+ Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))),
40142
40340
  queue.getWorkers().catch(() => []),
40143
40341
  redis.get(dayKey()),
40144
40342
  redis.get(weekKey()),
40145
40343
  redis.get(monthKey()),
40146
40344
  redis.get(tokenDayKey()),
40147
40345
  // issue #25 daily token spend (budget:t:YYYY-MM-DD)
40148
- queue.getJobSchedulers(0, -1, true),
40149
- queue.getActive(0, 0).catch(() => []),
40346
+ // The shared queue's failure is UNCAUGHT (the panel must not draw a scheduler list it could not
40347
+ // read), a host queue's is caught: one unreachable host degrades one host's triggers, never the view.
40348
+ Promise.all(queues.map((q, i) => i === 0 ? q.getJobSchedulers(0, -1, true) : q.getJobSchedulers(0, -1, true).catch(() => []))),
40349
+ Promise.all(queues.map((q) => q.getActive(0, 0).catch(() => []))),
40150
40350
  // Per-scheduler stall counts (money backstop) for the cron drill-in; reuses the held client like the
40151
40351
  // budget GETs. HGETALL of an absent key is `{}`, so a never-stalled deployment shows 0 stalls.
40152
40352
  redis.hgetall(STALL_KEY).catch(() => ({}))
40153
40353
  ]);
40154
40354
  const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
40355
+ const counts = {};
40356
+ for (const c of countsPer) for (const [k, v] of Object.entries(c ?? {})) counts[k] = (counts[k] ?? 0) + Number(v ?? 0);
40357
+ const pausedState = pausedStates.every(Boolean);
40358
+ const pausedPartial = !pausedState && pausedStates.some(Boolean);
40359
+ const schedulerList = schedulerLists.flat();
40360
+ const activeList = activeLists.flat();
40155
40361
  const scopedLimits = readScopedLimits({ scopedLimitsPath: paths.scopedLimitsPath });
40156
40362
  const limitRows = Array.isArray(scopedLimits?.limits) ? scopedLimits.limits : [];
40157
40363
  const scopedNow = /* @__PURE__ */ new Date();
@@ -40173,7 +40379,7 @@ function createDashboardDeps(paths) {
40173
40379
  held,
40174
40380
  scopedLimits,
40175
40381
  scopedBudget,
40176
- queue: { pausedState, counts, workers },
40382
+ queue: { pausedState, pausedPartial, counts, workers, queues: queues.length, fleetDegraded },
40177
40383
  budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
40178
40384
  schedulers: mapSchedulers(schedulerList, Date.now()),
40179
40385
  schedulerStalls: stallHash ?? {},
@@ -40193,15 +40399,19 @@ function createDashboardDeps(paths) {
40193
40399
  };
40194
40400
  },
40195
40401
  async pause() {
40196
- await queue.pause();
40402
+ await fleetQueues();
40403
+ for (const q of await killSwitchQueues()) await q.pause();
40197
40404
  },
40198
40405
  async resume() {
40199
- await queue.resume();
40406
+ await fleetQueues();
40407
+ for (const q of await killSwitchQueues()) await q.resume();
40200
40408
  },
40201
40409
  async dispose() {
40202
- try {
40203
- await queue.close();
40204
- } catch {
40410
+ for (const q of pool.values()) {
40411
+ try {
40412
+ await q.close();
40413
+ } catch {
40414
+ }
40205
40415
  }
40206
40416
  try {
40207
40417
  redis.disconnect();
@@ -40743,11 +40953,11 @@ function statusHeader(queue, inner, styler) {
40743
40953
  return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
40744
40954
  }
40745
40955
  const c = queue.counts ?? {};
40746
- const running = !queue.pausedState;
40956
+ const running = !queue.pausedState && !queue.pausedPartial;
40747
40957
  const failed = Number(c.failed ?? 0);
40748
40958
  const stateColor = running ? "success" : "warning";
40749
40959
  const dot = styler.fg(stateColor, "\u25CF");
40750
- const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : "PAUSED"));
40960
+ const word = styler.bold(styler.fg(stateColor, running ? "RUNNING" : queue.pausedPartial ? "PART PAUSED" : "PAUSED"));
40751
40961
  const sep2 = styler.fg("dim", " \xB7 ");
40752
40962
  const delayed = Number(c.delayed ?? 0);
40753
40963
  const vitals = `${c.waiting ?? 0} waiting` + sep2 + `${c.active ?? 0} active` + sep2 + (delayed > 0 ? `${delayed} delayed` + sep2 : "") + (failed > 0 ? styler.fg("error", `${failed} failed`) : `${failed} failed`) + sep2 + `${queue.workers ?? "?"} workers`;
@@ -41267,6 +41477,7 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
41267
41477
  const endMs = toMs(r.endedAt);
41268
41478
  const dur = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs ? ` (${formatDuration(endMs - startMs)})` : "";
41269
41479
  out.push(kv("timing", `${fmtStamp(r.startedAt)} \u2192 ${fmtStamp(r.endedAt)}${dur}`));
41480
+ if (r.host) out.push(kv("host", String(r.host)));
41270
41481
  const turnBits = [`${show(r.turns)} turns`, `exit ${show(r.exitCode)}`];
41271
41482
  if (r.budgetReserved !== null && r.budgetReserved !== void 0) turnBits.push(`${r.budgetReserved} budget slot`);
41272
41483
  if (r.attempt !== null && r.attempt !== void 0) turnBits.push(`attempt ${r.attempt}`);
@@ -41346,12 +41557,13 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
41346
41557
  if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
41347
41558
  return fitLine(line, inner, styler);
41348
41559
  }
41349
- var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, HELD_LIMIT, HELD_HYDRATE_MAX2, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, HELD_ON_DASHBOARD;
41560
+ var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, HELD_LIMIT, HELD_HYDRATE_MAX2, FLEET_READ_TIMEOUT_MS, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS, HELD_ON_DASHBOARD;
41350
41561
  var init_dashboard = __esm({
41351
41562
  "src/dashboard.ts"() {
41352
41563
  init_budget();
41353
41564
  init_connection();
41354
41565
  init_queue();
41566
+ init_host_registry();
41355
41567
  init_scheduler_stall_guard();
41356
41568
  init_pause_windows();
41357
41569
  init_read_model();
@@ -41371,6 +41583,7 @@ var init_dashboard = __esm({
41371
41583
  DRILL_WIDTH = 70;
41372
41584
  HELD_LIMIT = 20;
41373
41585
  HELD_HYDRATE_MAX2 = 200;
41586
+ FLEET_READ_TIMEOUT_MS = 2e3;
41374
41587
  KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
41375
41588
  KIND_WIDTH = 13;
41376
41589
  FRAME_CHROME_ROWS = 4;
@@ -41908,7 +42121,7 @@ var init_setup_wizard = __esm({
41908
42121
  init_deployment_pointer();
41909
42122
  init_read_model();
41910
42123
  init_index();
41911
- RUNTIME_VERSION = "1.6.1";
42124
+ RUNTIME_VERSION = "1.7.0";
41912
42125
  RECEIVER_VERSION = "1.4.0";
41913
42126
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41914
42127
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
@@ -42089,7 +42302,7 @@ function registerTools(pi) {
42089
42302
  pi.registerTool({
42090
42303
  name: "dispatch_runs",
42091
42304
  label: "pi-dispatch runs",
42092
- description: "Read-only. Returns structured, PII-free run records from the durable run history. Raw job logs are not available to tools \u2014 ask the user to run /dispatch logs.",
42305
+ description: "Read-only. Returns structured run records (PII-free by construction, plus the worker name when the operator declared one) from the durable run history. Raw job logs are not available to tools \u2014 ask the user to run /dispatch logs.",
42093
42306
  parameters: typebox_exports.Object({
42094
42307
  limit: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1, maximum: 50 })),
42095
42308
  jobId: typebox_exports.Optional(typebox_exports.String())
@@ -42128,7 +42341,10 @@ function registerTools(pi) {
42128
42341
  if (res.unreachable) {
42129
42342
  throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
42130
42343
  }
42131
- return toolText("paused");
42344
+ if (res.partial) {
42345
+ throw new Error(`paused ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half paused: ${res.partial.error}`);
42346
+ }
42347
+ return toolText(`paused${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`);
42132
42348
  }
42133
42349
  });
42134
42350
  pi.registerTool({
@@ -42143,7 +42359,10 @@ function registerTools(pi) {
42143
42359
  if (res.unreachable) {
42144
42360
  throw new Error(`could not reach the queue at ${paths.valkeyUrl}: ${res.unreachable}`);
42145
42361
  }
42146
- return toolText("resumed");
42362
+ if (res.partial) {
42363
+ throw new Error(`resumed ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half resumed: ${res.partial.error}`);
42364
+ }
42365
+ return toolText(`resumed${res.queues.length > 1 ? ` (${res.queues.length} queues)` : ""}`);
42147
42366
  }
42148
42367
  });
42149
42368
  pi.registerTool({
@@ -42758,11 +42977,15 @@ ${scopedText}` : ""}`);
42758
42977
  const paused = sub === "pause";
42759
42978
  const res = await setQueuePaused({ url: paths.valkeyUrl, paused });
42760
42979
  if (res.unreachable) {
42761
- notify?.(`could not reach Valkey at ${paths.valkeyUrl} \u2014 is it running? (docker compose up)`, "error");
42980
+ notify?.(
42981
+ res.partial ? `${paused ? "paused" : "resumed"} ${res.partial.done.join(", ")} but FAILED at ${res.partial.failed} \u2014 the deployment is half ${paused ? "paused" : "resumed"}` : `could not reach Valkey at ${paths.valkeyUrl} \u2014 is it running? (docker compose up)`,
42982
+ "error"
42983
+ );
42762
42984
  return;
42763
42985
  }
42986
+ const span = res.blind ? ` (fleet unknown: ${res.blind}, only ${res.queues[0]})` : res.queues.length > 1 ? ` (${res.queues.length} queues)` : "";
42764
42987
  notify?.(
42765
- paused ? "paused \u2014 worker will stop taking new jobs (jobs still enqueue; durable, survives restart)" : "resumed",
42988
+ paused ? `paused \u2014 worker will stop taking new jobs (jobs still enqueue; durable, survives restart)${span}` : `resumed${span}`,
42766
42989
  "info"
42767
42990
  );
42768
42991
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
5
5
  "keywords": [
6
6
  "pi-package",