@edgehero/pi-dispatch 4.3.0 → 5.0.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/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/cancel-cli.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* held on run.waitFor -> the shared removeHeldJob sequence (hold keys first, then the job); no record
|
|
9
9
|
* (the dispatch_wait_cancel rule).
|
|
10
10
|
* delayed / queued -> job.remove(); no record, same rule.
|
|
11
|
+
* Both through `removeQueuedJob` (cancel-state.mjs, issue #737), the sequence the
|
|
12
|
+
* console's cancel-queued shares, which also deletes the removed job's defer record.
|
|
11
13
|
* active -> a `cancel:req` key + a brief ack poll (cancel-state.mjs), because the abort can
|
|
12
14
|
* only be raised by the process that holds the job. No ack within the window is a
|
|
13
15
|
* NAMED failure, never a silent one: the job may be on a host that is down, or on
|
|
@@ -22,17 +24,29 @@
|
|
|
22
24
|
* job's attempt count.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
|
-
import { cancelReqKey, ranBefore,
|
|
26
|
-
import { jobKey } from "./wait-state.mjs";
|
|
27
|
+
import { cancelReqKey, ranBefore, removeQueuedJob, requestCancel } from "./cancel-state.mjs";
|
|
27
28
|
|
|
28
29
|
/** Same budget the kill switch gives the host registry before acting on what the keyspace alone says. */
|
|
29
30
|
const FLEET_READ_TIMEOUT_MS = 2_000;
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A sentence another module wrote (BullMQ's, a client's, Valkey's own: of any length, holding any bytes), as a line of
|
|
34
|
+
* this command carries it to stderr, which is a terminal: every control character a terminal acts on (C0, DEL and C1:
|
|
35
|
+
* an ESC sequence, a CSI with no ESC, a bell, a carriage return that overwrites the line) made a space, then cut at 200
|
|
36
|
+
* characters. The console carries such text by the same substitution and the same cut (`boundedText` in the admin's
|
|
37
|
+
* job-actions.mjs, over its `scrubControls`), which a test holds this to on the class a terminal executes. The
|
|
38
|
+
* console's class is wider (it also substitutes what draws as nothing, or as a blank that is not a space, for a
|
|
39
|
+
* reader), and it is asked of the admin's own width table, which the worker does not import.
|
|
40
|
+
*/
|
|
41
|
+
export function boundedText(text) {
|
|
42
|
+
return String(text ?? "no reason given").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200);
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
/**
|
|
32
46
|
* Run the cancel. Returns the process exit code. Every collaborator is a seam with the production default,
|
|
33
47
|
* so tests drive states and races without a queue; `write` is the stdout seam cli.mjs already injects.
|
|
34
48
|
*/
|
|
35
|
-
export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn } = {}) {
|
|
49
|
+
export async function runCancel(jobId, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, ackTimeoutMs = 10_000, pollMs = 250, sleep, refusalFn, touchStore, touchOutbox = null, now = Date.now } = {}) {
|
|
36
50
|
const fail = (message) => {
|
|
37
51
|
errWrite(`error: ${message}\n`);
|
|
38
52
|
return 1;
|
|
@@ -98,45 +112,44 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
if (!job) {
|
|
101
|
-
return fail(`no job ${jobId} in ${names.length} queue(s)${blind ? ` [registry unreadable: ${blind} — a host that is not registered may still hold it]` : ""}`);
|
|
115
|
+
return fail(`no job ${jobId} in ${names.length} queue(s)${blind ? ` [registry unreadable: ${boundedText(blind)} — a host that is not registered may still hold it]` : ""}`);
|
|
102
116
|
}
|
|
103
117
|
|
|
104
|
-
// Held FIRST
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return fail(`could not cancel held job ${jobId}: ${res.invalid}`);
|
|
118
|
+
// Held FIRST, then a job waiting to start: the shared sequence (`removeQueuedJob`, cancel-state.mjs), which the
|
|
119
|
+
// console's cancel-queued runs too. It judges the hold by the `wait:job` hash's `since`, the clock that makes it a
|
|
120
|
+
// real hold rather than a counter key, and deletes the removed job's defer record. This verb's reach is its own:
|
|
121
|
+
// it removes a job waiting to retry as well, which the console refuses.
|
|
122
|
+
const queued = await removeQueuedJob({ redis: probe, queue: owner, job, jobId, touchStore, touchOutbox, now });
|
|
123
|
+
if (queued.ok && queued.touchWarning) errWrite("warning: the job was removed; touch tracking is incomplete. Do not repeat the cancellation.\n");
|
|
124
|
+
if (queued.ok && queued.held) {
|
|
125
|
+
// A hold consumes no attempt (`skipAttempt`), so a held job that ran is a retry held again by its conditions.
|
|
126
|
+
write(`cancelled ${jobId}: it was held on its wait condition, and ${ranBefore(queued)}\n`);
|
|
127
|
+
return 0;
|
|
115
128
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// operator whose job is now exactly the case the active path exists for.
|
|
127
|
-
state = await job.getState().catch(() => "unknown");
|
|
128
|
-
// The re-read state and the throw's own words together, labelled apart: the error came from
|
|
129
|
-
// the remove attempt, the state from after it, and gluing them unlabelled read as one
|
|
130
|
-
// mismatched diagnosis (review finding).
|
|
131
|
-
if (state !== "active") return fail(`could not remove ${jobId} — it is now ${state}; the remove failed with: ${error.message}`);
|
|
132
|
-
}
|
|
129
|
+
// A removal that failed after the hold's keys were deleted changed something: the job lost its hold and was not removed,
|
|
130
|
+
// so the line says so and how to finish, never only the error (which reads as "nothing happened"). Every error a
|
|
131
|
+
// line carries is bounded (`boundedText`): it is another module's words, of any length and with any bytes.
|
|
132
|
+
if (queued.invalid !== undefined && queued.cleared === true) {
|
|
133
|
+
return fail(`could not cancel held job ${jobId}: its hold was cleared, but the job itself could not be removed (${boundedText(queued.invalid)}); it may still be queued, now without its hold. Run \`pi-dispatch cancel ${jobId}\` again to remove or stop it`);
|
|
134
|
+
}
|
|
135
|
+
if (queued.invalid !== undefined) return fail(`could not cancel held job ${jobId}: ${boundedText(queued.invalid)}`);
|
|
136
|
+
if (queued.ok) {
|
|
137
|
+
write(`removed ${jobId} (${queued.state}): ${ranBefore(job)}\n`);
|
|
138
|
+
return 0;
|
|
133
139
|
}
|
|
140
|
+
const state = queued.state;
|
|
141
|
+
// The one race worth handling by name: picked up between getState and remove. remove() throws on a locked job, so
|
|
142
|
+
// the state is re-read and an active job falls through to the active path rather than failing an operator whose
|
|
143
|
+
// job is now exactly the case the active path exists for. Otherwise the re-read state and the throw's own words
|
|
144
|
+
// together, labelled apart: the error came from the remove attempt, the state from after it, and gluing them
|
|
145
|
+
// unlabelled read as one mismatched diagnosis.
|
|
146
|
+
if (queued.raced && state !== "active") return fail(`could not remove ${jobId} — it is now ${state}; the remove failed with: ${boundedText(queued.error.message)}`);
|
|
134
147
|
|
|
135
148
|
if (state === "active") {
|
|
136
149
|
requested = true;
|
|
137
150
|
const res = await requestCancel({ redis: probe, jobId, ackTimeoutMs, pollMs, ...(sleep ? { sleep } : {}) });
|
|
138
151
|
if (res.ack !== undefined) {
|
|
139
|
-
write(`cancel accepted by ${res.ack === "" ? "the worker" : res.ack} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
|
|
152
|
+
write(`cancel accepted by ${res.ack === "" ? "the worker" : boundedText(res.ack)} — stopping the container (allow ~30s); the run record will say operator-cancel\n`);
|
|
140
153
|
return 0;
|
|
141
154
|
}
|
|
142
155
|
// Gate round 2 of PR #479: the worker stops its cancel poll before it holds or retries a job, so a request that
|
|
@@ -165,7 +178,7 @@ export async function runCancel(jobId, url, { write = (chunk) => process.stdout.
|
|
|
165
178
|
]);
|
|
166
179
|
clearTimeout(bound);
|
|
167
180
|
}
|
|
168
|
-
return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${error
|
|
181
|
+
return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${boundedText(error?.message)}`);
|
|
169
182
|
} finally {
|
|
170
183
|
probe.disconnect?.();
|
|
171
184
|
for (const q of queues) await q.close().catch(() => {});
|
package/src/cancel-state.mjs
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
import { makeTouchStore, touchScopeOf } from "./touch-state.mjs";
|
|
2
|
+
import { trackTouchAction } from "./touch-outbox.mjs";
|
|
3
|
+
import { isProjectId } from "./project-id.mjs";
|
|
4
|
+
|
|
5
|
+
const isWorkload = (job) => job.data?.kind !== "question-grouping" && !["zero-touch-grouping", "answer-draft"].includes(job.data?.purpose);
|
|
6
|
+
|
|
7
|
+
function stopTouch(job, queue, jobId, now) {
|
|
8
|
+
const data = job.data ?? {};
|
|
9
|
+
const owner = /^pi-jobs@([A-Za-z0-9._-]{1,64})$/.exec(queue?.name ?? "")?.[1] ?? null;
|
|
10
|
+
// A shared-queue local job has no owning machine yet. Hashing an empty host manufactures a different
|
|
11
|
+
// folder identity from its eventual run, so leave that attribution unknown rather than claiming a mismatch.
|
|
12
|
+
const knownScope = /^[a-f0-9]{64}$/.test(data.touchScopeId ?? "") || !(data.kind === "local" || data.folder) || owner !== null;
|
|
13
|
+
return { type: "stop", runId: jobId, at: now(), ...(knownScope ? { scopeId: touchScopeOf(data, owner) } : {}), ...(isProjectId(data.projectId) ? { project: data.projectId } : {}), nonce: `stop:${jobId}` };
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* The `cancel:` keyspace: how an operator's cancel reaches the worker that owns a running job (issue #287).
|
|
3
17
|
*
|
|
@@ -24,8 +38,19 @@
|
|
|
24
38
|
* silently loses an operator's edit. This is a transient, attended, TTL-bounded one-shot whose loss is
|
|
25
39
|
* REPORTED: the requester is watching the ack poll, and a request that reaches nobody comes back as a
|
|
26
40
|
* named timeout, never as silence.
|
|
41
|
+
*
|
|
42
|
+
* BESIDE IT, THE `defer:` KEYSPACE (issue #737), which this module deletes from and never writes:
|
|
43
|
+
*
|
|
44
|
+
* defer:job:<jobId> HASH, why the worker put a waiting job back on the delayed set
|
|
45
|
+
* (`defer-reasons.mjs` holds the fields and their rules, `defer-write.mjs` writes
|
|
46
|
+
* it, with its own TTL). `removeQueuedJob` deletes it once the job is removed, so a
|
|
47
|
+
* removed job leaves no reason behind. The TTL is the backstop for a delete that
|
|
48
|
+
* fails, and a record without its job is never shown: every reader starts from
|
|
49
|
+
* the queue's own sets.
|
|
27
50
|
*/
|
|
28
51
|
|
|
52
|
+
import { deferKey } from "./defer-reasons.mjs";
|
|
53
|
+
import { dropKey, dropModelAttempt, modelAttemptIdOf } from "./model-attempts.mjs";
|
|
29
54
|
import { HELD_SET, jobKey, leaseKey } from "./wait-state.mjs";
|
|
30
55
|
|
|
31
56
|
/** How long an unanswered request may sit before redis reaps it (the requester deletes it sooner). */
|
|
@@ -36,6 +61,21 @@ export const CANCEL_ACK_TTL_MS = 5 * 60_000;
|
|
|
36
61
|
|
|
37
62
|
export const cancelReqKey = (jobId) => `cancel:req:${jobId}`;
|
|
38
63
|
export const cancelAckKey = (jobId) => `cancel:ack:${jobId}`;
|
|
64
|
+
// Separate keys make an older worker fail closed: it cannot mistake an agent request for a human's.
|
|
65
|
+
export const agentCancelReqKey = (jobId) => `cancel:agent:req:${jobId}`;
|
|
66
|
+
export const agentCancelAckKey = (jobId) => `cancel:agent:ack:${jobId}`;
|
|
67
|
+
|
|
68
|
+
const cancelRequestKeyOf = (jobId, interventionOrigin) => (interventionOrigin === "agent" ? agentCancelReqKey(jobId) : cancelReqKey(jobId));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Takes back a request whose answer was lost, for a caller that goes on to report a failure (the console's fleet reader; the
|
|
72
|
+
* CLI's catch does the same under its own bound). A SET with no answer can still land: a Valkey that stopped answering holds
|
|
73
|
+
* it on its socket and runs it when it wakes, so the DEL goes right behind it, in order. Not awaited: on the same outage it
|
|
74
|
+
* has its own bound to wait out, and the caller already has its answer.
|
|
75
|
+
*/
|
|
76
|
+
export function withdrawCancel({ redis, jobId, interventionOrigin = "human" }) {
|
|
77
|
+
Promise.resolve().then(() => redis.del(cancelRequestKeyOf(jobId, interventionOrigin))).catch(() => {});
|
|
78
|
+
}
|
|
39
79
|
|
|
40
80
|
/**
|
|
41
81
|
* Ask whichever worker holds `jobId` to cancel it, and wait briefly for the answer.
|
|
@@ -48,17 +88,32 @@ export const cancelAckKey = (jobId) => `cancel:ack:${jobId}`;
|
|
|
48
88
|
*
|
|
49
89
|
* `pollMs`/`sleep` are injectable so tests never wait on a wall clock.
|
|
50
90
|
*/
|
|
51
|
-
export async function requestCancel({ redis, jobId, ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
52
|
-
|
|
91
|
+
export async function requestCancel({ redis, jobId, interventionOrigin = "human", ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
92
|
+
const requestKey = cancelRequestKeyOf(jobId, interventionOrigin);
|
|
93
|
+
const ackKey = interventionOrigin === "agent" ? agentCancelAckKey(jobId) : cancelAckKey(jobId);
|
|
94
|
+
await redis.set(requestKey, interventionOrigin === "agent" ? "agent-cancel" : "operator-cancel", "PX", CANCEL_REQ_TTL_MS);
|
|
53
95
|
for (let waited = 0; ; waited += pollMs) {
|
|
54
|
-
|
|
96
|
+
let host;
|
|
97
|
+
try {
|
|
98
|
+
host = await redis.get(ackKey);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// The request went out and its answer is what was lost, so no caller may say nothing was changed.
|
|
101
|
+
if (err && typeof err === "object") err.valkeyMayStillRun = true;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
55
104
|
if (host !== null && host !== undefined) return { ack: host };
|
|
56
105
|
if (waited + pollMs > ackTimeoutMs) break;
|
|
57
106
|
await sleep(pollMs);
|
|
58
107
|
}
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
108
|
+
// Losing this DEL leaves the request standing for its 60s TTL, where a worker can still read it, so that is no "nothing was
|
|
109
|
+
// changed" and it rejects, marked, as a lost answer does. The worker-side GET racing the DEL by one tick is a named residual
|
|
110
|
+
// (the operator is told to re-check `status`).
|
|
111
|
+
try {
|
|
112
|
+
await redis.del(requestKey);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err && typeof err === "object") err.valkeyMayStillRun = true;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
62
117
|
return { timeout: true };
|
|
63
118
|
}
|
|
64
119
|
|
|
@@ -80,18 +135,42 @@ export function ranBefore(job) {
|
|
|
80
135
|
: `it made ${made} attempts before and was waiting to retry; whatever those attempts recorded stays, and cancel writes none`;
|
|
81
136
|
}
|
|
82
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Whether the hold is still there, read back after its delete failed: its CLOCK (`since`), the field that makes the hash
|
|
140
|
+
* a hold, since the worker's own counters can re-create the key without it. False when it cannot be read.
|
|
141
|
+
*/
|
|
142
|
+
async function holdInPlace(redis, jobId) {
|
|
143
|
+
if (typeof redis?.hexists !== "function") return false;
|
|
144
|
+
try {
|
|
145
|
+
return (await redis.hexists(jobKey(jobId), "since")) === 1;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
83
151
|
/**
|
|
84
152
|
* Remove a job that is held on a `run.waitFor` condition: hold keys first, then the job (issue #230's
|
|
85
153
|
* sequence, issue #287's shared home). This is the inner body of the admin's `cancelHeldJob`, moved here
|
|
86
154
|
* so the CLI's held path and the panel's are ONE sequence rather than two copies that can drift -- the
|
|
87
155
|
* read-model's own rule is that key derivations are imported from the worker, never re-implemented.
|
|
88
156
|
*
|
|
89
|
-
* Returns `{ ok: true, jobId, attemptsMade }
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
157
|
+
* Returns `{ ok: true, jobId, attemptsMade }`, `{ invalid: <reason> }` (refused, or failed before anything was
|
|
158
|
+
* deleted: nothing was changed), or `{ invalid: <reason>, cleared: true }` when it failed once the hold's first key had
|
|
159
|
+
* been sent for deletion (most often `remove` refusing a job a worker locked in between): the hold is gone, or partly
|
|
160
|
+
* gone, while the job's own removal failed, which a caller must never word as "nothing was changed". A first delete
|
|
161
|
+
* that failed answers `{ invalid }` only when the hold is read back in place afterwards (Valkey refused the command:
|
|
162
|
+
* NOPERM, READONLY, MISCONF); a hold not read back, or a read that fails, keeps the mark, since a refusal can answer a
|
|
163
|
+
* resend of a delete that already ran. Never throws.
|
|
164
|
+
* Writes no run record of its own (INT-RUN-HISTORY-FILE-CONTRACT records terminal states of runs, and a removed hold
|
|
165
|
+
* is none); `attemptsMade` is what the job had done before, for the caller's sentence (`ranBefore`, issue #477): a job
|
|
166
|
+
* held again on a retry has made attempts, and whatever they recorded stays. Once the job is removed, its model attempt
|
|
167
|
+
* key goes too (`dropModelAttempt`, issue #812: bounded by `timeoutMs`, best-effort), since no retry is left to read it.
|
|
168
|
+
* `dropDefer` (removeQueuedJob's) deletes the job's defer record beside it, both sent before either is waited for, so the
|
|
169
|
+
* two cost a caller one bound, as they do for a waiting job, rather than two in a row.
|
|
93
170
|
*/
|
|
94
|
-
export async function removeHeldJob({ redis, queue, jobId }) {
|
|
171
|
+
export async function removeHeldJob({ redis, queue, jobId, timeoutMs = 2_000, dropDefer = false, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
|
|
172
|
+
// Set as the first hold key's delete is sent, before it is answered: a delete lost in transit may still have landed.
|
|
173
|
+
let cleared = false;
|
|
95
174
|
try {
|
|
96
175
|
const hash = await redis.hgetall(jobKey(jobId));
|
|
97
176
|
// A hold is a hash with a CLOCK, not merely a non-empty hash: the worker's own counters create this
|
|
@@ -110,16 +189,135 @@ export async function removeHeldJob({ redis, queue, jobId }) {
|
|
|
110
189
|
// The hold goes FIRST. If `remove` throws (an active job is locked) or a caller's timeout fires
|
|
111
190
|
// mid-sequence, an orphaned hash would keep a row on the panel for a job that no longer exists; an
|
|
112
191
|
// orphaned JOB is merely a job that still runs, which is the state the operator was already in.
|
|
113
|
-
|
|
192
|
+
cleared = true;
|
|
193
|
+
try {
|
|
194
|
+
await redis.del(jobKey(jobId));
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// A failed DEL may still have run: ioredis resends an unanswered command after a reconnect, and the refusal
|
|
197
|
+
// (an ACL without DEL, a read-only replica, a failed save) can be the answer to that resend. So the hold is
|
|
198
|
+
// read back, and only a hold still in place makes "nothing was changed" true; a read that fails keeps the mark.
|
|
199
|
+
if (await holdInPlace(redis, jobId)) cleared = false;
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
114
202
|
await redis.srem(HELD_SET, jobId).catch(() => {});
|
|
115
203
|
if (hash.dedupId) {
|
|
116
204
|
const holder = await redis.get(leaseKey(hash.dedupId));
|
|
117
205
|
if (holder === jobId) await redis.del(leaseKey(hash.dedupId));
|
|
118
206
|
}
|
|
119
|
-
|
|
207
|
+
const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
|
|
208
|
+
const removal = interventionOrigin === "agent" ? (await job.remove(), { ok: true }) : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: async () => { await job.remove(); return { ok: true }; }, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
|
|
209
|
+
await Promise.all([dropDefer ? dropDeferRecord(redis, jobId, timeoutMs) : null, dropAttemptOf(redis, queue, jobId, timeoutMs)]);
|
|
120
210
|
// Issue #477: what the job had done, for the caller's sentence (`ranBefore`); read off the job just removed.
|
|
121
|
-
return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0 };
|
|
211
|
+
return { ok: true, jobId, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
|
|
122
212
|
} catch (err) {
|
|
123
|
-
|
|
213
|
+
const invalid = err?.message ?? String(err);
|
|
214
|
+
return cleared ? { invalid, cleared: true } : { invalid };
|
|
124
215
|
}
|
|
125
216
|
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The states a job that has not started waits in, by the names `job.getState()` answers with: what `removeQueuedJob`
|
|
220
|
+
* removes. `paused` is the CLI's own list since issue #287; BullMQ 5.80.4 reads a job in a paused queue's list as
|
|
221
|
+
* `waiting`, so on it that job is removed under that word.
|
|
222
|
+
*/
|
|
223
|
+
export const QUEUED_STATES = Object.freeze(["delayed", "waiting", "prioritized", "paused"]);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Remove a job that waits to start (issue #737): the ONE sequence `pi-dispatch cancel` and the console's cancel-queued
|
|
227
|
+
* run, so the two cannot drift. Who may be removed is the caller's rule, not this function's: the CLI removes a job
|
|
228
|
+
* waiting to retry too, and the console refuses one that has ever started. `job` is the job the caller found on `queue`.
|
|
229
|
+
*
|
|
230
|
+
* held on run.waitFor (its `wait:job` hash has `since`) -> `removeHeldJob`, hold keys first, then the job;
|
|
231
|
+
* delayed, waiting, prioritized or paused (`QUEUED_STATES`) -> `job.remove()`;
|
|
232
|
+
* any other state -> nothing is changed.
|
|
233
|
+
*
|
|
234
|
+
* A HOLD IS JUDGED FIRST: a held job IS delayed, and the plain path would strand its `wait:*` keys as a panel row for a
|
|
235
|
+
* job that no longer exists. A hash that cannot be read is not a hold, as the CLI has always read it. `state` is the
|
|
236
|
+
* state the caller already read, or null to read it here.
|
|
237
|
+
*
|
|
238
|
+
* `guard` (optional; the CLI passes none) is a caller's last check, asked with `job` right before the removal is sent:
|
|
239
|
+
* before `removeHeldJob` for a held job, and after the state check, before `remove()`, otherwise. It answers null to go
|
|
240
|
+
* on, or what to refuse with; a guard that throws refuses too. BullMQ's remove checks only the job's lock and its
|
|
241
|
+
* scheduler, so a caller whose rule is about what the job has DONE (the console's never-started rule) reads that again
|
|
242
|
+
* here, one round trip before the remove (for a held job, before `removeHeldJob`'s own reads and deletes), rather than
|
|
243
|
+
* at its own earlier check.
|
|
244
|
+
*
|
|
245
|
+
* Once the job is removed, its defer record (`defer:job:<jobId>`) is deleted too: bounded by `timeoutMs`, best-effort,
|
|
246
|
+
* and only after a removal that succeeded, so a job that stays keeps its reason. Its model attempt key goes the same way
|
|
247
|
+
* (issue #812), beside the record, so the two deletes share one bound: here for a waiting job, inside `removeHeldJob`
|
|
248
|
+
* (`dropDefer`) for a held one. Returns, and never throws:
|
|
249
|
+
* `{ ok: true, held: true, jobId, attemptsMade }` a held job, removed;
|
|
250
|
+
* `{ invalid }` `removeHeldJob`'s own refusal of a held job, nothing changed;
|
|
251
|
+
* `{ invalid, cleared: true }` `removeHeldJob` failed after its hold's keys were deleted: the
|
|
252
|
+
* hold is gone and the job's own removal failed (never "nothing
|
|
253
|
+
* was changed");
|
|
254
|
+
* `{ refused, error? }` the guard's answer (`refused`), or its throw (`refused` null,
|
|
255
|
+
* `error` the throw); nothing was sent;
|
|
256
|
+
* `{ notQueued: true, state }` not waiting to start (`active`, `completed`, `unknown`...);
|
|
257
|
+
* `{ ok: true, held: false, jobId, state, attemptsMade }` a waiting job, removed;
|
|
258
|
+
* `{ raced: true, state, error }` `remove()` threw (most often: a worker took the job between
|
|
259
|
+
* the read and the remove, or it is a job scheduler's next
|
|
260
|
+
* run, which BullMQ refuses to remove); `state` is read again.
|
|
261
|
+
* `attemptsMade` is the job's own count for the caller's sentence (`ranBefore`), 0 when it is not a positive whole number.
|
|
262
|
+
*/
|
|
263
|
+
export async function removeQueuedJob({ redis, queue, job, jobId, state = null, timeoutMs = 2_000, guard = null, touchStore = undefined, touchOutbox = null, now = Date.now, interventionOrigin = "human" }) {
|
|
264
|
+
const hash = await Promise.resolve()
|
|
265
|
+
.then(() => redis.hgetall(jobKey(jobId)))
|
|
266
|
+
.catch(() => null);
|
|
267
|
+
if (hash?.since) {
|
|
268
|
+
const stop = await guarded(guard, job);
|
|
269
|
+
if (stop !== null) return stop;
|
|
270
|
+
const res = await removeHeldJob({ redis, queue, jobId, timeoutMs, dropDefer: true, touchStore, touchOutbox, now, interventionOrigin });
|
|
271
|
+
if (!res.ok) return res.cleared === true ? { invalid: res.invalid, cleared: true } : { invalid: res.invalid };
|
|
272
|
+
return { ok: true, held: true, jobId: res.jobId, attemptsMade: res.attemptsMade, ...(res.touchWarning ? { touchWarning: res.touchWarning } : {}) };
|
|
273
|
+
}
|
|
274
|
+
if (!job) return { notQueued: true, state: "unknown" };
|
|
275
|
+
const current = typeof state === "string" ? state : await stateOf(job);
|
|
276
|
+
if (!QUEUED_STATES.includes(current)) return { notQueued: true, state: current };
|
|
277
|
+
const stop = await guarded(guard, job);
|
|
278
|
+
if (stop !== null) return stop;
|
|
279
|
+
let removal;
|
|
280
|
+
try {
|
|
281
|
+
const store = touchStore === undefined ? (typeof redis?.eval === "function" ? makeTouchStore({ redis, now }) : null) : touchStore;
|
|
282
|
+
removal = interventionOrigin === "agent" ? (await job.remove(), { ok: true }) : await trackTouchAction({ store, outbox: touchOutbox, event: stopTouch(job, queue, jobId, now), act: async () => { await job.remove(); return { ok: true }; }, succeeded: () => !(job.attemptsMade > 0) && isWorkload(job) });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return { raced: true, state: await stateOf(job), error };
|
|
285
|
+
}
|
|
286
|
+
await Promise.all([dropDeferRecord(redis, jobId, timeoutMs), dropAttemptOf(redis, queue, jobId, timeoutMs)]);
|
|
287
|
+
return { ok: true, held: false, jobId, state: current, attemptsMade: Number.isInteger(job.attemptsMade) && job.attemptsMade > 0 ? job.attemptsMade : 0, ...(removal.touchWarning ? { touchWarning: removal.touchWarning } : {}) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `removeQueuedJob`'s guard asked: null to go on (no guard, or a null answer), else `{ refused }`, or `{ refused: null, error }` for a throw. */
|
|
291
|
+
async function guarded(guard, job) {
|
|
292
|
+
if (typeof guard !== "function") return null;
|
|
293
|
+
try {
|
|
294
|
+
const answer = await guard(job);
|
|
295
|
+
return answer === null || answer === undefined ? null : { refused: answer };
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return { refused: null, error };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A job's state, "unknown" where it cannot be read, as the CLI has always read it. */
|
|
302
|
+
function stateOf(job) {
|
|
303
|
+
return Promise.resolve()
|
|
304
|
+
.then(() => job.getState())
|
|
305
|
+
.catch(() => "unknown");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Delete a removed job's model attempt key (issue #812), under the id the processor wrote it with: the job's queue's name
|
|
310
|
+
* and its id. A queue handle without a name (BullMQ's always has one) names no key, so nothing is sent.
|
|
311
|
+
*/
|
|
312
|
+
async function dropAttemptOf(redis, queue, jobId, timeoutMs) {
|
|
313
|
+
if (typeof queue?.name !== "string" || queue.name === "") return;
|
|
314
|
+
await dropModelAttempt(redis, modelAttemptIdOf(queue.name, jobId), timeoutMs);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Delete a removed job's defer record, bounded and best-effort (model-attempts.mjs `dropKey`, the one such delete): the
|
|
319
|
+
* record's own TTL is the backstop for a delete that fails, and a record whose job is gone is never read.
|
|
320
|
+
*/
|
|
321
|
+
function dropDeferRecord(redis, jobId, timeoutMs) {
|
|
322
|
+
return dropKey(redis, deferKey(jobId), timeoutMs);
|
|
323
|
+
}
|
package/src/capabilities.mjs
CHANGED
|
@@ -96,6 +96,41 @@ export function parseCaps(raw) {
|
|
|
96
96
|
return out;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The implicit profile, `DEFAULT_SECRETS_PROFILE` in `secrets.mjs`, copied rather than imported for the
|
|
101
|
+
* reason the charset above is: that module spawns resolvers and this one is loaded by the receiver. A test
|
|
102
|
+
* holds the two equal.
|
|
103
|
+
*/
|
|
104
|
+
export const IMPLICIT_SECRET_PROFILE = "default";
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The profile a job's secrets resolve through, or undefined when it asked for none. `secretsArmed`'s rule
|
|
108
|
+
* (`secrets.mjs`), spelled again here for the same reason as the constant: an explicit profile, or a
|
|
109
|
+
* non-empty `secrets` map, which then resolves through the implicit `default` (issue #704). Before #704 only
|
|
110
|
+
* the explicit field was a need, so a trigger that never wrote `run.secretsProfile` was never routed and took
|
|
111
|
+
* the coin flip this module exists to remove.
|
|
112
|
+
*
|
|
113
|
+
* UPGRADE THE WORKERS BEFORE THE RECEIVER (and the console, whose re-run routes by this rule too). The router
|
|
114
|
+
* only ever picks a host that ADVERTISES every need, so the new need never sends a job to a host that cannot
|
|
115
|
+
* serve it. But an older worker advertises `s:default` only when its ENV declares it: one whose `default`
|
|
116
|
+
* lives in the overlay serves it without saying so. For a job with only this need that costs nothing (no
|
|
117
|
+
* advertiser is abstention 3, the shared queue it used before). For a job with a SECOND need it can regress:
|
|
118
|
+
* `run.secrets` without a profile plus a `w:ci` wait, an older host A declaring `w:ci` and serving `default`
|
|
119
|
+
* from its overlay, host B declaring neither. Before #704 the job needed `[w:ci]` and was routed to A; under
|
|
120
|
+
* a new receiver it needs `[s:default, w:ci]`, no host advertises both, and the shared queue lets B pop it
|
|
121
|
+
* and refuse. Once every worker runs this release, A advertises its overlay `default` and the job is routed
|
|
122
|
+
* to A again. An older receiver computes no such need and routes as before.
|
|
123
|
+
*/
|
|
124
|
+
// Exported (issue #712) for the run record's `secretsProfile` (run-history.mjs), which must name the profile this rule
|
|
125
|
+
// routes by and `resolveSecrets` resolves through, and must not import secrets.mjs, which spawns.
|
|
126
|
+
export function secretProfileNeeded(job) {
|
|
127
|
+
const named = job?.secretsProfile;
|
|
128
|
+
if (typeof named === "string" && named !== "") return named;
|
|
129
|
+
const secrets = job?.secrets;
|
|
130
|
+
if (secrets !== null && typeof secrets === "object" && Object.keys(secrets).length > 0) return IMPLICIT_SECRET_PROFILE;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
/**
|
|
100
135
|
* What a JOB needs, from the same two fields the worker's own refusals read.
|
|
101
136
|
*
|
|
@@ -105,7 +140,7 @@ export function parseCaps(raw) {
|
|
|
105
140
|
*/
|
|
106
141
|
export function jobNeeds(job) {
|
|
107
142
|
const needs = new Set();
|
|
108
|
-
const secret = token(CAP_SECRET, job
|
|
143
|
+
const secret = token(CAP_SECRET, secretProfileNeeded(job));
|
|
109
144
|
if (secret) needs.add(secret);
|
|
110
145
|
if (Array.isArray(job?.waitFor)) {
|
|
111
146
|
for (const condition of job.waitFor) {
|
package/src/capacity-cli.mjs
CHANGED
|
@@ -16,11 +16,15 @@ import { parseArgs } from "node:util";
|
|
|
16
16
|
import { CAPACITY_WINDOWS, computeCapacity } from "./capacity.mjs";
|
|
17
17
|
import { readCapacityRecords } from "./capacity-records.mjs";
|
|
18
18
|
import { formatCpus, formatMemory } from "./job-size.mjs";
|
|
19
|
+
import { RUN_MIRROR_MAX_RUNS_MAX, RUN_MIRROR_MAX_RUNS_MIN, runMirrorMaxRunsOf } from "./retention.mjs";
|
|
19
20
|
|
|
20
21
|
/** How long the CLI waits on the host registry, the kill switch's budget. */
|
|
21
22
|
const FLEET_READ_TIMEOUT_MS = 2_000;
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The deployment keys this command reads beside the Valkey URL. `PI_RUN_MIRROR_MAX_RUNS` (issue #761) is the run index's
|
|
25
|
+
* cap, which tells an index cut by count from one that is only large (`readMirrorWindow`).
|
|
26
|
+
*/
|
|
27
|
+
export const CAPACITY_ENV_KEYS = Object.freeze(["PI_LOGS_DIR", "PI_LOG_RETENTION_DAYS", "PI_WORKER_NAME", "PI_RUN_MIRROR_MAX_RUNS"]);
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
30
|
* Run the command. Returns the exit code. Every collaborator is a seam with the production default.
|
|
@@ -45,7 +49,7 @@ export async function runCapacity(args, { env = process.env, write = (chunk) =>
|
|
|
45
49
|
if (deployment.problem) return fail(deployment.problem);
|
|
46
50
|
const facts = await deploymentFacts(deployment.env);
|
|
47
51
|
if (facts.problem) return fail(facts.problem);
|
|
48
|
-
const { logsDir, retentionDays, localHost } = facts;
|
|
52
|
+
const { logsDir, retentionDays, localHost, runMirrorMaxRuns } = facts;
|
|
49
53
|
|
|
50
54
|
const { killSwitchValkeyUrls, makeRedisClient, urlShown } = await import("./connection.mjs");
|
|
51
55
|
const picked = (pickUrls ?? killSwitchValkeyUrls)({ env, flagUrl: parsed.values["valkey-url"] ?? null });
|
|
@@ -75,7 +79,7 @@ export async function runCapacity(args, { env = process.env, write = (chunk) =>
|
|
|
75
79
|
// report's clock: a row's age decides how far its running jobs count (`LIVE_FRESH_MS`).
|
|
76
80
|
const [fleet, read] = await Promise.all([
|
|
77
81
|
redis ? (readLiveHostsFn ?? readLiveHosts)(redis, { now, timeoutMs: FLEET_READ_TIMEOUT_MS, prune: false }).catch((error) => ({ unreachable: error?.message ?? "registry unreadable" })) : { unreachable: null },
|
|
78
|
-
readCapacityRecords({ redis, logsDir, sinceMs: windowStartMs, nowMs, retentionDays, localHost, noMirrorReason: refusedNote, timeoutMs: FLEET_READ_TIMEOUT_MS, ...(fs ? { fs } : {}) }),
|
|
82
|
+
readCapacityRecords({ redis, logsDir, sinceMs: windowStartMs, nowMs, retentionDays, localHost, indexMax: runMirrorMaxRuns, noMirrorReason: refusedNote, timeoutMs: FLEET_READ_TIMEOUT_MS, ...(fs ? { fs } : {}) }),
|
|
79
83
|
]);
|
|
80
84
|
if (named && (fleet.unreachable || read.mirrorState.startsWith("unreachable"))) return fail(`could not read Valkey at ${urlShown(url)}: ${fleet.unreachable ? `host registry ${fleet.unreachable}` : `run mirror ${read.mirrorState}`}`);
|
|
81
85
|
const coverage = { ...read.coverage, reason: [read.coverage.reason, fleet.unreachable ? `host registry unreadable (${fleet.unreachable})` : null].filter(Boolean).join("; ") || null };
|
|
@@ -100,20 +104,23 @@ export function onlyHost(report, host) {
|
|
|
100
104
|
if (!known.includes(host)) return { unknown: known };
|
|
101
105
|
const shown = report.hosts.find((h) => h.name === host);
|
|
102
106
|
const { fromMs, source: _source, truncated, ...counts } = shown.coverage;
|
|
103
|
-
return { report: { ...report, hosts: [shown], coverage: { ...report.coverage, fromMs, truncated, ...counts, historyNotShared: report.coverage.historyNotShared.filter((n) => n === host) } } };
|
|
107
|
+
return { report: { ...report, fleet: undefined, hosts: [shown], coverage: { ...report.coverage, fromMs, truncated, ...counts, historyNotShared: report.coverage.historyNotShared.filter((n) => n === host) } } };
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
/**
|
|
107
111
|
* What this command reads of the deployment: the logs directory (config.mjs `logsDirPath`, the worker's rule), the
|
|
108
|
-
* retention (30 days unless set; 0 keeps the files)
|
|
109
|
-
*
|
|
112
|
+
* retention (30 days unless set; 0 keeps the files), this host's name (`PI_WORKER_NAME`, else the hostname) and the run
|
|
113
|
+
* index's cap (`runMirrorMaxRuns`, `PI_RUN_MIRROR_MAX_RUNS`, 5,000 unless set; issue #761), or `{ problem }` for a
|
|
114
|
+
* retention or a cap the worker would refuse to boot with.
|
|
110
115
|
*/
|
|
111
116
|
export async function deploymentFacts(env) {
|
|
112
117
|
const raw = env.PI_LOG_RETENTION_DAYS;
|
|
113
118
|
if (raw !== undefined && raw !== "" && !/^\d{1,6}$/.test(raw)) return { problem: `PI_LOG_RETENTION_DAYS must be a non-negative integer (got ${JSON.stringify(raw)})` };
|
|
119
|
+
const cap = runMirrorMaxRunsOf(env.PI_RUN_MIRROR_MAX_RUNS);
|
|
120
|
+
if (cap.problem) return { problem: `PI_RUN_MIRROR_MAX_RUNS must be an integer ${RUN_MIRROR_MAX_RUNS_MIN}-${RUN_MIRROR_MAX_RUNS_MAX} (got ${JSON.stringify(env.PI_RUN_MIRROR_MAX_RUNS)})` };
|
|
114
121
|
const { logsDirPath, defaultWorkerName, WORKER_NAME_RE } = await import("./config.mjs");
|
|
115
122
|
const named = env.PI_WORKER_NAME;
|
|
116
|
-
return { logsDir: logsDirPath(env), retentionDays: raw === undefined || raw === "" ? 30 : Number(raw), localHost: typeof named === "string" && WORKER_NAME_RE.test(named) ? named : defaultWorkerName() };
|
|
123
|
+
return { logsDir: logsDirPath(env), retentionDays: raw === undefined || raw === "" ? 30 : Number(raw), localHost: typeof named === "string" && WORKER_NAME_RE.test(named) ? named : defaultWorkerName(), runMirrorMaxRuns: cap.value };
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
/** A span in the largest whole units that say it: `40s`, `6m`, `2h 5m`, `3d 4h`. */
|
|
@@ -164,6 +171,7 @@ const CONTROL = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g;
|
|
|
164
171
|
/** The human report: one block per host, then what the fleet's history covers. Control characters stripped. */
|
|
165
172
|
export function capacityText(report, { since }) {
|
|
166
173
|
const lines = [];
|
|
174
|
+
if (report.fleet) lines.push(...fleetFacts(report.fleet));
|
|
167
175
|
for (const h of report.hosts) lines.push(...hostLines(h, since, report.coverage));
|
|
168
176
|
if (report.hosts.length === 0) lines.push(`${capitalized(noRunText(report.coverage, `in the last ${since}`))}.`);
|
|
169
177
|
lines.push(...coverageLines(report.coverage));
|
|
@@ -373,3 +381,15 @@ export function coverageLines(cov) {
|
|
|
373
381
|
|
|
374
382
|
/** The fleet's history cut by the run mirror (`coverage.truncated`), as the CLI's coverage line says it. */
|
|
375
383
|
export const TRUNCATED_NOTE = "history truncated: the run mirror holds nothing older for at least one host, so its earlier time is counted as neither busy nor idle";
|
|
384
|
+
|
|
385
|
+
/** Shared fleet headline wording for the CLI and the Spend capacity view. */
|
|
386
|
+
export function fleetFacts(fleet) {
|
|
387
|
+
const pct = (value) => value === null ? "unknown" : percentText(value);
|
|
388
|
+
const wait = (value) => value === null ? "no run recorded one" : durationText(value);
|
|
389
|
+
return [
|
|
390
|
+
`Fleet busy share of slot time: ${pct(fleet.busyPerMille)}${fleet.incomplete ? " (coverage incomplete)" : ""}`,
|
|
391
|
+
`Fleet peak: ${fleet.peak} jobs at once${fleet.peakAtMs === null ? "" : ` at ${new Date(fleet.peakAtMs).toISOString()}`}`,
|
|
392
|
+
`Fleet wait for a slot: p95 ${wait(fleet.waits.p95Ms)}, median ${wait(fleet.waits.p50Ms)}`,
|
|
393
|
+
`Fleet promised memory at the busiest hour: ${pct(fleet.promisedMemPerMille)}`,
|
|
394
|
+
];
|
|
395
|
+
}
|