@edgehero/pi-dispatch-admin 1.5.0 → 1.6.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
@@ -69,6 +69,7 @@ One command puts a live TUI over the whole deployment:
69
69
  - **Triggers, editable live.** cron, label, comment, pull_request and issue triggers with colored drill-ins showing what fires each one, what it runs, and its trust model. Added, edited and deleted without a restart. A one-shot (`once: true`) shows armed or spent in its drill-in: a spent entry stays in the list, matches nothing, and re-arms when the operator deletes its `on.disarmed` mark from the file. Triggers that run third-party code or a custom image are badged; opting in or out of either stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
70
70
  - **Quiet hours.** Scheduled pause windows per folder or repo: defer runs between certain times, timezone-aware, and resume automatically. Deferred, never dropped, at zero budget cost.
71
71
  - **Scoped limits.** Per-repo or per-folder job caps (day, week, month) refused before any spend, plus a per-scope concurrency ceiling enforced by deferral. Local jobs also hold a one-job-per-folder guard that is always on and has no switch (it lives in the worker process, and one worker per docker daemon is the supported shape), because two agents editing one working tree race each other with no gate and no undo.
72
+ - **Held jobs, with what they are waiting for.** A trigger carrying `run.waitFor` holds its job in the queue, unstarted and unbilled, until an instant passes or a check script the operator wrote exits 0. The panel grows a **held** section while anything waits (the target, the condition, and how long it has waited) and hides it again when nothing does, so a wait stops being an anonymous entry in the delayed count. `dispatch_waits` lists them and `dispatch_wait_cancel` stops one behind the operator confirm, which is the only way to cancel a held job short of editing redis by hand ([`docs/wait-for.md`](https://github.com/edgehero/pi-dispatch/blob/main/docs/wait-for.md)).
72
73
  - **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.
73
74
  - **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
74
75
 
package/dist/index.mjs CHANGED
@@ -7687,6 +7687,33 @@ var init_secret_profiles = __esm({
7687
7687
  }
7688
7688
  });
7689
7689
 
7690
+ // ../worker/src/wait-for.mjs
7691
+ function parseAfterInstant(text) {
7692
+ if (typeof text !== "string" || !AFTER_INSTANT.test(text)) return { error: "shape" };
7693
+ const ms = Date.parse(text);
7694
+ if (!Number.isFinite(ms)) return { error: "calendar" };
7695
+ const [year, month, day] = text.slice(0, 10).split("-").map(Number);
7696
+ const probe = new Date(Date.UTC(year, month - 1, day));
7697
+ if (year < 100) probe.setUTCFullYear(year);
7698
+ if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) {
7699
+ return { error: "calendar" };
7700
+ }
7701
+ return { ms };
7702
+ }
7703
+ function afterInstantMs(text) {
7704
+ const parsed = parseAfterInstant(text);
7705
+ return parsed.error ? null : parsed.ms;
7706
+ }
7707
+ var WAIT_CONDITION_KEYS, WAIT_CONDITION_MAX, WAIT_AFTER_MAX_DEFAULT_MS, AFTER_INSTANT;
7708
+ var init_wait_for = __esm({
7709
+ "../worker/src/wait-for.mjs"() {
7710
+ WAIT_CONDITION_KEYS = ["after", "profile"];
7711
+ WAIT_CONDITION_MAX = 4;
7712
+ WAIT_AFTER_MAX_DEFAULT_MS = 30 * 24 * 3600 * 1e3;
7713
+ AFTER_INSTANT = /^\d{4}-\d{2}-\d{2}[Tt]([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?([Zz]|[+-]\d{2}:\d{2})$/;
7714
+ }
7715
+ });
7716
+
7690
7717
  // ../worker/src/config.mjs
7691
7718
  function configError(message) {
7692
7719
  const error = new Error(message);
@@ -7714,6 +7741,7 @@ var init_config2 = __esm({
7714
7741
  init_egress();
7715
7742
  init_forges();
7716
7743
  init_secret_profiles();
7744
+ init_wait_for();
7717
7745
  CHAIN_DEPTH_MAX_DEFAULT = 1;
7718
7746
  CHAIN_MAX_PER_JOB_DEFAULT = 2;
7719
7747
  WORKER_ONLY_SECRET_VARS = /* @__PURE__ */ new Set(["GITHUB_APP_PRIVATE_KEY"]);
@@ -8114,6 +8142,18 @@ var init_scoped_limits = __esm({
8114
8142
  }
8115
8143
  });
8116
8144
 
8145
+ // ../worker/src/wait-state.mjs
8146
+ var LEASE_SLACK_MS, LEASE_MIN_MS, HELD_SET, jobKey, SATISFIED_MS;
8147
+ var init_wait_state = __esm({
8148
+ "../worker/src/wait-state.mjs"() {
8149
+ LEASE_SLACK_MS = 60 * 60 * 1e3;
8150
+ LEASE_MIN_MS = 5 * 60 * 1e3;
8151
+ HELD_SET = "wait:held";
8152
+ jobKey = (jobId) => `wait:job:${jobId}`;
8153
+ SATISFIED_MS = 15 * 60 * 1e3;
8154
+ }
8155
+ });
8156
+
8117
8157
  // ../worker/src/subscriptions.mjs
8118
8158
  function isNonEmptyString4(value) {
8119
8159
  return typeof value === "string" && value.trim() !== "";
@@ -8466,6 +8506,7 @@ function normalizeCron(on, run, index, path, state) {
8466
8506
  validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
8467
8507
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
8468
8508
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
8509
+ validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
8469
8510
  return {
8470
8511
  on: { type: "cron", id, pattern },
8471
8512
  run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
@@ -8715,6 +8756,81 @@ function validateSecretsProfile(run, at, path) {
8715
8756
  }
8716
8757
  return profile;
8717
8758
  }
8759
+ function validateWaitFor(on, run, at, path, { onType }) {
8760
+ for (const [label, source, exactIsLegal] of [
8761
+ ["run", run, true],
8762
+ ["on", on, false]
8763
+ ]) {
8764
+ for (const key of Object.keys(source ?? {})) {
8765
+ if (exactIsLegal && key === "waitFor") continue;
8766
+ if (key.replace(/[^a-z0-9]/gi, "").toLowerCase() !== "waitfor") continue;
8767
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.waitFor? A wait condition the loader drops holds nothing while the file reads as though it does, so a near miss is refused rather than dropped: ${path}`);
8768
+ }
8769
+ }
8770
+ const waitFor = run.waitFor;
8771
+ if (waitFor === void 0) return void 0;
8772
+ if (onType === "cron") {
8773
+ throw configError(`${at}: run.waitFor is not yet covered on a cron trigger -- the scheduler advances at pickup, so a held occurrence outlives the teardown that would delete it and still pays, and its surviving job hash can fail worker boot with a scheduler id collision; bounding that needs delayed-set-aware teardown, which is a gap to close, not a limit: ${path}`);
8774
+ }
8775
+ if (on?.once === true) {
8776
+ throw configError(`${at}: run.waitFor and on.once cannot be combined -- the one-shot disarms on every run record, so a wait that never cleared would spend the one-shot with no container ever started and no way to re-arm it but editing this file: ${path}`);
8777
+ }
8778
+ if (run.replicas !== void 0) {
8779
+ throw configError(`${at}: run.waitFor and run.replicas cannot be combined -- replicas fan out at enqueue, so each one would hold and poll the same external condition independently, multiplying the checks by ${run.replicas} for one answer: ${path}`);
8780
+ }
8781
+ if (!Array.isArray(waitFor) || waitFor.length === 0) {
8782
+ throw configError(`${at}: run.waitFor must be a non-empty array of conditions when present (got ${JSON.stringify(waitFor)}): ${path}`);
8783
+ }
8784
+ if (waitFor.length > WAIT_CONDITION_MAX) {
8785
+ throw configError(`${at}: run.waitFor names ${waitFor.length} conditions, over the ${WAIT_CONDITION_MAX} cap -- each profile condition is a subprocess run before the container, holding a concurrency slot while it runs: ${path}`);
8786
+ }
8787
+ const expected = WAIT_CONDITION_KEYS.join("|");
8788
+ const normalized = [];
8789
+ const profiles = /* @__PURE__ */ new Set();
8790
+ let seenAfter = false;
8791
+ for (let i = 0; i < waitFor.length; i += 1) {
8792
+ const condition = waitFor[i];
8793
+ const where = `${at}: run.waitFor[${i}]`;
8794
+ if (condition === null || typeof condition !== "object" || Array.isArray(condition)) {
8795
+ throw configError(`${where} must be an object naming exactly one condition (got ${JSON.stringify(condition)}): ${path}`);
8796
+ }
8797
+ const keys = Object.keys(condition);
8798
+ if (keys.length !== 1) {
8799
+ throw configError(`${where} must name exactly one condition (expected ${expected}), got ${keys.length === 0 ? "an empty object" : JSON.stringify(keys)}: ${path}`);
8800
+ }
8801
+ const key = keys[0];
8802
+ if (key === "exclusive") {
8803
+ throw configError(`${where}: exclusive is no longer a condition you write -- the worker already holds at most one local job per folder, always on, with no configuration and no off switch, and scoped-limits.json bounds any scope further: ${path}`);
8804
+ }
8805
+ if (!WAIT_CONDITION_KEYS.includes(key)) {
8806
+ throw configError(`${where} has an unsupported condition ${JSON.stringify(key)} (expected ${expected}) -- an unknown condition here is a term of the gate that would silently do nothing, so it is refused rather than dropped: ${path}`);
8807
+ }
8808
+ if (key === "after") {
8809
+ if (seenAfter) {
8810
+ throw configError(`${where}: run.waitFor names a second "after" -- the conditions are a conjunction, so two instants mean the later one and the file would read as though both mattered: ${path}`);
8811
+ }
8812
+ if (afterInstantMs(condition.after) === null) {
8813
+ throw configError(`${where}: after must be an ISO-8601 instant carrying its own zone, like "2026-09-01T09:00:00Z" or "2026-09-01T11:00:00+02:00" (got ${JSON.stringify(condition.after)}) -- without a zone the same file would hold for a different instant on a worker in a different timezone: ${path}`);
8814
+ }
8815
+ seenAfter = true;
8816
+ normalized.push({ after: condition.after });
8817
+ continue;
8818
+ }
8819
+ const profile = condition.profile;
8820
+ if (!isNonEmptyString5(profile)) {
8821
+ throw configError(`${where}: profile must be a non-empty string naming one of the wait profiles this deployment declares (got ${JSON.stringify(profile)}): ${path}`);
8822
+ }
8823
+ if (!ID_CHARSET.test(profile)) {
8824
+ throw configError(`${where}: profile ${JSON.stringify(profile)} may use letters, digits, dot, dash and underscore only -- PI_WAIT_PROFILES is a comma-separated list of name:path pairs, so a name carrying either separator cannot be declared: ${path}`);
8825
+ }
8826
+ if (profiles.has(profile)) {
8827
+ throw configError(`${where}: run.waitFor names the profile ${JSON.stringify(profile)} twice -- the conditions are a conjunction, so the second one can never change the answer while it doubles the subprocesses: ${path}`);
8828
+ }
8829
+ profiles.add(profile);
8830
+ normalized.push({ profile });
8831
+ }
8832
+ return normalized;
8833
+ }
8718
8834
  function normalizeLabel(on, run, index, path) {
8719
8835
  const at = `trigger at index ${index}`;
8720
8836
  const predicate = validatePredicate(on, index, path, true);
@@ -8735,9 +8851,10 @@ function normalizeLabel(on, run, index, path) {
8735
8851
  const replicas = validateReplicas(run, at, path);
8736
8852
  const secrets = validateSecrets(run, at, path);
8737
8853
  const secretsProfile = validateSecretsProfile(run, at, path);
8854
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8738
8855
  return {
8739
8856
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
8740
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8857
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8741
8858
  };
8742
8859
  }
8743
8860
  function normalizeComment(on, run, index, path, state) {
@@ -8766,9 +8883,10 @@ function normalizeComment(on, run, index, path, state) {
8766
8883
  const replicas = validateReplicas(run, at, path);
8767
8884
  const secrets = validateSecrets(run, at, path);
8768
8885
  const secretsProfile = validateSecretsProfile(run, at, path);
8886
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8769
8887
  return {
8770
8888
  on: { type: "comment", phrase: on.phrase },
8771
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8889
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8772
8890
  };
8773
8891
  }
8774
8892
  function normalizeIssue(on, run, index, path) {
@@ -8807,6 +8925,7 @@ function normalizeIssue(on, run, index, path) {
8807
8925
  const replicas = validateReplicas(run, at, path);
8808
8926
  const secrets = validateSecrets(run, at, path);
8809
8927
  const secretsProfile = validateSecretsProfile(run, at, path);
8928
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8810
8929
  return {
8811
8930
  on: {
8812
8931
  type: "issue",
@@ -8816,7 +8935,7 @@ function normalizeIssue(on, run, index, path) {
8816
8935
  ...number !== void 0 && { number },
8817
8936
  ...once !== void 0 && { once }
8818
8937
  },
8819
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
8938
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8820
8939
  };
8821
8940
  }
8822
8941
  function normalizePullRequest(on, run, index, path) {
@@ -8864,6 +8983,7 @@ function normalizePullRequest(on, run, index, path) {
8864
8983
  const replicas = validateReplicas(run, at, path);
8865
8984
  const secrets = validateSecrets(run, at, path);
8866
8985
  const secretsProfile = validateSecretsProfile(run, at, path);
8986
+ const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
8867
8987
  return {
8868
8988
  on: {
8869
8989
  type: "pull_request",
@@ -8878,7 +8998,7 @@ function normalizePullRequest(on, run, index, path) {
8878
8998
  ...number !== void 0 && { number },
8879
8999
  ...once !== void 0 && { once }
8880
9000
  },
8881
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
9001
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
8882
9002
  };
8883
9003
  }
8884
9004
  function validateReviewState(on, actions, run, at, path) {
@@ -8907,6 +9027,7 @@ var init_triggers = __esm({
8907
9027
  init_flow_gate();
8908
9028
  init_forges();
8909
9029
  init_reserved_env();
9030
+ init_wait_for();
8910
9031
  ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request", "issue"]);
8911
9032
  DISARMED_TYPE = "disarmed";
8912
9033
  PR_ACTIONS = {
@@ -10751,6 +10872,98 @@ async function readScopedBudget({ url, limits, redisFn = makeRedisClient, timeou
10751
10872
  }
10752
10873
  }
10753
10874
  }
10875
+ async function readHeldJobs({ url, limit = 20, redisFn = makeRedisClient, timeoutMs = 2500, now = () => Date.now() } = {}) {
10876
+ let redis;
10877
+ try {
10878
+ parseConnection(url, { failFast: true });
10879
+ redis = redisFn(url);
10880
+ const settled = (async () => {
10881
+ const ids = await redis.smembers(HELD_SET);
10882
+ const hydrated = await Promise.all(
10883
+ ids.slice(0, HELD_HYDRATE_MAX).map(async (jobId) => {
10884
+ try {
10885
+ const h = await redis.hgetall(jobKey(jobId));
10886
+ if (!h || !h.since) {
10887
+ await redis.srem(HELD_SET, jobId).catch(() => {
10888
+ });
10889
+ return null;
10890
+ }
10891
+ const since = Number(h.since);
10892
+ return {
10893
+ jobId,
10894
+ target: h.target || null,
10895
+ label: h.label || null,
10896
+ waitedMs: Number.isFinite(since) && since > 0 ? Math.max(0, now() - since) : null,
10897
+ checks: Number(h.checks) || 0
10898
+ };
10899
+ } catch {
10900
+ return null;
10901
+ }
10902
+ })
10903
+ );
10904
+ const rows = hydrated.filter(Boolean);
10905
+ rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
10906
+ return {
10907
+ rows: rows.slice(0, limit),
10908
+ more: Math.max(0, rows.length - limit),
10909
+ // True when the index itself was longer than this reader will hydrate: `more` is then a floor, and
10910
+ // a caller that renders a total must say so rather than state a number it cannot know.
10911
+ truncated: ids.length > HELD_HYDRATE_MAX
10912
+ };
10913
+ })();
10914
+ return await withTimeout(settled, timeoutMs, { unreachable: "timed out reaching the queue" });
10915
+ } catch (err) {
10916
+ return { unreachable: err?.message ?? String(err) };
10917
+ } finally {
10918
+ if (redis) {
10919
+ try {
10920
+ redis.disconnect();
10921
+ } catch {
10922
+ }
10923
+ }
10924
+ }
10925
+ }
10926
+ async function cancelHeldJob({ url, jobId, redisFn = makeRedisClient, queueFn = makeQueue, timeoutMs = 2500 } = {}) {
10927
+ if (typeof jobId !== "string" || jobId === "") return { invalid: "a job id is required" };
10928
+ let redis;
10929
+ let queue;
10930
+ try {
10931
+ parseConnection(url, { failFast: true });
10932
+ redis = redisFn(url);
10933
+ queue = queueFn(parseConnection(url, { failFast: true }));
10934
+ const settled = (async () => {
10935
+ const hash = await redis.hgetall(jobKey(jobId));
10936
+ if (!hash || !hash.since) return { invalid: `job ${jobId} is not waiting on a condition` };
10937
+ const job = await queue.getJob(jobId);
10938
+ if (!job) return { invalid: `job ${jobId} is no longer in the queue` };
10939
+ const state = await job.getState().catch(() => null);
10940
+ if (state !== "delayed" && state !== "waiting" && state !== "prioritized") {
10941
+ return { invalid: `job ${jobId} is ${state ?? "in an unknown state"}, not waiting -- it has already left the hold` };
10942
+ }
10943
+ await redis.del(jobKey(jobId));
10944
+ await redis.srem(HELD_SET, jobId).catch(() => {
10945
+ });
10946
+ if (hash.dedupId) {
10947
+ const holder = await redis.get(`wait:key:${hash.dedupId}`);
10948
+ if (holder === jobId) await redis.del(`wait:key:${hash.dedupId}`);
10949
+ }
10950
+ await job.remove();
10951
+ return { ok: true, jobId };
10952
+ })();
10953
+ return await withTimeout(settled, timeoutMs, { invalid: "timed out reaching the queue" });
10954
+ } catch (err) {
10955
+ return { invalid: err?.message ?? String(err) };
10956
+ } finally {
10957
+ try {
10958
+ redis?.disconnect?.();
10959
+ } catch {
10960
+ }
10961
+ try {
10962
+ await queue?.close?.();
10963
+ } catch {
10964
+ }
10965
+ }
10966
+ }
10754
10967
  function readSubscriptions({ subscriptionsPath, fs = nodeFs3 }) {
10755
10968
  if (subscriptionsPath === null || subscriptionsPath === void 0) return { missing: true };
10756
10969
  let text;
@@ -11382,7 +11595,7 @@ function clampLines(lines) {
11382
11595
  const n = Number.isFinite(lines) ? Math.floor(lines) : 200;
11383
11596
  return Math.min(2e3, Math.max(1, n));
11384
11597
  }
11385
- var DISPATCH_RUN_TTL_SECONDS, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
11598
+ var DISPATCH_RUN_TTL_SECONDS, HELD_HYDRATE_MAX, SCAN_WINDOW_MAX_DAYS, GRAPH_LIMITS, GRAPH_LS_TREE_MAX_BYTES;
11386
11599
  var init_read_model = __esm({
11387
11600
  "src/read-model.mjs"() {
11388
11601
  init_config2();
@@ -11391,6 +11604,7 @@ var init_read_model = __esm({
11391
11604
  init_budget();
11392
11605
  init_pause_windows();
11393
11606
  init_scoped_limits();
11607
+ init_wait_state();
11394
11608
  init_subscriptions();
11395
11609
  init_connection();
11396
11610
  init_queue();
@@ -11403,6 +11617,7 @@ var init_read_model = __esm({
11403
11617
  init_costs();
11404
11618
  init_triggers_file();
11405
11619
  DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
11620
+ HELD_HYDRATE_MAX = 200;
11406
11621
  SCAN_WINDOW_MAX_DAYS = 92;
11407
11622
  GRAPH_LIMITS = Object.freeze({
11408
11623
  maxFoldersScanned: 16,
@@ -39505,6 +39720,25 @@ function renderBudget({ budget, settings: settings2 } = {}) {
39505
39720
  if (pct !== null) lines.push(` soft-hold band: ${pct}%`);
39506
39721
  return lines.join("\n");
39507
39722
  }
39723
+ function renderHeldJobs({ held } = {}) {
39724
+ if (!held) return null;
39725
+ if (held.unreachable) return `unreadable (${held.unreachable})`;
39726
+ const rows = Array.isArray(held.rows) ? held.rows : [];
39727
+ if (rows.length === 0) return null;
39728
+ const more = Number(held.more) || 0;
39729
+ const lines = rows.map((r) => `${r.target ?? r.jobId ?? "-"} ${r.label ?? "-"} waited ${plainDuration(r.waitedMs)}`);
39730
+ if (more > 0) lines.push(`... and ${more} more`);
39731
+ return lines.join("\n");
39732
+ }
39733
+ function plainDuration(ms) {
39734
+ if (!Number.isFinite(ms) || ms < 0) return "?";
39735
+ const s = Math.floor(ms / 1e3);
39736
+ if (s < 60) return `${s}s`;
39737
+ const m = Math.floor(s / 60);
39738
+ if (m < 60) return `${m}m`;
39739
+ const h = Math.floor(m / 60);
39740
+ return h < 24 ? `${h}h${m % 60}m` : `${Math.floor(h / 24)}d${h % 24}h`;
39741
+ }
39508
39742
  function renderScopedLimits({ limits, scopedBudget } = {}) {
39509
39743
  if (limits?.invalid) return `Scoped limits: file invalid (${limits.invalid})`;
39510
39744
  const list = Array.isArray(limits?.limits) ? limits.limits : [];
@@ -39869,6 +40103,34 @@ function centerBlock(lines, overlayWidth, blockWidth) {
39869
40103
  const prefix = " ".repeat(pad2);
39870
40104
  return lines.map((l) => prefix + l);
39871
40105
  }
40106
+ async function heldJobs(redis) {
40107
+ const ids = await redis.smembers("wait:held");
40108
+ const at = Date.now();
40109
+ const hydrated = await Promise.all(
40110
+ ids.slice(0, HELD_HYDRATE_MAX2).map(async (jobId) => {
40111
+ try {
40112
+ const h = await redis.hgetall(`wait:job:${jobId}`);
40113
+ if (!h || !h.since) {
40114
+ await redis.srem("wait:held", jobId).catch(() => {
40115
+ });
40116
+ return null;
40117
+ }
40118
+ const since = Number(h.since);
40119
+ return {
40120
+ jobId,
40121
+ target: h.target || null,
40122
+ label: h.label || null,
40123
+ waitedMs: Number.isFinite(since) && since > 0 ? Math.max(0, at - since) : null
40124
+ };
40125
+ } catch {
40126
+ return null;
40127
+ }
40128
+ })
40129
+ );
40130
+ const rows = hydrated.filter(Boolean);
40131
+ rows.sort((a, b) => (b.waitedMs ?? -1) - (a.waitedMs ?? -1));
40132
+ return { rows: rows.slice(0, HELD_LIMIT), more: Math.max(0, rows.length - HELD_LIMIT), truncated: ids.length > HELD_HYDRATE_MAX2 };
40133
+ }
39872
40134
  function createDashboardDeps(paths) {
39873
40135
  const queue = makeQueue(parseConnection(paths.valkeyUrl, { failFast: true }));
39874
40136
  const redis = makeRedisClient(paths.valkeyUrl);
@@ -39906,7 +40168,9 @@ function createDashboardDeps(paths) {
39906
40168
  })
39907
40169
  )
39908
40170
  };
40171
+ const held = await heldJobs(redis).catch((err) => ({ unreachable: err?.message ?? String(err) }));
39909
40172
  return {
40173
+ held,
39910
40174
  scopedLimits,
39911
40175
  scopedBudget,
39912
40176
  queue: { pausedState, counts, workers },
@@ -40378,6 +40642,8 @@ function renderPanel(snapshot, width, state, styler) {
40378
40642
  ];
40379
40643
  const scopedText = renderScopedLimits({ limits: snapshot.scopedLimits, scopedBudget: snapshot.scopedBudget });
40380
40644
  if (scopedText) sections.splice(2, 0, { title: "SCOPED LIMITS", lines: toLines(scopedText) });
40645
+ const heldText = renderHeldJobs({ held: snapshot.held });
40646
+ if (heldText) sections.push({ title: "HELD", lines: toLines(heldText) });
40381
40647
  const plain = [title];
40382
40648
  for (const section of sections) plain.push(section.title, ...section.lines);
40383
40649
  plain.push(KEY_HINTS);
@@ -40417,6 +40683,7 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40417
40683
  // collapse budget. The key hint lives in this divider, not the footer -- the footer's width
40418
40684
  // arithmetic has no headroom for another hint (its own comment), the s/o/Tab precedent.
40419
40685
  { key: "limits", head: ["scoped limits", `${sl.count} \xB7 m manage`], body: sl.lines, priority: 0, viewKey: "m" },
40686
+ ...heldSection(snapshot.held, inner, styler),
40420
40687
  { key: "runs", head: ["runs", `last ${runCount} \xB7 o ${runSort}`], body: runLines(runRows, selected - trg.count, inner, styler) },
40421
40688
  { key: "settings", head: ["settings", "s edit"], body: settingsLines(snapshot.settings, inner, styler), priority: 2, viewKey: "s" }
40422
40689
  ];
@@ -40432,7 +40699,7 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40432
40699
  sections.forEach((s, i) => {
40433
40700
  if (i > 0) lines.push(RULE);
40434
40701
  if (collapsed.has(s.key)) {
40435
- lines.push(styler.divider(s.head[0], `(${s.body.length} hidden \u2014 ${s.viewKey} to view)`, inner));
40702
+ lines.push(styler.divider(s.head[0], s.viewKey ? `(${s.body.length} hidden \u2014 ${s.viewKey} to view)` : `(${s.body.length} hidden)`, inner));
40436
40703
  return;
40437
40704
  }
40438
40705
  if (s.head) lines.push(styler.divider(s.head[0], s.head[1], inner));
@@ -40440,6 +40707,37 @@ function buildListLines(snapshot, selected, inner, styler, runSort = "time", ava
40440
40707
  });
40441
40708
  return lines;
40442
40709
  }
40710
+ function heldSection(held, inner, styler) {
40711
+ if (!held) return [];
40712
+ if (held.unreachable) {
40713
+ return [{ key: "held", priority: 5, head: ["held", "waiting on conditions"], body: [styler.cell(`unreadable (${held.unreachable})`, inner, { color: "error" })] }];
40714
+ }
40715
+ const rows = Array.isArray(held.rows) ? held.rows : [];
40716
+ if (rows.length === 0) return [];
40717
+ const shown = rows.slice(0, HELD_ON_DASHBOARD);
40718
+ const hidden = rows.length - shown.length + (Number(held.more) || 0);
40719
+ const body = shown.map((r) => heldRow(r, inner, styler));
40720
+ if (hidden > 0) body.push(styler.cell(styler.fg("dim", `\u2193 ${hidden} more`), inner));
40721
+ const total = rows.length + (Number(held.more) || 0);
40722
+ return [{ key: "held", priority: 5, head: ["held", `${total}${held.truncated ? "+" : ""} waiting on conditions`], body }];
40723
+ }
40724
+ function heldRow(r, inner, styler) {
40725
+ const bits = [
40726
+ `${styler.fg("dim", "\u25CB")} ${styler.fg("accent", r.target ?? r.jobId ?? "-")}`,
40727
+ styler.fg("text", r.label ?? "-"),
40728
+ styler.fg("dim", `waited ${fmtDuration(r.waitedMs)}`)
40729
+ ];
40730
+ return fitLine(bits.join(styler.fg("dim", " \xB7 ")), inner, styler);
40731
+ }
40732
+ function fmtDuration(ms) {
40733
+ if (!Number.isFinite(ms) || ms < 0) return "?";
40734
+ const s = Math.floor(ms / 1e3);
40735
+ if (s < 60) return `${s}s`;
40736
+ const m = Math.floor(s / 60);
40737
+ if (m < 60) return `${m}m`;
40738
+ const h = Math.floor(m / 60);
40739
+ return h < 24 ? `${h}h${m % 60}m` : `${Math.floor(h / 24)}d${h % 24}h`;
40740
+ }
40443
40741
  function statusHeader(queue, inner, styler) {
40444
40742
  if (!queue || queue.unreachable) {
40445
40743
  return styler.cell(`queue unreachable (${queue?.unreachable ?? "?"})`, inner, { color: "error" });
@@ -41048,7 +41346,7 @@ function runDetailHints(inner, styler, canOpen = false, canCopy = false, copiedN
41048
41346
  if (copiedNote) return fitLine(styler.fg("success", copiedNote) + styler.fg("dim", " \xB7 ") + line, inner, styler);
41049
41347
  return fitLine(line, inner, styler);
41050
41348
  }
41051
- var KEY_HINTS, RUNS_ON_DASHBOARD, RUNS_VIEWPORT, RUN_SORTS, REFRESH_MS, TAIL_LINES, TAIL_VIEWPORT, MIN_WIDTH2, DRILL_WIDTH, KIND_COLOR, KIND_WIDTH, FRAME_CHROME_ROWS;
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;
41052
41350
  var init_dashboard = __esm({
41053
41351
  "src/dashboard.ts"() {
41054
41352
  init_budget();
@@ -41071,9 +41369,12 @@ var init_dashboard = __esm({
41071
41369
  TAIL_VIEWPORT = 20;
41072
41370
  MIN_WIDTH2 = 8;
41073
41371
  DRILL_WIDTH = 70;
41372
+ HELD_LIMIT = 20;
41373
+ HELD_HYDRATE_MAX2 = 200;
41074
41374
  KIND_COLOR = { cron: "accent", label: "syntaxType", comment: "syntaxKeyword", pull_request: "syntaxFunction", issue: "syntaxString" };
41075
41375
  KIND_WIDTH = 13;
41076
41376
  FRAME_CHROME_ROWS = 4;
41377
+ HELD_ON_DASHBOARD = 3;
41077
41378
  }
41078
41379
  });
41079
41380
 
@@ -41607,8 +41908,8 @@ var init_setup_wizard = __esm({
41607
41908
  init_deployment_pointer();
41608
41909
  init_read_model();
41609
41910
  init_index();
41610
- RUNTIME_VERSION = "1.5.0";
41611
- RECEIVER_VERSION = "1.3.0";
41911
+ RUNTIME_VERSION = "1.6.0";
41912
+ RECEIVER_VERSION = "1.4.0";
41612
41913
  RUNTIME_PKG = "@edgehero/pi-dispatch";
41613
41914
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
41614
41915
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -42106,6 +42407,41 @@ ${JSON.stringify(before)}
42106
42407
  return toolText(JSON.stringify(rows));
42107
42408
  }
42108
42409
  });
42410
+ pi.registerTool({
42411
+ name: "dispatch_waits",
42412
+ label: "pi-dispatch held jobs",
42413
+ description: "Read-only. Lists the jobs the worker is currently HOLDING on a trigger's run.waitFor condition: the job id, an id-only target, the operator's own words for what it is waiting on, and how long it has waited. A held job has reserved no budget slot and started no container. This is a different set from the queue's `delayed` count, which also mixes cron next-occurrences, retry backoff and quiet hours; only waits appear here. Use the job id for dispatch_wait_cancel.",
42414
+ parameters: typebox_exports.Object({}),
42415
+ async execute() {
42416
+ const paths = resolvePaths(process.env);
42417
+ const held = await readHeldJobs({ url: paths.valkeyUrl });
42418
+ return toolText(JSON.stringify(held));
42419
+ }
42420
+ });
42421
+ pi.registerTool({
42422
+ name: "dispatch_wait_cancel",
42423
+ label: "pi-dispatch cancel a held job",
42424
+ description: "Removes a job that is waiting on a run.waitFor condition, so it never runs. The operator MUST approve a confirm dialog naming the job and what it was waiting on; refused with no interactive operator. Use dispatch_waits for the job id. This is the ONLY way to stop a held job short of editing redis: a held job has spent nothing, so no budget cap will ever stop it, and deleting the trigger does not reach a job already enqueued. Cancelling writes no run record, because the job never ran.",
42425
+ executionMode: "sequential",
42426
+ parameters: typebox_exports.Object({ jobId: typebox_exports.String({ minLength: 1 }) }),
42427
+ async execute(_id, params, _signal, _onUpdate, ctx) {
42428
+ const paths = resolvePaths(process.env);
42429
+ const held = await readHeldJobs({ url: paths.valkeyUrl });
42430
+ if (held.unreachable) throw new Error(`queue unreachable: ${held.unreachable}`);
42431
+ const row = (held.rows ?? []).find((r) => r.jobId === params.jobId);
42432
+ if (!row) throw new Error(`no held job ${params.jobId} (dispatch_waits lists the ids that are waiting)`);
42433
+ const result = await confirmedWrite(
42434
+ ctx,
42435
+ { title: `Cancel held job ${row.jobId}`, message: `Cancel ${row.target ?? row.jobId}, waiting on ${row.label ?? "a condition"}. It will never run.` },
42436
+ async () => {
42437
+ const res = await cancelHeldJob({ url: paths.valkeyUrl, jobId: params.jobId });
42438
+ if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
42439
+ return { applied: true, jobId: params.jobId };
42440
+ }
42441
+ );
42442
+ return toolText(JSON.stringify(result));
42443
+ }
42444
+ });
42109
42445
  pi.registerTool({
42110
42446
  name: "dispatch_limit_add",
42111
42447
  label: "pi-dispatch add scoped limit",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "1.5.0",
3
+ "version": "1.6.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",
@@ -41,6 +41,12 @@ dialog before it takes effect**:
41
41
  (per folder/repo "quiet hours": runs for a scope are deferred between certain times and auto-resume after;
42
42
  `dispatch_pauses` lists them with their index). `dispatch_pause_edit` is a partial change — pass the index
43
43
  plus only the fields to alter. Deferring never drops a job and costs no budget.
44
+ - `dispatch_waits` — list the jobs the worker is HOLDING on a trigger's `run.waitFor` condition: job id,
45
+ an id-only target, the operator's own words for what it waits on, and how long it has waited. Different
46
+ from the queue's `delayed` count, which also mixes cron next-occurrences, retry backoff and quiet hours.
47
+ - `dispatch_wait_cancel` — remove a held job so it never runs (confirm-gated). It is the only lever that
48
+ reaches one: a held job has spent nothing, so no budget cap will ever refuse it, and deleting the trigger
49
+ does not reach a job already enqueued.
44
50
  - `dispatch_limit_add` / `dispatch_limit_edit` / `dispatch_limit_delete` — manage scoped limits (per
45
51
  repo/folder budget caps and concurrency: `day`/`week`/`month` job-count caps refuse a job pre-spend with
46
52
  reason `scope-cap`, never retried; `concurrent` defers the excess, never drops it; `dispatch_limits`
@@ -242,6 +248,28 @@ If you are asked why a GitLab trigger did not fire, the usual answers in order:
242
248
  an event *added*, so editing an already-labelled issue does nothing. This is deliberate.
243
249
  3. **The action word belongs to the other forge.** See above.
244
250
 
251
+ ## Held jobs — what `waitFor` looks like from here
252
+
253
+ A trigger may carry `run.waitFor`, and a job whose conditions have not cleared is **held**: deferred, with
254
+ no budget slot reserved, no kill timer armed, no retry attempt consumed, and no run record written. It runs
255
+ exactly once when every condition clears.
256
+
257
+ What that means when you are asked about a job that "has not run":
258
+
259
+ - **Look at `dispatch_waits` before the queue counts.** A held job is not `waiting` and not `active`. It is
260
+ in the delayed set, which also holds every cron trigger's next occurrence, so the panel's `delayed` number
261
+ is not an answer to "what is stuck".
262
+ - **A held job has cost nothing yet.** Do not reason about it against the spend caps: they count container
263
+ starts, and this job has started none. The bounds that apply to it are its own (`PI_WAIT_MAX_MS`,
264
+ `PI_WAIT_MAX_CHECKS`), and when one is reached the job terminates with a run record naming the reason.
265
+ - **The terminal reasons say what to do.** `wait-refused` means the operator's check reported the condition
266
+ will never clear. `wait-unanswerable` means the check could not answer repeatedly, which usually means the
267
+ script is broken rather than the condition slow. `wait-expired` means a bound was reached.
268
+ `wait-skew` / `wait-unreadable` mean two services in the deployment disagree about the field and one needs
269
+ upgrading or restarting.
270
+ - **Cancelling is an operator decision.** `dispatch_wait_cancel` needs a confirm, writes no run record (the
271
+ job never ran), and cannot be undone: the delivery is gone, and a webhook does not resend itself.
272
+
245
273
  ## Scoped limits — and the folder mutex you cannot turn off
246
274
 
247
275
  `scoped-limits.json` holds per-scope bounds beside the deployment-global ones: `day`/`week`/`month` cap