@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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { InfraRetry, RECOVERY_ENQUEUED, RECOVERY_SUPERSEDED } from "./processor.mjs";
|
|
2
|
+
import { enqueueLateForgeJob, QUEUE, releaseSemanticWindow } from "./queue.mjs";
|
|
3
|
+
import { runIdOf } from "./recovery-queue.mjs";
|
|
4
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
5
|
+
import { steerTargetOf } from "./inbox-relay.mjs";
|
|
6
|
+
import * as inboxModule from "./steer-inbox.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The run's inbox at its FINAL outcome (issue #646, DES-STEER-LATE-MESSAGE-RESUME-OR-REPLY): close it, so nothing more
|
|
10
|
+
* can be pushed, and count what became of every message for the record's `steered`.
|
|
11
|
+
*
|
|
12
|
+
* WHEN. At a final outcome only, immediately before the record write (index.mjs; a bolt test holds every record site to
|
|
13
|
+
* it). A FINAL outcome is every RETURNED result except a #645 handoff (`recovery-enqueued`: the run goes on in a
|
|
14
|
+
* recovery job that keeps the run id and must find its inbox open; `recovery-superseded`: the run already settled
|
|
15
|
+
* elsewhere), and a THROWN error that ends the job: an UnrecoverableError, anything that is not an InfraRetry, or an
|
|
16
|
+
* InfraRetry on the job's last attempt. An InfraRetry with attempts left is NOT final: the next attempt re-registers
|
|
17
|
+
* the run and writes the waiting messages again, so closing here would turn each of them into `late` for a run that
|
|
18
|
+
* is still going to happen.
|
|
19
|
+
*
|
|
20
|
+
* WHAT. `closeInbox` (one Lua script: sets closed, drops the open key and the target member, turns every `queued` or
|
|
21
|
+
* `written` message into `late`, returns every entry and state). After it every push is refused, and everything pushed
|
|
22
|
+
* before it is in its reply, so no message is accepted and then silently never answered. Operator-source late messages
|
|
23
|
+
* are reported only (the console and the CLI show `late`). Forge-source late messages go to `onLateForge(entries,
|
|
24
|
+
* context)`, which a wired worker builds with `makeLateForge` below (a resumed job, or one fixed reply); unwired it is a
|
|
25
|
+
* logging no-op. It is awaited, so what it does happens before the record is written, and it is called at the FIRST
|
|
26
|
+
* successful close only (with no entries too, because the wired `makeLateForge` also releases the run's semantic
|
|
27
|
+
* window; the unwired default releases nothing). A second settle of the same outcome (the processor's, then BullMQ's
|
|
28
|
+
* failed listener for the same thrown failure) gets the same late entries back with `first: false` and must not
|
|
29
|
+
* answer them twice (review round 1, F2): it only counts.
|
|
30
|
+
*
|
|
31
|
+
* Never throws: a Valkey fault is logged by code and answers null, and the record is then written without `steered`
|
|
32
|
+
* (the run's outcome must never wait on, or be rewritten by, its inbox).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Whether this outcome ends the run: see the header. `result` for a returned outcome, `error` for a thrown one. */
|
|
36
|
+
export function isFinalOutcome({ job, result = null, error = null } = {}) {
|
|
37
|
+
if (result !== null && result !== undefined) return result.reason !== RECOVERY_ENQUEUED && result.reason !== RECOVERY_SUPERSEDED;
|
|
38
|
+
if (error === null || error === undefined) return false;
|
|
39
|
+
// Anything but an InfraRetry ends the job: index.mjs rethrows it as an UnrecoverableError (or it already is one), and
|
|
40
|
+
// BullMQ never retries that. Decided on the class alone, so this module needs no queue library.
|
|
41
|
+
if (!(error instanceof InfraRetry)) return true;
|
|
42
|
+
const made = Number.isSafeInteger(job?.attemptsMade) && job.attemptsMade >= 0 ? job.attemptsMade : 0;
|
|
43
|
+
const attempts = Number.isSafeInteger(job?.opts?.attempts) && job.opts.attempts >= 1 ? job.opts.attempts : 1;
|
|
44
|
+
// `attemptsMade` counts the attempts FINISHED before this one while the job is processing (run-history.mjs
|
|
45
|
+
// `buildRecord` reads it the same way), so this attempt is the last when one more reaches the limit.
|
|
46
|
+
return made + 1 >= attempts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const defaultLateForge = (log) => (entries) => {
|
|
50
|
+
if (entries.length > 0) log("steer_late_forge_unanswered", { count: entries.length });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The one reply a late forge message gets (branch B): no mention, no quote of the message, fixed words. */
|
|
54
|
+
export const STEER_LATE_REPLY = "This run had already finished when your message arrived, so it did not see it. Comment again with the trigger phrase to start a new run.";
|
|
55
|
+
|
|
56
|
+
const codeOf = (e) => (typeof e?.code === "string" && e.code !== "" ? e.code : typeof e?.error === "string" ? e.error : "error");
|
|
57
|
+
const hopOf = (entry) => (Number.isSafeInteger(entry?.hop) && entry.hop >= 0 ? entry.hop : 0);
|
|
58
|
+
const targetNumber = (t) => (typeof t?.number === "number" ? t.number : typeof t?.number === "string" && /^[1-9][0-9]{0,15}$/.test(t.number) ? Number(t.number) : null);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `makeLateForge({ redis, queues, comment, log, now })` -> `onLateForge(entries, { job, runId, result, error, queueName })`,
|
|
62
|
+
* what a late forge message becomes at a steer-armed run's final outcome (DES-STEER-LATE-MESSAGE-RESUME-OR-REPLY).
|
|
63
|
+
*
|
|
64
|
+
* FIRST, whatever the entries: the run's semantic window is released (`releaseSemanticWindow`), because a comment after
|
|
65
|
+
* the run is no longer a steer (the run is gone from its target) and must start a job of its own, which the window the
|
|
66
|
+
* run still holds would otherwise swallow for the rest of its ten minutes.
|
|
67
|
+
*
|
|
68
|
+
* THEN, the forge-source late entries in inbox order, split by hop. A message with `hop >= 1` was carried once already,
|
|
69
|
+
* so it is only ever answered: that is what ends a chain of runs. Of the rest (hop 0), the FIRST decides:
|
|
70
|
+
* (A) the outcome is not `operator-cancel` or `agent-cancel` (the caller stopped this work; starting it again is
|
|
71
|
+
* the one thing the cancel promised not to do), its stored fallback job (the job the receiver would have enqueued,
|
|
72
|
+
* `readFallbacks`) has `resume === true`, and that job names this run's forge, repo, target type and number: the job is enqueued on the
|
|
73
|
+
* queue that ran this run (`job.queueName`: a host queue stays on its host, anything else goes to the shared one;
|
|
74
|
+
* the host-lost sweep passes `queueName` to send it to the shared queue, since that host is gone), under its own
|
|
75
|
+
* delivery-GUID id, with NO deduplication option (`enqueueLateForgeJob`). The other hop-0 messages are pre-seeded
|
|
76
|
+
* into that job's inbox with `hop: 1` (`preseedInbox`), so its relay writes them as steers at its loop start. Only
|
|
77
|
+
* when that job is steer-armed itself: an unarmed job has no relay and no settle, so a carried message would wait
|
|
78
|
+
* unread and never be answered; those are answered instead.
|
|
79
|
+
* (B) otherwise (no fallback, no resume, a cancel, a fallback for another target, a failed read or enqueue): answered.
|
|
80
|
+
* Everything answered gets ONE fixed comment on the run's target (`STEER_LATE_REPLY`), however many messages it covers.
|
|
81
|
+
* At most one extra job per run end. A failure along branch A falls back to the answer, logged by code: a message is
|
|
82
|
+
* either carried or answered, never neither. Operator-source late messages never reach here (reported only).
|
|
83
|
+
*
|
|
84
|
+
* Why pre-seed the inbox rather than carry the messages in the new job's data: the relay already writes every `queued`
|
|
85
|
+
* message of its run's inbox at its start, through the same framing, acks and settle as any steer, and a retry, a
|
|
86
|
+
* recovery and the console all see them there. In job data they would need a second delivery path into the relay, and
|
|
87
|
+
* would be durable job data holding attacker-chosen text the record and the console must never show.
|
|
88
|
+
*
|
|
89
|
+
* Logs `steer_late_resumed { jobId, runId, n }` (n: the messages the new job carries, its own first one included) and
|
|
90
|
+
* `steer_late_replied { runId, n }`; ids and counts, never text. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export function makeLateForge({ redis, queues, comment = async () => {}, log = () => {}, now = Date.now, inbox = inboxModule, enqueue = enqueueLateForgeJob, release = releaseSemanticWindow, canPublish = async () => true } = {}) {
|
|
93
|
+
const queueFor = (name) => {
|
|
94
|
+
if (typeof name === "string" && name.startsWith(`${QUEUE}@`) && typeof queues?.host === "function") return { queue: queues.host(name), name };
|
|
95
|
+
return { queue: queues?.shared?.(), name: QUEUE };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Branch A for `first`: the new job's id, or null (the caller answers instead). Never throws.
|
|
99
|
+
const resume = async ({ job, runId, first, queueName }) => {
|
|
100
|
+
const read = await inbox.readFallbacks({ redis, runId });
|
|
101
|
+
if (!read || read.error) {
|
|
102
|
+
log("steer_late_fallback_unread", { runId: sanitizeJobId(runId), code: codeOf(read) });
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const fields = read.jobs?.[first.id];
|
|
106
|
+
if (!fields || fields.resume !== true) return null;
|
|
107
|
+
if ((fields.kind !== undefined && fields.kind !== job.data.kind) || fields.repo !== job.data.repo || typeof fields.target?.type !== "string" || fields.target.type !== job.data.target?.type || targetNumber(fields.target) === null || targetNumber(fields.target) !== targetNumber(job.data.target)) {
|
|
108
|
+
log("steer_late_fallback_mismatch", { runId: sanitizeJobId(runId) });
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const route = queueFor(queueName ?? job.queueName);
|
|
112
|
+
try {
|
|
113
|
+
if (!route.queue) throw Object.assign(new Error("no queue"), { code: "no-queue" });
|
|
114
|
+
const { jobId } = await enqueue(route.queue, job.data.kind, fields);
|
|
115
|
+
return { jobId, fields, queue: route.name };
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log("steer_late_resume_failed", { runId: sanitizeJobId(runId), queue: route.name, code: codeOf(e) });
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return async function onLateForge(entries, { job, runId = runIdOf(job), result = null, error = null, queueName = null, heldResumed = false } = {}) {
|
|
123
|
+
try {
|
|
124
|
+
if (job?.data?.steer !== true) return;
|
|
125
|
+
await release(job, log);
|
|
126
|
+
const late = Array.isArray(entries) ? entries.filter((e) => e?.source === "forge") : [];
|
|
127
|
+
if (late.length === 0) return;
|
|
128
|
+
// Closing the old inbox must not carry its private fallback to a newly configured forge.
|
|
129
|
+
// The trusted worker checks the original draft's source again, including terminal retries.
|
|
130
|
+
if (await canPublish(job.data) !== true) { log("steer_late_source_unavailable", { runId: sanitizeJobId(runId), count: late.length }); return; }
|
|
131
|
+
const fresh = late.filter((e) => hopOf(e) === 0);
|
|
132
|
+
const answer = late.filter((e) => hopOf(e) >= 1);
|
|
133
|
+
const cancelled = ["operator-cancel", "agent-cancel"].includes(result?.reason) || ["operator-cancel", "agent-cancel"].includes(error?.reason);
|
|
134
|
+
// Issue #638: a held reply already became this parked run's resumed job, so a late message is answered, never
|
|
135
|
+
// a second resumed job for the same run.
|
|
136
|
+
const started = fresh.length > 0 && !cancelled && heldResumed !== true ? await resume({ job, runId, first: fresh[0], queueName }) : null;
|
|
137
|
+
if (started === null) {
|
|
138
|
+
answer.push(...fresh);
|
|
139
|
+
} else {
|
|
140
|
+
const carry = fresh.slice(1);
|
|
141
|
+
let carried = 0;
|
|
142
|
+
if (carry.length > 0 && started.fields.steer !== true) {
|
|
143
|
+
answer.push(...carry);
|
|
144
|
+
} else if (carry.length > 0) {
|
|
145
|
+
const seeded = await inbox.preseedInbox({ redis, runId: started.jobId, entries: carry.map((e) => ({ ...e, hop: 1 })), now });
|
|
146
|
+
if (!seeded || seeded.error) {
|
|
147
|
+
log("steer_late_preseed_failed", { jobId: sanitizeJobId(started.jobId), runId: sanitizeJobId(runId), code: codeOf(seeded) });
|
|
148
|
+
answer.push(...carry);
|
|
149
|
+
} else {
|
|
150
|
+
carried = carry.length;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
log("steer_late_resumed", { jobId: sanitizeJobId(started.jobId), runId: sanitizeJobId(runId), n: 1 + carried, queue: started.queue });
|
|
154
|
+
}
|
|
155
|
+
if (answer.length > 0) {
|
|
156
|
+
try {
|
|
157
|
+
await comment({ ...job.data, id: job.id }, STEER_LATE_REPLY);
|
|
158
|
+
} catch {}
|
|
159
|
+
log("steer_late_replied", { runId: sanitizeJobId(runId), n: answer.length });
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
log("steer_late_failed", { runId: sanitizeJobId(runId ?? ""), code: codeOf(e) });
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `settleInbox({ redis, job, runId, result, error, log, onLateForge, inbox })` -> `{ delivered, late, withdrawn }` or
|
|
169
|
+
* null (not a steer job, not final, or the close failed). `job` is the BullMQ job (its `data.steer`, its attempts).
|
|
170
|
+
*/
|
|
171
|
+
export async function settleInbox({ redis, job, runId = runIdOf(job), result = null, error = null, log = () => {}, onLateForge = null, inbox = inboxModule, lateContext = {} } = {}) {
|
|
172
|
+
try {
|
|
173
|
+
if (job?.data?.steer !== true) return null;
|
|
174
|
+
if (!isFinalOutcome({ job, result, error })) return null;
|
|
175
|
+
if (runId === null || runId === undefined || runId === "") return null;
|
|
176
|
+
const { targetKey } = steerTargetOf(job.data);
|
|
177
|
+
const closed = await inbox.closeInbox({ redis, runId, targetKey });
|
|
178
|
+
if (!closed || closed.error) {
|
|
179
|
+
log("steer_settle_failed", { jobId: sanitizeJobId(job.id), code: typeof closed?.error === "string" ? closed.error : "error", ...(closed?.reason ? { reason: closed.reason } : {}) });
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const counts = { delivered: 0, late: 0, withdrawn: 0 };
|
|
183
|
+
for (const st of Object.values(closed.state ?? {})) if (Object.hasOwn(counts, st)) counts[st] += 1;
|
|
184
|
+
const lateForge = (closed.entries ?? []).filter((e) => e?.source === "forge" && closed.state?.[e.id] === "late");
|
|
185
|
+
if (closed.first === true) {
|
|
186
|
+
try {
|
|
187
|
+
await (typeof onLateForge === "function" ? onLateForge : defaultLateForge(log))(lateForge, { ...lateContext, job, runId, result, error });
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
if (counts.late > 0) log("steer_settled_late", { jobId: sanitizeJobId(job.id), ...counts });
|
|
191
|
+
return counts;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
log("steer_settle_failed", { jobId: sanitizeJobId(job?.id ?? ""), code: typeof e?.code === "string" ? e.code : "error" });
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|