@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/queue.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { makeApprovalStore } from "./approval-state.mjs";
|
|
2
|
+
import { validateFork } from "./fork-job.mjs";
|
|
1
3
|
import { randomUUID } from "node:crypto";
|
|
2
4
|
import { Queue } from "bullmq";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
+
import { metaKeyPattern, queueNameOfMetaKey } from "./bullmq-internals.mjs";
|
|
6
|
+
import { assertJudgedConnection, givesUp, onValkeyError } from "./connection.mjs";
|
|
7
|
+
import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualCanaryJobId, manualTriggerJobId } from "./job-id.mjs";
|
|
5
8
|
import { targetSeparator } from "./forges.mjs";
|
|
6
9
|
import { PR_CLOSE_ACTIONS } from "./triggers.mjs";
|
|
10
|
+
import { consoleAnswerBy, consoleDraftOf } from "./console-answer.mjs";
|
|
11
|
+
import { JOB_DATA_VERSION_FIELD, withJobDataVersion } from "./job-data-version.mjs";
|
|
12
|
+
import { FORGE_JOB_RETENTION_S, LOCAL_JOB_KEPT_COMPLETED_S, LOCAL_JOB_KEPT_FAILED_S } from "./retention.mjs";
|
|
7
13
|
|
|
8
14
|
// The close words in every forge's spelling, derived from the one table (never re-typed here): a
|
|
9
15
|
// matched PR action in this set marks a close job for the semantic-key discriminant below.
|
|
@@ -11,6 +17,48 @@ const PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
|
|
|
11
17
|
|
|
12
18
|
export const QUEUE = "pi-jobs";
|
|
13
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A builder's job data stamped with the version it needs (issue #761, `DES-STORE-VERSION-WHEN-NEEDED`). `dataVersion`
|
|
22
|
+
* is the version the builder's INPUT arrived with: a job a newer release stored that this build re-enqueues (a held
|
|
23
|
+
* reply, a late message's fallback, a rerun), so the stamp never falls below it. Throws when that version is one this
|
|
24
|
+
* build cannot read, above its own or not a whole number of 1 or more (`withJobDataVersion`'s RangeError): such input
|
|
25
|
+
* cannot be rebuilt here without dropping what this build does not know, and the error carries `code: "newer-release"`,
|
|
26
|
+
* so a caller that falls back to a reply logs why. Nothing is stamped while the data needs 1, so every job of today's
|
|
27
|
+
* fields is stored byte for byte as before. `dataVersions` (`{ table, valueTable, supported }`) is the version rule,
|
|
28
|
+
* injectable for tests.
|
|
29
|
+
*/
|
|
30
|
+
function stampJobData(data, dataVersion, dataVersions = {}) {
|
|
31
|
+
try {
|
|
32
|
+
return withJobDataVersion(dataVersion === undefined ? data : { ...data, [JOB_DATA_VERSION_FIELD]: dataVersion }, dataVersions);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error instanceof RangeError && error.code === undefined) error.code = "newer-release";
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Deterministic source/run binding survives a lost enqueue acknowledgement. No question or draft is queued. */
|
|
40
|
+
export async function enqueueAnswerDraftJob(queue, { runId, ruleId, draftRunId }) {
|
|
41
|
+
if (!/^[A-Za-z0-9._:-]{1,200}$/.test(runId ?? "") || !/^[a-z][a-z0-9_-]{0,63}$/.test(ruleId ?? "") || !/^answer-draft-[a-f0-9]{40}$/.test(draftRunId ?? "")) throw new Error("invalid answer draft reference");
|
|
42
|
+
await queue.add("answer-draft", { kind: "answer-draft", purpose: "answer-draft", answerDraft: { runId, ruleId } }, { jobId: draftRunId, attempts: 2, backoff: { type: "exponential", delay: 5000 }, removeOnComplete: { age: FORGE_JOB_RETENTION_S, count: 5000 }, removeOnFail: { age: FORGE_JOB_RETENTION_S, count: 5000 } });
|
|
43
|
+
return draftRunId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A private evidence reference, never collector-supplied execution authority. */
|
|
47
|
+
export async function enqueueWatchJob(queue, fields, { now = Date.now, phase = "dispatch" } = {}) {
|
|
48
|
+
if (!/^[a-f0-9]{64}$/.test(fields.id) || !/^[A-Za-z0-9_-]{1,128}$/.test(fields.ruleId) || !["dispatch", "action"].includes(phase)) throw new Error("invalid watch queue reference");
|
|
49
|
+
const id = `watch-${phase}-${fields.id}`;
|
|
50
|
+
await queue.add("watch", { kind: "watch", watch: { findingId: fields.id, ruleId: fields.ruleId, phase } }, { jobId: id, delay: Math.max(0, (fields.dueAt ?? now()) - now()), attempts: 2, backoff: { type: "exponential", delay: 5000 }, removeOnComplete: { age: 7 * 86400, count: 10000 }, removeOnFail: { age: 7 * 86400, count: 10000 } });
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A confirmed grouping request has no private text and one paid attempt. Collection failures never buy another. */
|
|
55
|
+
export async function enqueueQuestionGroupJob(queue, { runId, digest, configDigest }, { nonce = randomUUID } = {}) {
|
|
56
|
+
if (!/^[A-Za-z0-9._:-]{1,200}$/.test(runId ?? "") || !/^[a-f0-9]{64}$/.test(digest ?? "") || !/^[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("invalid question grouping reference");
|
|
57
|
+
const jobId = `question-group-${nonce()}`;
|
|
58
|
+
await queue.add("question-grouping", { kind: "question-grouping", questionGrouping: { runId, digest, configDigest } }, { jobId, attempts: 1, removeOnComplete: { age: FORGE_JOB_RETENTION_S, count: 5000 }, removeOnFail: { age: FORGE_JOB_RETENTION_S, count: 5000 } });
|
|
59
|
+
return jobId;
|
|
60
|
+
}
|
|
61
|
+
|
|
14
62
|
/**
|
|
15
63
|
* The queue a HOST-AFFINE job goes to (issue #57): work only one machine can do, because the folder, the
|
|
16
64
|
* secret resolver or the wait-check script lives there.
|
|
@@ -63,7 +111,40 @@ export function fleetQueueNames(hosts) {
|
|
|
63
111
|
/** The name charset, duplicated from `config.mjs` deliberately: this module imports nothing. */
|
|
64
112
|
const WORKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
65
113
|
|
|
66
|
-
export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualTriggerJobId };
|
|
114
|
+
export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId, manualCanaryJobId, manualTriggerJobId };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* BullMQ's `jobScheduler` and `repeat` getters are `new Promise(async (resolve) => { ... await this.client ... })` (read
|
|
118
|
+
* at 5.80.4). A connection that gave up rejects `this.client` inside that async executor, where no caller can reach it:
|
|
119
|
+
* the outer promise never settles and the rejection is unhandled, which ends the process. One `getJobSchedulers` on a
|
|
120
|
+
* refused or absent Valkey took the whole console down, from behind every `.catch` and timeout its readers carry.
|
|
121
|
+
*
|
|
122
|
+
* So the connection is awaited FIRST, out here, where a rejection is the caller's. Past that `this.client` is settled and
|
|
123
|
+
* resolved, so the getter's own await cannot reject. Guarding each call site instead was the other way, and one of them
|
|
124
|
+
* already did (`await q.client` in trigger-schedule-zones): the next caller would have to know, and `makeQueue` is the one
|
|
125
|
+
* place every queue of this project is built.
|
|
126
|
+
*
|
|
127
|
+
* The constructor is the same class of fault at another address. `RedisConnection.close` removes every listener, and a
|
|
128
|
+
* connect still in flight then fails into `emit("error")`, which with no listener throws out of a promise chain nothing can
|
|
129
|
+
* reach, so a read that closed its queue at its own bound ended the process a moment after it answered. A queue that was
|
|
130
|
+
* closed has nobody left to tell, so that one emit says nothing.
|
|
131
|
+
*/
|
|
132
|
+
class SettledQueue extends Queue {
|
|
133
|
+
constructor(name, options) {
|
|
134
|
+
super(name, options);
|
|
135
|
+
const { connection } = this;
|
|
136
|
+
const emit = connection.emit.bind(connection);
|
|
137
|
+
connection.emit = (event, ...args) => (event === "error" && connection.listenerCount("error") === 0 ? false : emit(event, ...args));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get jobScheduler() {
|
|
141
|
+
return this.client.then(() => super.jobScheduler);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get repeat() {
|
|
145
|
+
return this.client.then(() => super.repeat);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
67
148
|
|
|
68
149
|
/**
|
|
69
150
|
* A queue handle. `name` defaults to the shared queue, so every existing caller is unchanged and a
|
|
@@ -72,9 +153,10 @@ export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeli
|
|
|
72
153
|
export function makeQueue(connection, { name = QUEUE } = {}) {
|
|
73
154
|
// Issue #464: only a connection `parseConnection` built, which judges and pins the Valkey it dials.
|
|
74
155
|
assertJudgedConnection(connection);
|
|
75
|
-
const queue = new
|
|
156
|
+
const queue = new SettledQueue(name, { connection });
|
|
76
157
|
// Issue #468: an error of this queue is one line, its message, never BullMQ's console.error of the whole object.
|
|
77
|
-
|
|
158
|
+
// A queue that gives up is the kind a long-lived reader replaces, so it is the kind that repeats the line per read.
|
|
159
|
+
onValkeyError(queue, `queue ${name}`, undefined, { quietly: givesUp(connection) });
|
|
78
160
|
return queue;
|
|
79
161
|
}
|
|
80
162
|
|
|
@@ -85,8 +167,8 @@ export function makeQueue(connection, { name = QUEUE } = {}) {
|
|
|
85
167
|
* removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
|
|
86
168
|
* redelivered, so a modest window is enough.
|
|
87
169
|
*/
|
|
88
|
-
export async function enqueueLocalJob(queue, fields) {
|
|
89
|
-
return (await addLocalJob(queue, fields)).id;
|
|
170
|
+
export async function enqueueLocalJob(queue, fields, { dataVersions } = {}) {
|
|
171
|
+
return (await addLocalJob(queue, fields, { dataVersions })).id;
|
|
90
172
|
}
|
|
91
173
|
|
|
92
174
|
/**
|
|
@@ -119,8 +201,8 @@ export async function enqueueLocalJob(queue, fields) {
|
|
|
119
201
|
* done rather than inventing one. A read that fails outright returns `existing: undefined`: whether this
|
|
120
202
|
* call queued anything is then unknown, and the caller says so instead of guessing either way.
|
|
121
203
|
*/
|
|
122
|
-
export async function enqueueLocalJobReporting(queue, fields) {
|
|
123
|
-
return addReporting(queue, (enqueueNonce) => addLocalJob(queue, { ...fields, enqueueNonce }));
|
|
204
|
+
export async function enqueueLocalJobReporting(queue, fields, { dataVersions } = {}) {
|
|
205
|
+
return addReporting(queue, (enqueueNonce) => addLocalJob(queue, { ...fields, enqueueNonce }, { dataVersions }));
|
|
124
206
|
}
|
|
125
207
|
|
|
126
208
|
/**
|
|
@@ -135,8 +217,7 @@ export async function enqueueLocalJobReporting(queue, fields) {
|
|
|
135
217
|
* job is not retried, as a scheduled tick is not (a scheduler job carries no `attempts`). The id is
|
|
136
218
|
* `manualTriggerJobId`'s, so `localEventContext` reports `source: "cron"` with `scheduledFor: null`.
|
|
137
219
|
*/
|
|
138
|
-
export async function enqueueTriggerRunReporting(queue, schedule, { now = new Date() } = {}) {
|
|
139
|
-
const jobId = manualTriggerJobId({ triggerId: schedule.schedulerId, now });
|
|
220
|
+
export async function enqueueTriggerRunReporting(queue, schedule, { now = new Date(), jobId = manualTriggerJobId({ triggerId: schedule.schedulerId, now }) } = {}) {
|
|
140
221
|
return addReporting(queue, async (enqueueNonce) => {
|
|
141
222
|
await queue.add(schedule.name, { ...schedule.data, enqueueNonce }, { ...schedule.opts, jobId });
|
|
142
223
|
return { id: jobId };
|
|
@@ -176,7 +257,7 @@ export function swallowedRunSentence(jobId, existing) {
|
|
|
176
257
|
return `an identical run was queued${hhmm} as ${jobId} (${existing?.state ?? "already queued or done"}); nothing new was queued.`;
|
|
177
258
|
}
|
|
178
259
|
|
|
179
|
-
async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, maxCostUsd, image, backend, excludeTools, mcp, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, enqueueNonce, now = new Date() }) {
|
|
260
|
+
async function addLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, models, fallbackModels, maxCostUsd, packages, image, backend, excludeTools, excludeExtensions, excludeExtensionTools, mcp, skillsDir, secrets, secretsProfile, recover, steer, chainDepth, parentJobId, jobId, enqueueNonce, dataVersion, now = new Date() }, { dataVersions } = {}) {
|
|
180
261
|
const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
|
|
181
262
|
// A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
|
|
182
263
|
// minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
|
|
@@ -185,11 +266,13 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
|
|
|
185
266
|
// vanish silently -- and the `cmd:` prefix keeps a command named X from colliding with a flow named X
|
|
186
267
|
// (`:` is outside the skill-name charset, so no real flow can spell the prefixed form). A flow job's
|
|
187
268
|
// key is byte-identical to before the feature.
|
|
188
|
-
const id = jobId ?? localJobId({ folder, flow: command !== undefined ? `cmd:${command}` : flow, task, minute, provider, model, models });
|
|
269
|
+
const id = jobId ?? localJobId({ folder, flow: command !== undefined ? `cmd:${command}` : flow, task, minute, provider, model, models, fallbackModels });
|
|
189
270
|
// image/chainDepth/parentJobId land on `data` only when present, so a plain non-chained job's data is
|
|
190
271
|
// byte-identical. `image` is the container image this job runs in (INT-TRIGGERS-FILE-CONTRACT); absent
|
|
191
272
|
// resolves the deployment default at job start, never a value frozen here.
|
|
192
|
-
|
|
273
|
+
// Stamped with the version its fields need (issue #761, `stampJobData`): nothing while that is 1. Before the add, so
|
|
274
|
+
// input from a newer release throws here and nothing is queued.
|
|
275
|
+
const data = stampJobData({
|
|
193
276
|
kind: "local",
|
|
194
277
|
folder,
|
|
195
278
|
flow,
|
|
@@ -204,8 +287,12 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
|
|
|
204
287
|
// Issue #502: the allowed-model list, conditional like `image`, so a job whose trigger named none keeps its
|
|
205
288
|
// data byte-identical. An outbox child carries its parent's (outbox.mjs), never one its request file names.
|
|
206
289
|
...(models !== undefined && { models }),
|
|
290
|
+
...(fallbackModels !== undefined && { fallbackModels }),
|
|
207
291
|
// Issue #501: the per-job dollar cap, passed through for the outbox child that inherits its parent's.
|
|
208
292
|
...(maxCostUsd !== undefined && { maxCostUsd }),
|
|
293
|
+
// Issue #800: the outbox child's `"packages": false`, off its parent's data. Not in this function's list before, so the collector could not have
|
|
294
|
+
// passed it. A key every worker already reads, so it asks for no `dataVersion`. Conditional like `image`, so no other job's data moves.
|
|
295
|
+
...(packages !== undefined && { packages }),
|
|
209
296
|
...(image !== undefined && { image }),
|
|
210
297
|
// #227. WHERE this job's container is built. Conditional like `image`, so a trigger that named no
|
|
211
298
|
// venue produces byte-identical job data -- and at JOB level, never inside `trigger`, for the reason
|
|
@@ -217,6 +304,10 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
|
|
|
217
304
|
// same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
|
|
218
305
|
// worker's business, never agent-visible input to reason about.
|
|
219
306
|
...(excludeTools !== undefined && { excludeTools }),
|
|
307
|
+
// Issue #800. Each its own conditional, and at JOB level, excludeTools' two reasons: a dropped list runs the job with what the
|
|
308
|
+
// file takes away, and what the session may not load is the worker's business, never agent-visible input.
|
|
309
|
+
...(excludeExtensions !== undefined && { excludeExtensions }),
|
|
310
|
+
...(excludeExtensionTools !== undefined && { excludeExtensionTools }),
|
|
220
311
|
...(mcp !== undefined && { mcp }),
|
|
221
312
|
// The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
|
|
222
313
|
// Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
|
|
@@ -228,25 +319,65 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
|
|
|
228
319
|
// this differs from `replicas` -- that one is refused on a local job and this one is not.
|
|
229
320
|
...(secrets !== undefined && { secrets }),
|
|
230
321
|
...(secretsProfile !== undefined && { secretsProfile }),
|
|
322
|
+
// Issue #645: durable runs, enqueueForgeJob's rule (only when armed, job level, never part of the id). No local
|
|
323
|
+
// producer passes it today, and no trigger can: the loader refuses run.recover on cron. It is accepted here so a
|
|
324
|
+
// local producer that gains it later cannot quietly move the minute-windowed dedup key by doing so.
|
|
325
|
+
...(recover === true && { recover: true }),
|
|
326
|
+
// Issue #646: steering a running job, the same rule (only when armed, job level, never part of the id). Unlike
|
|
327
|
+
// recover a local producer can pass it: the loader allows run.steer on cron, and an operator's local run may ask.
|
|
328
|
+
...(steer === true && { steer: true }),
|
|
231
329
|
...(chainDepth !== undefined && { chainDepth }),
|
|
232
330
|
...(parentJobId !== undefined && { parentJobId }),
|
|
233
331
|
// Issue #524: `enqueueLocalJobReporting`'s own mark, so it can tell whether ITS add is the one stored.
|
|
234
332
|
...(enqueueNonce !== undefined && { enqueueNonce }),
|
|
235
|
-
};
|
|
333
|
+
}, dataVersion, dataVersions);
|
|
236
334
|
await queue.add("local", data, {
|
|
237
335
|
jobId: id,
|
|
238
336
|
attempts: 2,
|
|
239
337
|
backoff: { type: "exponential", delay: 60_000 },
|
|
240
|
-
removeOnComplete: { age:
|
|
241
|
-
removeOnFail: { age:
|
|
338
|
+
removeOnComplete: { age: LOCAL_JOB_KEPT_COMPLETED_S },
|
|
339
|
+
removeOnFail: { age: LOCAL_JOB_KEPT_FAILED_S },
|
|
242
340
|
});
|
|
243
341
|
return { id };
|
|
244
342
|
}
|
|
245
343
|
|
|
246
|
-
// Coalesces rapid re-label spam; the GUID jobId +
|
|
247
|
-
// window only needs to absorb burst re-labels, not the full redelivery window.
|
|
344
|
+
// Coalesces rapid re-label spam; the GUID jobId + its retention (`FORGE_JOB_RETENTION_S`) handle exact redelivery, so
|
|
345
|
+
// this window only needs to absorb burst re-labels, not the full redelivery window. A Forgejo replay is the exception:
|
|
346
|
+
// it carries a new delivery id, so this window is the only layer that coalesces one, and only inside it.
|
|
248
347
|
const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
|
|
249
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Close the semantic window of a run that PARKED (REQ-AWAIT-INPUT, DES-AWAIT-INPUT-ENDS-THE-JOB). Never throws.
|
|
351
|
+
*
|
|
352
|
+
* The window exists to coalesce rapid duplicate deliveries into one ACTIVE job. A ttl-mode key outlives the job it
|
|
353
|
+
* was set for (bullmq 5.80.4, `removeDeduplicationKeyIfNeededOnFinalization.lua`: a finished job deletes its key only
|
|
354
|
+
* when it has no ttl), so without this a person's answer that lands within ten minutes of the delivery that STARTED
|
|
355
|
+
* the parked run is swallowed against that finished run: the receiver answers `deduplicated`, nothing runs, and the
|
|
356
|
+
* marker stays. Once a run has parked, the next delivery on its key is a deliberate answer, never a duplicate.
|
|
357
|
+
*
|
|
358
|
+
* It asks the BullMQ job itself, rather than rebuilding the key from the job data: `job.deduplicationId` is the id
|
|
359
|
+
* the add stored with the job (`deid` in its hash), so there is no second spelling of the key format to drift from
|
|
360
|
+
* `enqueueForgeJob`'s, and it covers every suffix (the `closed:`/`cmd:` slot, a replica's `:r<n>`) by construction.
|
|
361
|
+
* And `job.removeDeduplicationKey()` is a compare-and-delete (`removeDeduplicationKey-1.lua`: DEL only when the key
|
|
362
|
+
* still holds THIS job's id), where `queue.removeDeduplicationKey(id)` is a bare DEL that could free a key a later
|
|
363
|
+
* job now holds.
|
|
364
|
+
*
|
|
365
|
+
* Fails open with a named line: a failure only leaves today's behaviour (the answer waits out the window).
|
|
366
|
+
* `released` is false, logged as a release and not as a failure, when the key is already gone (the window lapsed
|
|
367
|
+
* during a long run) or now names another job.
|
|
368
|
+
*/
|
|
369
|
+
export async function releaseSemanticWindow(job, log = () => {}) {
|
|
370
|
+
if (!job?.deduplicationId || typeof job.removeDeduplicationKey !== "function") return false;
|
|
371
|
+
try {
|
|
372
|
+
const released = (await job.removeDeduplicationKey()) === true;
|
|
373
|
+
log("parked_window_released", { jobId: job.id ?? null, released });
|
|
374
|
+
return released;
|
|
375
|
+
} catch (error) {
|
|
376
|
+
log("parked_window_release_failed", { jobId: job.id ?? null, code: typeof error?.code === "string" ? error.code : "error" });
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
250
381
|
/**
|
|
251
382
|
* Enqueue a GitHub-triggered job. Returns `{ jobId, deduplicated, survivingJobId? }` -- the computed
|
|
252
383
|
* per-delivery id, plus whether the SEMANTIC window swallowed this delivery (issue #289; the GUID
|
|
@@ -311,14 +442,100 @@ export async function enqueueGitLabJob(queue, fields) {
|
|
|
311
442
|
* window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
|
|
312
443
|
* has always been.
|
|
313
444
|
*/
|
|
314
|
-
export async function enqueueForgeJob(queue, kind,
|
|
445
|
+
export async function enqueueForgeJob(queue, kind, fields, { dataVersions } = {}) {
|
|
446
|
+
const { jobId, data: built, dedupId } = forgeJobOf(kind, fields, { dataVersions });
|
|
447
|
+
let data = built;
|
|
448
|
+
let routedName = null;
|
|
449
|
+
// Bind the delivery to the request visible at ingress: a delayed old reply must never decide a later park.
|
|
450
|
+
// A reply held before publication has no binding yet; its post-park enqueue reads again here.
|
|
451
|
+
if ((data.gates?.length > 0 && data.trigger?.comment) || data.approvalRequestId !== undefined) {
|
|
452
|
+
const found = await makeApprovalStore({ redis: await queue.client }).find(data);
|
|
453
|
+
if (!found.ok) throw new Error("approval-state-unavailable");
|
|
454
|
+
if (data.approvalRequestId !== undefined) {
|
|
455
|
+
if (found.request?.requestId !== data.approvalRequestId) throw new TypeError("approval-request-stale");
|
|
456
|
+
} else if (found.request?.state === "pending") {
|
|
457
|
+
data.approvalRequestId = found.request.requestId;
|
|
458
|
+
}
|
|
459
|
+
if (data.approvalRequestId !== undefined && found.request?.hostQueue) routedName = found.request.hostQueue;
|
|
460
|
+
// Issue #761: the binding above adds a field to built data, so the stamp is asked again for what is stored.
|
|
461
|
+
data = stampJobData(data, undefined, dataVersions);
|
|
462
|
+
}
|
|
463
|
+
// Only the host owning the retained session can consume this decision. Central routing also covers console
|
|
464
|
+
// answers and held replies, whose callers may have selected the shared queue before a request existed.
|
|
465
|
+
const routed = routedName !== null && routedName !== queue.name ? makeQueue(queue.opts.connection, { name: routedName }) : null;
|
|
466
|
+
let added;
|
|
467
|
+
try {
|
|
468
|
+
added = await (routed ?? queue).add(kind, data, {
|
|
469
|
+
jobId,
|
|
470
|
+
deduplication: { id: dedupId, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
|
|
471
|
+
...forgeJobOpts(),
|
|
472
|
+
});
|
|
473
|
+
} finally {
|
|
474
|
+
if (routed) await routed.close();
|
|
475
|
+
}
|
|
476
|
+
// Issue #289: WHICH of the two dedup layers spoke is readable off `queue.add`'s return, and only one
|
|
477
|
+
// of them is. Verified at bullmq 5.80.4's own Lua: a jobId collision (the GUID shield,
|
|
478
|
+
// REQ-DEDUP-BY-DELIVERY-GUID) returns THE SAME id -- structurally invisible here, and correctly so,
|
|
479
|
+
// because a resend of a delivery that IS queued (a hand redelivery, or Azure's own resend) deserves
|
|
480
|
+
// the answer "queued" -- while the `deduplication` option (the 10-minute semantic window) returns the
|
|
481
|
+
// EXISTING job's DIFFERENT id.
|
|
482
|
+
// So `added.id !== jobId` means this delivery was swallowed by the window and created nothing, which
|
|
483
|
+
// the receiver used to log as `enqueued` and answer as success. A defensive undefined (a fake queue
|
|
484
|
+
// predating the comparison) reads as not-deduplicated, today's behaviour exactly.
|
|
485
|
+
if (added?.id && added.id !== jobId) return { jobId, deduplicated: true, survivingJobId: added.id, ...(routed ? { queue: routedName } : {}) };
|
|
486
|
+
return { jobId, deduplicated: false, ...(routed ? { queue: routedName } : {}) };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// A forge job's retry policy and retention, one table for both of its enqueue paths (the window-deduplicated one above
|
|
490
|
+
// and the late-message resume below). The retention is in SECONDS (`FORGE_JOB_RETENTION_S`, the delivery dedup window
|
|
491
|
+
// of REQ-DEDUP-BY-DELIVERY-GUID, retention.mjs); the semantic window's ttl is in ms.
|
|
492
|
+
// A function, so every add gets its own objects (bullmq keeps the options it is handed on the job).
|
|
493
|
+
const forgeJobOpts = () => ({
|
|
494
|
+
attempts: 2,
|
|
495
|
+
backoff: { type: "exponential", delay: 60_000 },
|
|
496
|
+
removeOnComplete: { age: FORGE_JOB_RETENTION_S },
|
|
497
|
+
removeOnFail: { age: FORGE_JOB_RETENTION_S },
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Enqueue the job a LATE forge message would have started (issue #646, DES-STEER-LATE-MESSAGE-RESUME-OR-REPLY, branch
|
|
502
|
+
* A): `fields` is what the receiver stored as the message's fallback (the object it would have handed
|
|
503
|
+
* `enqueueForgeJob`), rebuilt here through the same explicit field list, under the same delivery-GUID id and the same
|
|
504
|
+
* retry policy, on whichever queue the caller names. NO `deduplication` option: the run it follows held this target's
|
|
505
|
+
* semantic window, and the message is exactly the follow-up that window must not swallow. The GUID layer still holds,
|
|
506
|
+
* so two runs that both ended holding the message (replicas a comment fanned out to) make one job between them.
|
|
507
|
+
* Returns `{ jobId }` (an id bullmq already held answers the same, by design); throws what `queue.add` throws, and
|
|
508
|
+
* the caller falls back to a reply.
|
|
509
|
+
*/
|
|
510
|
+
export async function enqueueLateForgeJob(queue, kind, fields, { dataVersions } = {}) {
|
|
511
|
+
const { jobId, data } = forgeJobOf(kind, fields, { dataVersions });
|
|
512
|
+
await queue.add(kind, data, { jobId, ...forgeJobOpts() });
|
|
513
|
+
return { jobId };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The forge job's id, its data (an explicit field list, see `enqueueForgeJob`, stamped by `stampJobData`) and its
|
|
518
|
+
* semantic dedup id. Throws on a bad delivery id or console name, and on input a newer release stored with a
|
|
519
|
+
* `dataVersion` above this build's (issue #761; every caller that rebuilds stored input answers with a reply then).
|
|
520
|
+
*/
|
|
521
|
+
function forgeJobOf(kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, fallbackModels, maxCostUsd, packages, image, backend, excludeTools, excludeExtensions, excludeExtensionTools, mcp, skillsDir, instructions, resume, awaitInput, gates, approvalRequestId, gateTimeout, gateBudgetRaiseUsd, progress, statusComment, recover, steer, fork, forkPolicy, forkReserveMicros, thinkingLevel, parkedReply, replyReceivedAt, console: consoleMeta, secrets, secretsProfile, waitFor, replica, replicas, dataVersion }, { dataVersions } = {}) {
|
|
315
522
|
const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
|
|
523
|
+
if (approvalRequestId !== undefined && (typeof approvalRequestId !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(approvalRequestId))) throw new TypeError("approval-request-invalid");
|
|
524
|
+
if (replyReceivedAt !== undefined && (!Number.isSafeInteger(replyReceivedAt) || replyReceivedAt < 0 || resume !== true || typeof trigger?.comment?.body !== "string")) throw new TypeError("reply-receipt-invalid");
|
|
525
|
+
// Issue #642: an answer typed into the operator console carries WHO typed it, `{ by: <token name> }`, and nothing
|
|
526
|
+
// else. Rebuilt from the one field rather than copied, `parkedReply`'s rule for durable job data, and refused when the
|
|
527
|
+
// name is not a console token's: the worker prints it on the forge in the answer's trail comment, so a name outside
|
|
528
|
+
// that charset (an `@`, markdown, a control byte) must never reach a queue.
|
|
529
|
+
const consoleBy = consoleMeta === undefined ? null : consoleAnswerBy(consoleMeta);
|
|
530
|
+
const consoleDraft = consoleDraftOf(consoleMeta);
|
|
531
|
+
if (consoleDraft?.invalid) throw new TypeError("invalid answer draft provenance");
|
|
532
|
+
if (consoleMeta !== undefined && consoleBy === null) throw new TypeError("enqueueForgeJob: console.by must be a console token name ([a-z0-9][a-z0-9._-]{0,31})");
|
|
316
533
|
// `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
|
|
317
534
|
// come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
|
|
318
535
|
// only when the filter resolved one, exactly like chainDepth/parentJobId above, so an unflagged trigger's
|
|
319
536
|
// job data is byte-identical. Both sit at JOB level, never inside `trigger` -- that object is descriptive
|
|
320
537
|
// and is copied verbatim into /job/event.json, where an execution knob has no business.
|
|
321
|
-
const data = {
|
|
538
|
+
const data = stampJobData({
|
|
322
539
|
kind,
|
|
323
540
|
repo,
|
|
324
541
|
...(projectId !== undefined && { projectId }),
|
|
@@ -340,6 +557,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
340
557
|
// Issue #502: the allowed-model list, conditional like `packages` below so an unflagged trigger's job data is
|
|
341
558
|
// byte-identical, and at JOB level, never inside `trigger` (copied verbatim into /job/event.json).
|
|
342
559
|
...(models !== undefined && { models }),
|
|
560
|
+
...(fallbackModels !== undefined && { fallbackModels }),
|
|
343
561
|
...(packages !== undefined && { packages }),
|
|
344
562
|
...(image !== undefined && { image }),
|
|
345
563
|
// #227. WHERE this job's container is built. Conditional like `image`, so a trigger that named no
|
|
@@ -352,6 +570,10 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
352
570
|
// same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
|
|
353
571
|
// worker's business, never agent-visible input to reason about.
|
|
354
572
|
...(excludeTools !== undefined && { excludeTools }),
|
|
573
|
+
// Issue #800. Each its own conditional, and at JOB level, excludeTools' two reasons: a dropped list runs the job with what the
|
|
574
|
+
// file takes away, and what the session may not load is the worker's business, never agent-visible input.
|
|
575
|
+
...(excludeExtensions !== undefined && { excludeExtensions }),
|
|
576
|
+
...(excludeExtensionTools !== undefined && { excludeExtensionTools }),
|
|
355
577
|
...(mcp !== undefined && { mcp }),
|
|
356
578
|
// #501. The trigger's per-job dollar cap, as written. Conditional like `excludeTools`, at JOB level for its
|
|
357
579
|
// reason, and a narrowing like it: the worker runs the job under the smaller of this and the deployment cap.
|
|
@@ -369,9 +591,29 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
369
591
|
// REQ-AWAIT-INPUT (issue #625): only when armed, so an unflagged job's data is byte-identical, and at JOB level
|
|
370
592
|
// for resume's reason: `trigger` is copied verbatim into /job/event.json.
|
|
371
593
|
...(awaitInput === true && { awaitInput: true }),
|
|
594
|
+
...(gates !== undefined && { gates }),
|
|
595
|
+
...(approvalRequestId !== undefined && { approvalRequestId }),
|
|
596
|
+
...(gateTimeout !== undefined && { gateTimeout }),
|
|
597
|
+
...(gateBudgetRaiseUsd !== undefined && { gateBudgetRaiseUsd }),
|
|
598
|
+
// Issue #644: the report_progress tool, and the forge status comment. Only when armed, awaitInput's rule and
|
|
599
|
+
// for its reasons (byte-identical unflagged data; job level, never inside `trigger`).
|
|
600
|
+
...(progress === true && { progress: true }),
|
|
601
|
+
...(statusComment === true && { statusComment: true }),
|
|
602
|
+
// Issue #645: durable runs. Only when armed, the same rule and reasons; never part of an id either, since whether a
|
|
603
|
+
// lost run continues from its checkpoint says nothing about which delivery this is.
|
|
604
|
+
...(recover === true && { recover: true }),
|
|
605
|
+
// Issue #646: steering a running job. Only when armed, the same rule and reasons; never part of an id, since whether
|
|
606
|
+
// a run accepts messages says nothing about which delivery started it.
|
|
607
|
+
...(steer === true && { steer: true }),
|
|
372
608
|
// A phrase-free reply to a parked issue (issue #625), gated by the worker on the key's `parked` mark. Never part
|
|
373
609
|
// of an id: it says how the delivery was routed, not what it is.
|
|
374
610
|
...(parkedReply === true && { parkedReply: true }),
|
|
611
|
+
// The held-reply settle supplies the receiver's original clock, not a timestamp from comment text.
|
|
612
|
+
// It is private accounting metadata, so /job/event.json's trigger remains byte-identical.
|
|
613
|
+
...(replyReceivedAt !== undefined && { replyReceivedAt }),
|
|
614
|
+
// Issue #642: the console answer's provenance, at JOB level and never inside `trigger` (copied verbatim into
|
|
615
|
+
// /job/event.json): it is for the worker's trail comment, not for the agent. Never part of an id.
|
|
616
|
+
...(consoleBy !== null && { console: { by: consoleBy, ...(consoleDraft ?? {}) } }),
|
|
375
617
|
// REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
|
|
376
618
|
// them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
|
|
377
619
|
// credential in Redis would outlive the container it was scoped to. The worker resolves them at job
|
|
@@ -392,9 +634,11 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
392
634
|
// Conditional for the same reason packages/image/resume are: an unflagged job's data must keep
|
|
393
635
|
// exactly the keys it has today. `replica` is this job's 1-based index and `replicas` the set size;
|
|
394
636
|
// both are integers, so the run record they land in stays PII-free by construction.
|
|
637
|
+
...(fork !== undefined && { fork: validateFork(fork), forkPolicy, forkReserveMicros }),
|
|
638
|
+
...(thinkingLevel !== undefined && { thinkingLevel }),
|
|
395
639
|
...(replica !== undefined && { replica }),
|
|
396
640
|
...(replicas !== undefined && { replicas }),
|
|
397
|
-
};
|
|
641
|
+
}, dataVersion, dataVersions);
|
|
398
642
|
// A close-triggered job (issue #231) leads the semantic key's flow slot with `closed:`. Without it,
|
|
399
643
|
// a label/comment/PR job on the same target and flow inside the 10-minute window silently swallows
|
|
400
644
|
// the close job -- and because a swallowed close job writes no run record, the once trigger it was
|
|
@@ -410,30 +654,14 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
410
654
|
// narrow to the matched action word, like the PR half already does.
|
|
411
655
|
const isCloseJob = matched?.type === "issue" || (matched?.type === "pull_request" && PR_CLOSE_WORDS.has(matched?.action));
|
|
412
656
|
const flowSlot = `${isCloseJob ? "closed:" : ""}${command !== undefined ? `cmd:${command}` : flow}`;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${flowSlot}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
|
|
422
|
-
attempts: 2,
|
|
423
|
-
backoff: { type: "exponential", delay: 60_000 },
|
|
424
|
-
removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
|
|
425
|
-
removeOnFail: { age: 31 * 24 * 3600 },
|
|
426
|
-
});
|
|
427
|
-
// Issue #289: WHICH of the two dedup layers spoke is readable off `queue.add`'s return, and only one
|
|
428
|
-
// of them is. Verified at bullmq 5.80.4's own Lua: a jobId collision (the GUID shield,
|
|
429
|
-
// REQ-DEDUP-BY-DELIVERY-GUID) returns THE SAME id -- structurally invisible here, and correctly so,
|
|
430
|
-
// because a forge retry of a delivery that IS queued deserves the answer "queued" -- while the
|
|
431
|
-
// `deduplication` option (the 10-minute semantic window) returns the EXISTING job's DIFFERENT id.
|
|
432
|
-
// So `added.id !== jobId` means this delivery was swallowed by the window and created nothing, which
|
|
433
|
-
// the receiver used to log as `enqueued` and answer as success. A defensive undefined (a fake queue
|
|
434
|
-
// predating the comparison) reads as not-deduplicated, today's behaviour exactly.
|
|
435
|
-
if (added?.id && added.id !== jobId) return { jobId, deduplicated: true, survivingJobId: added.id };
|
|
436
|
-
return { jobId, deduplicated: false };
|
|
657
|
+
// A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
|
|
658
|
+
// trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
|
|
659
|
+
// command's 10-minute window would swallow a different command's delivery on the same target. The
|
|
660
|
+
// `cmd:` prefix keeps a command named X from coalescing against a flow named X -- `:` is outside
|
|
661
|
+
// the skill-name charset, so no real flow can spell the prefixed form -- and a flow job's key
|
|
662
|
+
// stays byte-identical to before the feature.
|
|
663
|
+
const dedupId = `${repo}${targetSeparator(kind, target?.type)}${target.number}:${flowSlot}${fork !== undefined ? `:fork:${fork.id}` : ""}${replica !== undefined ? `:r${replica}` : ""}`;
|
|
664
|
+
return { jobId, data, dedupId };
|
|
437
665
|
}
|
|
438
666
|
|
|
439
667
|
/**
|
|
@@ -458,12 +686,23 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
458
686
|
* being wrong costs money, not for a reader that runs every second. Fails open to `[]`, so an unreadable
|
|
459
687
|
* keyspace degrades to the registry's answer rather than refusing.
|
|
460
688
|
*/
|
|
461
|
-
export async function discoverHostQueues(redis,
|
|
462
|
-
|
|
689
|
+
export async function discoverHostQueues(redis, opts = {}) {
|
|
690
|
+
return (await discoverHostQueuesReporting(redis, opts)).names;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* `discoverHostQueues`, and whether its answer is the whole keyspace (issue #710, review round 1): `{ names, incomplete }`,
|
|
695
|
+
* `incomplete` null for a scan that reached its end, else why it did not (the scan threw, or ran out of time with
|
|
696
|
+
* `names` the part it read). For a reader for which a queue it did not ask is not an answer: the console's run page
|
|
697
|
+
* asks whether a job's data is still held, and "no queue holds it" off a scan that never finished is a false refusal.
|
|
698
|
+
* The kill switch keeps `discoverHostQueues`, which fails open to what it read.
|
|
699
|
+
*/
|
|
700
|
+
export async function discoverHostQueuesReporting(redis, { timeoutMs = 2_000, count = 500 } = {}) {
|
|
701
|
+
const prefix = `${QUEUE}@`;
|
|
463
702
|
const names = new Set();
|
|
703
|
+
let cursor = "0";
|
|
464
704
|
try {
|
|
465
705
|
const deadline = Date.now() + timeoutMs;
|
|
466
|
-
let cursor = "0";
|
|
467
706
|
do {
|
|
468
707
|
// BOUNDED, because BullMQ's connections carry `maxRetriesPerRequest: null` and a command against
|
|
469
708
|
// an unreachable server therefore QUEUES FOREVER rather than rejecting -- so an unguarded await
|
|
@@ -476,24 +715,26 @@ export async function discoverHostQueues(redis, { timeoutMs = 2_000, count = 500
|
|
|
476
715
|
// thing on the loop, which is the one case it exists for.
|
|
477
716
|
let timer;
|
|
478
717
|
const [next, keys] = await Promise.race([
|
|
479
|
-
redis.scan(cursor, "MATCH",
|
|
718
|
+
redis.scan(cursor, "MATCH", metaKeyPattern(prefix), "COUNT", count),
|
|
480
719
|
new Promise((_, reject) => {
|
|
481
720
|
timer = setTimeout(() => reject(new Error("scan timed out")), Math.max(1, deadline - Date.now()));
|
|
482
721
|
}),
|
|
483
722
|
]).finally(() => clearTimeout(timer));
|
|
484
723
|
cursor = next;
|
|
485
724
|
for (const key of keys ?? []) {
|
|
486
|
-
const
|
|
725
|
+
const queueName = queueNameOfMetaKey(String(key));
|
|
726
|
+
if (queueName === null || !queueName.startsWith(prefix)) continue;
|
|
727
|
+
const name = queueName.slice(prefix.length);
|
|
487
728
|
// Validated like every other peer-derived name: a key an operator hand-created could hold
|
|
488
729
|
// anything, and `new Queue` throws on a `:`, which would take the kill switch out entirely.
|
|
489
730
|
if (WORKER_NAME_RE.test(name)) names.add(name);
|
|
490
731
|
}
|
|
491
732
|
} while (cursor !== "0" && Date.now() < deadline);
|
|
492
|
-
} catch {
|
|
493
|
-
// Fail open: the registry's answer alone is still better than refusing to pause.
|
|
494
|
-
return [];
|
|
733
|
+
} catch (err) {
|
|
734
|
+
// Fail open: the registry's answer alone is still better than refusing to pause. Nothing read is kept, as before.
|
|
735
|
+
return { names: [], incomplete: err?.message ?? String(err) };
|
|
495
736
|
}
|
|
496
|
-
return [...names].sort().map(hostQueueName);
|
|
737
|
+
return { names: [...names].sort().map(hostQueueName), incomplete: String(cursor) === "0" ? null : "the queue scan ran out of time" };
|
|
497
738
|
}
|
|
498
739
|
|
|
499
740
|
/**
|