@edgehero/pi-dispatch-admin 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.mjs +345 -9
- package/package.json +1 -1
- package/skills/operate-pi-dispatch/SKILL.md +28 -0
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.
|
|
41611
|
-
RECEIVER_VERSION = "1.
|
|
41911
|
+
RUNTIME_VERSION = "1.6.1";
|
|
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.
|
|
3
|
+
"version": "1.6.1",
|
|
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
|