@edgehero/pi-dispatch 4.3.1 → 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 +84 -3
- 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 +190 -29
- 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 +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- 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 +734 -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 +1550 -173
- 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 +264 -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 +780 -38
- 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/index.mjs
CHANGED
|
@@ -1,28 +1,42 @@
|
|
|
1
|
+
import { resolveModelRoles } from "./model-roles.mjs";
|
|
2
|
+
import { SubscriptionWait } from "./subscription-wait.mjs";
|
|
3
|
+
import { zeroRatedFor } from "./reserve-estimate.mjs";
|
|
4
|
+
import { isTransientOverlayRead } from "./model-catalog.mjs";
|
|
5
|
+
import { checkFailoverCandidate, markModelCooldown, readModelAttempt, selectFailoverJob, writeModelAttempt } from "./model-failover.mjs";
|
|
6
|
+
import { modelAttemptIdOf } from "./model-attempts.mjs";
|
|
1
7
|
import { availableParallelism } from "node:os";
|
|
2
8
|
import { DelayedError, UnrecoverableError, Worker } from "bullmq";
|
|
3
9
|
import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
|
|
4
10
|
import { jobContainerName } from "./backend-local.mjs";
|
|
5
11
|
import { scrubCredentials } from "./redact.mjs";
|
|
12
|
+
import { cleanTouch } from "./touch-state.mjs";
|
|
6
13
|
import { BACKEND_NOT_REGISTERED } from "./backend-registry.mjs";
|
|
7
|
-
import { CANCEL_ACK_TTL_MS, cancelAckKey, cancelReqKey } from "./cancel-state.mjs";
|
|
8
|
-
import { InfraRetry, NETNS_KEEPER_CRASH_LOOP, NETNS_KEEPER_NOT_HOLDING, TERMINAL_COMMENTS, runJob } from "./processor.mjs";
|
|
14
|
+
import { CANCEL_ACK_TTL_MS, cancelAckKey, cancelReqKey, agentCancelReqKey, agentCancelAckKey } from "./cancel-state.mjs";
|
|
15
|
+
import { InfraRetry, NETNS_KEEPER_CRASH_LOOP, NETNS_KEEPER_NOT_HOLDING, NewerReleaseHold, TERMINAL_COMMENTS, runJob, checkDraftAnswerSource } from "./processor.mjs";
|
|
16
|
+
import { CANCELLED_BEFORE_START_COMMENT, SIZE_REFUSAL_COMMENTS } from "./comment-tables.mjs";
|
|
9
17
|
import { NETNS_KEEPER_YOUNG_HOLD_MAX_MS, netnsKeeperCrashLoopSentence, netnsKeeperLoopAgainSentence } from "./netns-keeper.mjs";
|
|
10
18
|
import { PODMAN_RESTART_HOLD_EXPIRED, PODMAN_RESTART_HOLD_MAX_MS, PODMAN_RESTART_HOLD_RECHECK_MS } from "./runtime-observations.mjs";
|
|
11
|
-
import { earlierFrom, targetFor } from "./run-history.mjs";
|
|
19
|
+
import { earlierFrom, recordOfThisJob, recordReadable, targetFor } from "./run-history.mjs";
|
|
12
20
|
import { isPerMachineHost } from "./backends.mjs";
|
|
13
21
|
import { hash16 } from "./fleet-lease.mjs";
|
|
14
22
|
import { endpointsForModel } from "./model-endpoints.mjs";
|
|
15
23
|
import { splitModelEntry } from "./model-ref.mjs";
|
|
16
24
|
import { effectiveCostCapMicros } from "./money.mjs";
|
|
17
|
-
import { dollarWindowCaps } from "./dollar-budget.mjs";
|
|
25
|
+
import { dollarWindowCaps, valkeyEndpointTag } from "./dollar-budget.mjs";
|
|
18
26
|
import { concurrencyFor, dollarCapsFor, makeInFlight, modelDollarRows, projectDollarCapsFor, projectRowFor, rowScopeFor, scopedLedgers } from "./scoped-limits.mjs";
|
|
19
27
|
import { memberScopeOf, projectOf } from "./projects.mjs";
|
|
20
28
|
import { resolveJobSize } from "./job-size.mjs";
|
|
21
29
|
import { BUDGET_RECHECK_MS, HOST_BUDGET_TICK_MS, NEVER_FITS_RECHECK_MS, makeHostBudget } from "./host-budget.mjs";
|
|
22
30
|
import { governedDollars } from "./allocation.mjs";
|
|
23
31
|
import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, afterMs, unreadableConditions, waitArmed, waitBackoffMs, waitLabel, waitProfileNames } from "./wait-for.mjs";
|
|
24
|
-
import { makeWaitState } from "./wait-state.mjs";
|
|
32
|
+
import { answerOf, makeWaitState } from "./wait-state.mjs";
|
|
33
|
+
import { makeDeferTo } from "./defer-write.mjs";
|
|
34
|
+
import { jobDataNewer } from "./job-data-version.mjs";
|
|
25
35
|
import { releaseSemanticWindow } from "./queue.mjs";
|
|
36
|
+
import { runIdOf } from "./recovery-queue.mjs";
|
|
37
|
+
import { isFinalOutcome, settleInbox } from "./inbox-settle.mjs";
|
|
38
|
+
import { makeAwaitKeeper } from "./held-settle.mjs";
|
|
39
|
+
import { isProjectId } from "./project-id.mjs";
|
|
26
40
|
|
|
27
41
|
|
|
28
42
|
export const QUEUE = "pi-jobs";
|
|
@@ -49,11 +63,18 @@ export const REJECTED_SEEN_MAX = 1000;
|
|
|
49
63
|
// docker daemon bounds any herd by its own concurrency, and a contended wake just re-defers.
|
|
50
64
|
export const SCOPE_BUSY_RECHECK_MS = 5_000;
|
|
51
65
|
|
|
66
|
+
// The newer-release re-check (issue #761, DES-STORE-VERSION-WHEN-NEEDED): a job this worker cannot read, or one whose
|
|
67
|
+
// settings, triggers or pause windows file a newer pi-dispatch wrote, waits for this host's upgrade. Nothing here can
|
|
68
|
+
// end that wait but an upgrade or a rollback of the file, so it asks rarely: ten minutes keeps a fleet mid-upgrade
|
|
69
|
+
// from spinning on jobs it cannot run, and an upgraded worker picks the job up on its next look.
|
|
70
|
+
export const NEWER_RELEASE_RECHECK_MS = 600_000;
|
|
71
|
+
|
|
52
72
|
// The endpoint-busy re-check (issue #503): a job whose model server has every slot held re-tests on a fixed
|
|
53
73
|
// cadence, for the scope re-check's reason (a held slot has no natural "until"). Its own value rather than a
|
|
54
|
-
// borrow of 5s
|
|
55
|
-
//
|
|
56
|
-
//
|
|
74
|
+
// borrow of 5s: 7s is distinct from the scope re-check, the wait throttle floor and the supersede re-ask, and a
|
|
75
|
+
// test keeps all four apart. Since issue #737 every deferral records its reason (`defer-write.mjs`), and the
|
|
76
|
+
// distinct instants stay the evidence for a reader older than that record, and for a job whose record could not
|
|
77
|
+
// be written. Short, because a local model run is often short too.
|
|
57
78
|
export const ENDPOINT_BUSY_RECHECK_MS = 7_000;
|
|
58
79
|
|
|
59
80
|
// How long a job waits before re-asking whether a target's holder is still alive (issue #230). Reached only
|
|
@@ -73,9 +94,10 @@ const THROTTLE_ALARM = 5;
|
|
|
73
94
|
|
|
74
95
|
// The floor under a throttled or aborted re-ask. Its own constant rather than a borrow of
|
|
75
96
|
// SUPERSEDE_RECHECK_MS, which documents an unrelated concern. Deliberately NOT 5s: that is
|
|
76
|
-
// SCOPE_BUSY_RECHECK_MS, and INT-WAIT-PROFILES-CONTRACT
|
|
77
|
-
//
|
|
78
|
-
// the
|
|
97
|
+
// SCOPE_BUSY_RECHECK_MS, and INT-WAIT-PROFILES-CONTRACT has wait deferrals distinguishable from scope
|
|
98
|
+
// deferrals by wake instant. The defer record (`defer-write.mjs`, issue #737) now says why a job sits in the
|
|
99
|
+
// delayed set; the instants remain the evidence for a reader older than it and for a record that could not be
|
|
100
|
+
// written. A test pins the two apart.
|
|
79
101
|
const THROTTLE_FLOOR_MS = 11_000;
|
|
80
102
|
|
|
81
103
|
/**
|
|
@@ -87,6 +109,13 @@ const THROTTLE_FLOOR_MS = 11_000;
|
|
|
87
109
|
*/
|
|
88
110
|
const ABORT_GRACE_MS = 30_000;
|
|
89
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The longest `boundAfterAbort` waits for its give-up hook (the host budget hold, and since issue #644 the live
|
|
114
|
+
* publisher's finish, itself bounded at 3 s plus 5 s) before it resolves anyway: a hook that hangs must not undo the
|
|
115
|
+
* bound this function exists for.
|
|
116
|
+
*/
|
|
117
|
+
const GIVE_UP_FINISH_MS = 10_000;
|
|
118
|
+
|
|
90
119
|
/**
|
|
91
120
|
* Resolve `run` normally, but stop waiting once the abort has fired and the grace has passed.
|
|
92
121
|
*
|
|
@@ -110,9 +139,21 @@ function boundAfterAbort(run, signal, job, log, graceMs = ABORT_GRACE_MS, onStop
|
|
|
110
139
|
if (settled) return;
|
|
111
140
|
settled = true;
|
|
112
141
|
log("stop_did_not_take", { job: job.id, graceMs });
|
|
113
|
-
// Issue #596, phase 2: the container may still run, so its host budget hold must outlive this job.
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
// Issue #596, phase 2: the container may still run, so its host budget hold must outlive this job. Issue
|
|
143
|
+
// #644: the hook also ends the attempt's live publisher, and is awaited (bounded) BEFORE this resolves,
|
|
144
|
+
// because the processor removes the job directory the publisher's last read needs once it has the result.
|
|
145
|
+
let wait = null;
|
|
146
|
+
Promise.race([
|
|
147
|
+
Promise.resolve()
|
|
148
|
+
.then(onStopDidNotTake)
|
|
149
|
+
.catch(() => {}),
|
|
150
|
+
new Promise((done) => {
|
|
151
|
+
wait = setTimeout(done, GIVE_UP_FINISH_MS);
|
|
152
|
+
wait.unref?.();
|
|
153
|
+
}),
|
|
154
|
+
])
|
|
155
|
+
.finally(() => clearTimeout(wait))
|
|
156
|
+
.then(() => resolve({ code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }));
|
|
116
157
|
}, graceMs);
|
|
117
158
|
// A boot-blocking handle is not wanted here: the worker should be able to exit if everything else
|
|
118
159
|
// has finished, and this timer only matters while a job is still in flight.
|
|
@@ -145,7 +186,9 @@ function boundAfterAbort(run, signal, job, log, graceMs = ABORT_GRACE_MS, onStop
|
|
|
145
186
|
* runs inside runJob against the freshly passed caps (CONST-BUDGET-BEFORE-TOKENS).
|
|
146
187
|
*
|
|
147
188
|
* The read happens once per pickup, right after the scope gate and before the model endpoint gate (issue #503),
|
|
148
|
-
* because that gate needs the effective provider and model, which the overlay can supply.
|
|
189
|
+
* because that gate needs the effective provider and model, which the overlay can supply. One more read sits at the
|
|
190
|
+
* processor's head (issue #761) and asks only whether a newer pi-dispatch wrote the file; every value a job runs with
|
|
191
|
+
* still comes from the pickup's read.
|
|
149
192
|
*/
|
|
150
193
|
/**
|
|
151
194
|
* A job's endpoint set (issues #503, #502): the endpoints its main model AND every model on its effective allowed
|
|
@@ -218,6 +261,11 @@ export function makeRunningJobs() {
|
|
|
218
261
|
entries.set(next, { ...entry });
|
|
219
262
|
return next;
|
|
220
263
|
},
|
|
264
|
+
/** Update only this pickup: a retry with the same job id has a different key. */
|
|
265
|
+
update(key, patch) {
|
|
266
|
+
const entry = entries.get(key);
|
|
267
|
+
if (entry) entries.set(key, { ...entry, ...patch });
|
|
268
|
+
},
|
|
221
269
|
/** Removes one pickup's entry; true only the first time. */
|
|
222
270
|
remove(key) {
|
|
223
271
|
return entries.delete(key);
|
|
@@ -228,13 +276,44 @@ export function makeRunningJobs() {
|
|
|
228
276
|
};
|
|
229
277
|
}
|
|
230
278
|
|
|
231
|
-
|
|
279
|
+
// A free pre-pickup refusal needs its own project snapshot, before settlement can await a live configuration edit.
|
|
280
|
+
// It never enters pickup, whose one snapshot must stay after lost-lock checks and immediately before pause gates.
|
|
281
|
+
function makeBeforePickupRecorder({ projects, settleSteerBeforePickup, recordRun, now }) {
|
|
282
|
+
return async (job, boundary) => {
|
|
283
|
+
const project = projectOf(job.data, projects());
|
|
284
|
+
const result = { ...boundary, budgetReserved: false };
|
|
285
|
+
await settleSteerBeforePickup({ job, result });
|
|
286
|
+
await recordRun({ job, result, project, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
|
|
287
|
+
return result;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function makeProcessor({ cancelJob, stopContainer, containerName = (job) => jobContainerName(job.id), redis, getSettings, readModelRoles = () => ({}), hostBudget = null, abortGraceMs = ABORT_GRACE_MS, applyConcurrency = () => {}, pauseUntil = () => null, pauseWindowsNewer = () => false, triggersNewer = () => false, scopedLimits = () => [], projects = () => [], jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), hostBound = null, checkLease = null, scopeLease = null, endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels = () => null, endpointSetFor = mainModelEndpoints, deps, recordRun = () => {}, settledRecord = null, previousRecord = null, timeoutMs = JOB_TIMEOUT_MS, cancelPollMs = 2_000, cancelStopBoundMs = CANCEL_STOP_BOUND_MS, hostName = "", multiHost = false, now = () => Date.now(), waitState = makeWaitState({ redis, now }), afterMaxMs = () => WAIT_AFTER_MAX_DEFAULT_MS, checkSlots = makeInFlight(), checkSlotCount = () => 1, checkTimeoutMs = () => 10_000, concurrencyNow = () => 3, intervalMs = () => WAIT_INTERVAL_FLOOR_MS * 2, maxWaitMs = () => 24 * 3600 * 1000, maxChecks = () => 96, maxFaults = () => 5, random = Math.random, cpus = availableParallelism, runningJobs = makeRunningJobs(), valkeyTag = null }) {
|
|
232
292
|
// The lost-lock gate's rejection lines, said ONCE per job id, stall count and attempt. The gate runs before every
|
|
233
293
|
// deferral (pause window, wait, scope or endpoint busy), and BullMQ never resets a job's stall count, so a stalled
|
|
234
294
|
// scheduled job whose record is refused meets the gate again on every deferred pickup: a 30-minute scope-busy hold
|
|
235
295
|
// is about 360 of them. The verdict cannot change between them (same record, same attempt), so one line says it.
|
|
236
296
|
// Bounded, oldest out first, so a long-lived worker holds at most REJECTED_SEEN_MAX keys.
|
|
237
297
|
const rejectedSeen = new Set();
|
|
298
|
+
// Watch producer refusals finish before pickup and cannot carry a steering session.
|
|
299
|
+
const recordWatchOutcome = recordRun;
|
|
300
|
+
// A free boundary refusal happens before pickup creates a keeper or takes a host slot. Its existing inbox and
|
|
301
|
+
// held replies still need a final close before the record, just as every terminal path inside pickup does.
|
|
302
|
+
const settleSteerBeforePickup = async ({ job, result }) => {
|
|
303
|
+
let held = null;
|
|
304
|
+
if (job.data?.awaitInput === true && typeof deps?.settleHeld === "function") {
|
|
305
|
+
try { held = await deps.settleHeld({ job, result, afterTake: false }); } catch {}
|
|
306
|
+
}
|
|
307
|
+
if (job.data?.steer !== true) return;
|
|
308
|
+
const steered = await settleInbox({ redis, job, result, log: deps?.log ?? (() => {}), lateContext: { heldResumed: Number(held?.resumed) > 0 }, ...(typeof deps?.onLateForge === "function" ? { onLateForge: deps.onLateForge } : {}) });
|
|
309
|
+
if (steered) result.steered = steered;
|
|
310
|
+
};
|
|
311
|
+
const recordBeforePickup = makeBeforePickupRecorder({ projects, settleSteerBeforePickup, recordRun, now });
|
|
312
|
+
// THE ONE DEFERRAL (issue #737, `defer-write.mjs`): every gate below that puts the job back on the delayed set goes
|
|
313
|
+
// through `deferTo`, which records the gate's reason in `defer:job:<id>` (bounded, fail-open) and then makes the move
|
|
314
|
+
// and the `DelayedError` throw the gates always made; `noteRetry` records an infrastructure retry's backoff before its
|
|
315
|
+
// throw. Built once per processor, so its log episode (one line per outage, not per wake) spans every job it defers.
|
|
316
|
+
const { deferTo, noteRetry } = makeDeferTo({ redis, DelayedError, now, hostName, log: (event, fields) => deps?.log?.(event, fields) });
|
|
238
317
|
const firstRejection = (key) => {
|
|
239
318
|
if (rejectedSeen.has(key)) return false;
|
|
240
319
|
rejectedSeen.add(key);
|
|
@@ -266,51 +345,187 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
266
345
|
};
|
|
267
346
|
const record = await settledRecord(job.id, { attempt, since: job.timestamp, onReject });
|
|
268
347
|
if (record) {
|
|
348
|
+
// A record a NEWER pi-dispatch wrote (issue #761) settles the attempt by its stable core, as `recordVerdict`
|
|
349
|
+
// judged it, and is replayed by that core alone: its `reason`, `exitCode`, `turns`, `tokens` and
|
|
350
|
+
// `budgetReserved` may mean something this build does not know, and the completed listener pages and logs on
|
|
351
|
+
// them. So the job ends as the record's outcome with every other field null: `pagesAsPolicy` needs a reason
|
|
352
|
+
// it knows and so pages nobody, and the `job_completed` line names the outcome alone.
|
|
353
|
+
if (!recordReadable(record)) {
|
|
354
|
+
deps?.log?.("job_lost_lock_after_completion", { jobId: job.id, outcome: record.outcome });
|
|
355
|
+
return { outcome: record.outcome, reason: null, exitCode: null, turns: null, tokens: null, budgetReserved: null };
|
|
356
|
+
}
|
|
269
357
|
deps?.log?.("job_lost_lock_after_completion", { jobId: job.id, outcome: record.outcome, ...(record.reason ? { reason: record.reason } : {}) });
|
|
270
358
|
return { outcome: record.outcome, reason: record.reason ?? null, exitCode: record.exitCode ?? null, turns: record.turns ?? null, tokens: record.tokens ?? null, budgetReserved: record.budgetReserved ?? null };
|
|
271
359
|
}
|
|
272
360
|
}
|
|
273
361
|
|
|
362
|
+
// THE RUN'S INBOX AT ITS FINAL OUTCOME (issue #646, inbox-settle.mjs). A steer-armed job's inbox is closed, and what
|
|
363
|
+
// became of its messages counted, IMMEDIATELY BEFORE EVERY RECORD WRITE in this function, so the record's `steered`
|
|
364
|
+
// is the inbox's final word and a message can no longer be pushed into a run whose outcome is being written. ONE
|
|
365
|
+
// helper, called on the line before each `recordRun`/`recordAfterGate` (a bolt in steer-settle.test.mjs refuses a
|
|
366
|
+
// record site without it), which itself decides whether the outcome is final: an InfraRetry with attempts left and
|
|
367
|
+
// a #645 recovery handoff leave the inbox open for the next attempt. It decorates the result (or the error) with
|
|
368
|
+
// `steered` and the attempt's `steerSkipped`, which the record rebuilds; never throws, and a non-steer job costs
|
|
369
|
+
// nothing. `steerRan` is what this attempt's runContainer said about steering (null before any container).
|
|
370
|
+
let steerRan = null;
|
|
371
|
+
// Issue #712: this attempt's named live-transcript skip (runContainer's `liveSkipped`), null before any container.
|
|
372
|
+
// Carried onto the result here, at the one call every record site makes (the bolt above holds them to it), rather
|
|
373
|
+
// than at each of the processor's return statements, so no record site can be given it and another not.
|
|
374
|
+
let liveRan = null;
|
|
375
|
+
// Issue #712: the OpenShell venues' policy facts (runContainer's `openshell`, backend-openshell.mjs), carried the same
|
|
376
|
+
// way and for the same reason; the record rebuilds them (run-history.mjs `recordedOpenshell`), so nothing is trusted here.
|
|
377
|
+
let openshellRan = null;
|
|
378
|
+
let selectedEffective = null;
|
|
379
|
+
let providerFailureRan = null;
|
|
380
|
+
let modelRolesRan = null;
|
|
381
|
+
const carryLiveSkip = (target) => {
|
|
382
|
+
if (target === null || typeof target !== "object") return;
|
|
383
|
+
try {
|
|
384
|
+
if (liveRan !== null && target.liveSkipped === undefined) target.liveSkipped = liveRan;
|
|
385
|
+
if (openshellRan !== null && target.openshell === undefined) target.openshell = openshellRan;
|
|
386
|
+
if (selectedEffective?.modelRole) target.modelRole = selectedEffective.modelRole;
|
|
387
|
+
if (selectedEffective?.modelRole) { target.provider = selectedEffective.provider; target.model = selectedEffective.model; }
|
|
388
|
+
if (selectedEffective?.modelWhy) { target.modelWhy = selectedEffective.modelWhy; target.provider = selectedEffective.provider; target.model = selectedEffective.model; }
|
|
389
|
+
if (providerFailureRan) target.providerFailure = providerFailureRan;
|
|
390
|
+
if (modelRolesRan) target.modelRoles = modelRolesRan;
|
|
391
|
+
// Every attempt replaces the same record. Carry intervention fallback and uncertainty from the
|
|
392
|
+
// prior attempt, or a later cold start would erase the only proof of an earlier resumed answer.
|
|
393
|
+
// Only from a record this build can read (issue #761): a newer one's touches are outside its stable core and
|
|
394
|
+
// may mean something else, and this attempt's record would present them as this build's own. And only from
|
|
395
|
+
// this job's own previous attempt's record (`priorAttemptRecord` passed `recordOfThisJob` at the pickup's
|
|
396
|
+
// read, issue #811): an older job's interventions under a reused id are not this run's.
|
|
397
|
+
const prior = recordReadable(priorAttemptRecord) ? priorAttemptRecord : null;
|
|
398
|
+
if (prior?.touchLineageSkipped === "source-unavailable") target.touchLineageSkipped = "source-unavailable";
|
|
399
|
+
const touches = [...(Array.isArray(prior?.touches) ? prior.touches : []), ...(Array.isArray(target.touches) ? target.touches : [])].map(cleanTouch).filter(Boolean);
|
|
400
|
+
if (touches.length) target.touches = [...new Map(touches.map((event) => [event.id, event])).values()].sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)).slice(0, 16);
|
|
401
|
+
} catch {}
|
|
402
|
+
};
|
|
403
|
+
const settleSteer = async ({ result = null, error = null } = {}) => {
|
|
404
|
+
const held = await settleHeldReplies({ result, error });
|
|
405
|
+
carryLiveSkip(result ?? error);
|
|
406
|
+
if (job.data?.steer !== true) return;
|
|
407
|
+
// Issue #638: a run both steer-armed and awaitInput can end parked with a held reply AND a late steer message.
|
|
408
|
+
// Only one of them becomes the resumed job: the held reply, settled first, and the late messages are then
|
|
409
|
+
// answered (`heldResumed`), never a second job for the same parked run.
|
|
410
|
+
const steered = await settleInbox({ redis, job, result, error, log: deps?.log ?? (() => {}), lateContext: { heldResumed: Number(held?.resumed) > 0 || Number(heldTaken?.resumed) > 0 || heldTaken?.taken === "other" }, ...(typeof deps?.onLateForge === "function" ? { onLateForge: deps.onLateForge } : {}) });
|
|
411
|
+
const skipped = typeof steerRan?.steerSkipped === "string" ? steerRan.steerSkipped : null;
|
|
412
|
+
const target = result ?? error;
|
|
413
|
+
if (target === null || typeof target !== "object") return;
|
|
414
|
+
try {
|
|
415
|
+
if (steered) target.steered = steered;
|
|
416
|
+
if (skipped !== null) target.steerSkipped = skipped;
|
|
417
|
+
} catch {}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// HELD REPLIES AT THE SAME FINAL OUTCOME (issue #638, held-settle.mjs), called first by `settleSteer` so that every
|
|
421
|
+
// record site the bolt above holds to the steer settle settles an awaitInput run's held replies too: one call per
|
|
422
|
+
// site, so no site can be given one and not the other. At a final outcome the keeper stops BEFORE the close, so no
|
|
423
|
+
// refresh in flight can put the run back on its target after the close removed it. `deps.settleHeld` is start.mjs's
|
|
424
|
+
// `makeHeldSettle`; unwired, nothing is held and nothing is settled. Never throws.
|
|
425
|
+
let awaitKeeper = null;
|
|
426
|
+
// What the park's take started (issue #638 review, H1: the take runs INSIDE the park sequence, before this settle),
|
|
427
|
+
// kept so the steer settle can tell its late messages a held reply already became the resumed job.
|
|
428
|
+
let heldTaken = null;
|
|
429
|
+
// Whether the take ran at all: the backstop settle then only drops (a take whose close failed already answered).
|
|
430
|
+
let heldTakeRan = false;
|
|
431
|
+
const takeHeldReplies = async () => {
|
|
432
|
+
if (job.data?.awaitInput !== true || typeof deps?.takeHeld !== "function") return null;
|
|
433
|
+
heldTakeRan = true;
|
|
434
|
+
await awaitKeeper?.stop();
|
|
435
|
+
heldTaken = await deps.takeHeld({ job });
|
|
436
|
+
return heldTaken;
|
|
437
|
+
};
|
|
438
|
+
const settleHeldReplies = async ({ result = null, error = null } = {}) => {
|
|
439
|
+
if (job.data?.awaitInput !== true) return;
|
|
440
|
+
try {
|
|
441
|
+
if (!isFinalOutcome({ job, result, error })) return;
|
|
442
|
+
await awaitKeeper?.stop();
|
|
443
|
+
if (typeof deps?.settleHeld === "function") return await deps.settleHeld({ job, result, error, afterTake: heldTakeRan });
|
|
444
|
+
} catch {}
|
|
445
|
+
return null;
|
|
446
|
+
};
|
|
447
|
+
|
|
274
448
|
// THE EARLIER ATTEMPTS' SLOT TIME (issue #599): a retry, or a pickup after a stall, writes its record over the one
|
|
275
449
|
// before it (one file and one mirror key per job id), so that record is read NOW, before anything here can replace
|
|
276
450
|
// it, and its slot interval (with the ones it carried) rides every record this pickup writes (`earlier`). Only on
|
|
277
451
|
// such a pickup, so a first attempt reads nothing; bounded by the reader, and a fault reads as none.
|
|
452
|
+
// The record counts only when it is THIS job's previous attempt's (`recordOfThisJob`, the rule the lost-lock checks
|
|
453
|
+
// share; issue #811): ids are reused (a lost queue, a hand redelivery), and a record an older job left under the
|
|
454
|
+
// same id would hand this job that job's slot time (`earlier`), its interventions (the touches), and a
|
|
455
|
+
// never-started verdict the failover retry would start over from the primary on (`retryNeverStarted`). The
|
|
456
|
+
// previous attempt is `attemptsMade` after a failure, which BullMQ counts, or this pickup's own number,
|
|
457
|
+
// `attemptsMade + 1`, after a stall, which it does not (a scheduled job's stall puts it back to wait with only its
|
|
458
|
+
// `stalledCounter` raised). Both are kept, the higher winning, rather than one chosen from `stalledCounter`:
|
|
459
|
+
// BullMQ never lowers that count, so a job that stalled once and later failed would look for the stalled
|
|
460
|
+
// attempt's number, miss its own failed record, and a failover retry with no model attempt key would then be
|
|
461
|
+
// refused `model-failover-no-candidate` (a comment, a used-up delivery, a disarmed one-shot). What that leaves: a
|
|
462
|
+
// stall that wrote no record leaves the attempt before it to answer, which is still this job's own.
|
|
463
|
+
// Each side (this host's file, the fleet's copy) is held to the rule BEFORE the higher attempt is kept
|
|
464
|
+
// (`previousRecord`'s `keep`), so a stale record at a higher attempt cannot hide this job's own on the other side,
|
|
465
|
+
// and the answer is judged again after, so a reader that ignores `keep` cannot hand the stale record back. The count
|
|
466
|
+
// is read as `buildRecord` reads it for the record's own `attempt`, so the two cannot disagree.
|
|
467
|
+
const attemptsBefore = Number.isInteger(job.attemptsMade) && job.attemptsMade >= 0 ? job.attemptsMade : 0;
|
|
468
|
+
const ownPrior = (record) => [attemptsBefore, attemptsBefore + 1].some((attempt) => recordOfThisJob(record, { jobId: job.id, attempt, since: job.timestamp }));
|
|
278
469
|
let earlier = null;
|
|
470
|
+
let priorAttemptRecord = null;
|
|
279
471
|
if (typeof previousRecord === "function" && ((Number.isInteger(job.attemptsMade) && job.attemptsMade > 0) || Number(job.stalledCounter) > 0)) {
|
|
280
472
|
try {
|
|
281
|
-
|
|
473
|
+
const read = await previousRecord(job.id, { keep: ownPrior });
|
|
474
|
+
priorAttemptRecord = ownPrior(read) ? read : null;
|
|
475
|
+
earlier = earlierFrom(priorAttemptRecord);
|
|
282
476
|
} catch {
|
|
477
|
+
priorAttemptRecord = null;
|
|
283
478
|
earlier = null;
|
|
284
479
|
}
|
|
285
480
|
}
|
|
286
481
|
|
|
287
482
|
// Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): if this job's folder/repo is inside an active pause
|
|
288
483
|
// window, DEFER it to the window end via BullMQ's delayed set -- the job keeps its identity/dedup and
|
|
289
|
-
// auto-resumes when re-picked. This is FIRST, before the kill timer, the settings read, and
|
|
290
|
-
// reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
|
|
291
|
-
// (CONST-BUDGET-BEFORE-TOKENS). `
|
|
292
|
-
//
|
|
293
|
-
//
|
|
484
|
+
// auto-resumes when re-picked. This is FIRST of the deferral gates, before the kill timer, the settings read, and
|
|
485
|
+
// the budget reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
|
|
486
|
+
// (CONST-BUDGET-BEFORE-TOKENS). `deferTo` records the reason, then moves the job with the worker's `token` and
|
|
487
|
+
// throws the `DelayedError` by which BullMQ learns the job was deferred (worker.js recognises it) rather than
|
|
488
|
+
// completed or failed; the `> now + 1s` guard keeps a boundary tick from busy-deferring.
|
|
294
489
|
// One clock snapshot for both the window lookup and the guard, so they cannot disagree across the
|
|
295
490
|
// call (and a test can inject a fixed clock). `now` defaults to the real wall clock in production.
|
|
296
491
|
const nowMs = now();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
492
|
+
// The job's project (issue #499, INT-PROJECTS-FILE-CONTRACT), resolved ONCE here from one read of the projects ref
|
|
493
|
+
// (an in-memory ref, so the read is free), and for the limits snapshot's reason below: the gate, the ledger and the
|
|
494
|
+
// record agree for this attempt, whatever an operator does to projects.json mid-run. A retry or a deferral is a new
|
|
495
|
+
// pickup and resolves again. Above the pause gate since issue #737, so every deferral's record names it, the pause
|
|
496
|
+
// window's included. Every record from the never-fits check on carries it (the refusal there by hand, every one
|
|
497
|
+
// past the wait gate through `recordAfterGate`); the wait gate's refusals carry none and are resolved from the live
|
|
498
|
+
// ref (start.mjs). An id or null, never a name.
|
|
499
|
+
const pickupProjects = projects();
|
|
500
|
+
const grouping = job.data?.purpose === "zero-touch-grouping" ? job.data.questionGrouping : null;
|
|
501
|
+
const sourceProject = grouping?.project;
|
|
502
|
+
// The classifier has an empty internal workspace, so folder membership would bill it to "other".
|
|
503
|
+
// Its private runtime resolved the source's historical project; reject a deleted binding before any
|
|
504
|
+
// reserve instead of silently charging another project's allowance.
|
|
505
|
+
if (grouping && (sourceProject !== null && (!isProjectId(sourceProject) || !pickupProjects.some((row) => row.id === sourceProject)))) {
|
|
506
|
+
const result = { outcome: "policy", reason: "question-group-config-changed", budgetReserved: false };
|
|
507
|
+
await settleSteer({ result });
|
|
508
|
+
await recordRun({ job, result, startedAt: new Date(nowMs).toISOString(), endedAt: new Date(nowMs).toISOString(), project: null });
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
const project = grouping ? sourceProject : projectOf(job.data, pickupProjects);
|
|
512
|
+
// BullMQ marks this pickup active before entering here. The canary takes its exclusive
|
|
513
|
+
// lease before proving all active lists empty, so either side of that race sees the other.
|
|
514
|
+
if (await deps?.canaryBusy?.()) await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, "canary", { project });
|
|
515
|
+
// Issue #761: the pause windows file on disk was written by a newer pi-dispatch. The windows the gate below reads
|
|
516
|
+
// are the LAST GOOD ones, and the newer file may pause this very job (a field this build would drop narrows when
|
|
517
|
+
// a window applies), so judging it by them would run a job the operator paused. Every job waits for this host's
|
|
518
|
+
// upgrade or a readable file instead, right before the pause gate and so before every hold and spend.
|
|
519
|
+
if (pauseWindowsNewer() === true) {
|
|
520
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "the pause windows file", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
521
|
+
await deferTo(job, token, nowMs + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
|
|
301
522
|
}
|
|
523
|
+
const until = pauseUntil(job.data, nowMs);
|
|
524
|
+
if (until && until > nowMs + 1000) await deferTo(job, token, until, "pause-window", { project });
|
|
302
525
|
|
|
303
|
-
// The limits snapshot
|
|
304
|
-
//
|
|
526
|
+
// The limits snapshot and the size, read here, right after the pause gate and ABOVE the wait gate, so the
|
|
527
|
+
// never-fits check below can refuse a size before a job waits (issue #596, gate round 1 of phase 2).
|
|
305
528
|
const limits = scopedLimits();
|
|
306
|
-
// The job's project (issue #499, INT-PROJECTS-FILE-CONTRACT), resolved ONCE here from one read of the projects ref,
|
|
307
|
-
// beside the limits snapshot and for its reason: the gate, the ledger and the record agree for this attempt,
|
|
308
|
-
// whatever an operator does to projects.json mid-run. A retry or a deferral is a new pickup and resolves again.
|
|
309
|
-
// Every record from the never-fits check on carries it (the refusal there by hand, every one past the wait gate
|
|
310
|
-
// through `recordAfterGate`); the wait gate's refusals carry none and are resolved from the live ref (start.mjs).
|
|
311
|
-
// An id or null, never a name.
|
|
312
|
-
const pickupProjects = projects();
|
|
313
|
-
const project = projectOf(job.data, pickupProjects);
|
|
314
529
|
// THE JOB'S SIZE (issue #596, `job-size.mjs`), resolved ONCE here from the same limits snapshot and pickup project
|
|
315
530
|
// every gate below reads: the project row's memory and CPUs, else the deployment's PI_JOB_MEMORY and PI_JOB_CPUS
|
|
316
531
|
// (`jobSizeEnv`, validated at boot), else 4g and 2. It reaches the container as an ARGUMENT (runJob's `jobSize`,
|
|
@@ -353,12 +568,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
353
568
|
const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false, hostBudget: { memMiB: budgetNow.memMiB, cpuCenti: budgetNow.cpuCenti, hostShare: share } };
|
|
354
569
|
// Above the wait gate, so through the recorder's own arguments rather than the bound one below it: the
|
|
355
570
|
// same pickup project and size, by hand once.
|
|
571
|
+
await settleSteer({ result });
|
|
356
572
|
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), project, size, earlier });
|
|
357
573
|
return result;
|
|
358
574
|
}
|
|
359
575
|
deps?.log?.("job_size_never_fits_here_deferred", { jobId: job.id, project, misfit, delayMs: NEVER_FITS_RECHECK_MS, ...sizeFields });
|
|
360
|
-
await job
|
|
361
|
-
throw new DelayedError();
|
|
576
|
+
await deferTo(job, token, nowMs + NEVER_FITS_RECHECK_MS, "size-never-fits", { project });
|
|
362
577
|
}
|
|
363
578
|
}
|
|
364
579
|
|
|
@@ -395,6 +610,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
395
610
|
// resolver path or a vault topology -- `secret-profile-unknown` sets that rule.
|
|
396
611
|
if (sentence && deps?.comment) await Promise.resolve(deps.comment(job.data, sentence)).catch(() => {});
|
|
397
612
|
const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
613
|
+
await settleSteer({ result });
|
|
398
614
|
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), earlier });
|
|
399
615
|
return result;
|
|
400
616
|
};
|
|
@@ -464,14 +680,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
464
680
|
// for both; refusing would drop a delivery over a holder that may be gone. So decide
|
|
465
681
|
// nothing: re-defer briefly and ask again once the probe can answer.
|
|
466
682
|
deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
|
|
467
|
-
await job
|
|
468
|
-
throw new DelayedError();
|
|
683
|
+
await deferTo(job, token, nowMs + SUPERSEDE_RECHECK_MS, "wait-supersede", { project, holder: claim.holder });
|
|
469
684
|
}
|
|
470
685
|
if (claim.tookOverFrom) deps?.log?.("wait_lease_taken_over", { jobId: job.id, from: claim.tookOverFrom });
|
|
471
|
-
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: holdUntil });
|
|
686
|
+
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: holdUntil, afterMs: holdUntil });
|
|
472
687
|
deps?.log?.("wait_deferred", { jobId: job.id, until: new Date(holdUntil).toISOString(), label: waitLabel(job.data) });
|
|
473
|
-
await job
|
|
474
|
-
throw new DelayedError();
|
|
688
|
+
await deferTo(job, token, holdUntil, "wait-after", { project });
|
|
475
689
|
}
|
|
476
690
|
|
|
477
691
|
// TIER 2: the polled conditions. Last, because it is the only part of this gate that spawns a
|
|
@@ -497,7 +711,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
497
711
|
// the lease, so a job that never wins one has no `since`, never reaches the maximum, and
|
|
498
712
|
// re-wakes forever with no record and no bound. There is no deciding check to run first
|
|
499
713
|
// here -- that is the whole condition -- so the bound applies directly.
|
|
500
|
-
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs() });
|
|
714
|
+
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs(), afterMs: holdUntil });
|
|
501
715
|
if (held >= maxWaitMs()) {
|
|
502
716
|
return await refuseWait("wait-expired", "wait_expired", { reason: "max-wait-unchecked", denials, heldForMs: held }, `Gave up waiting: this deployment could not run the check often enough to answer within the maximum wait. Not run.`);
|
|
503
717
|
}
|
|
@@ -507,8 +721,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
507
721
|
const wait = Math.max(THROTTLE_FLOOR_MS, Math.floor(waitBackoffMs(intervalMs(), held) / 4));
|
|
508
722
|
const delay = wait + Math.floor(wait * 0.1 * random());
|
|
509
723
|
deps?.log?.("wait_check_throttled", { jobId: job.id, delayMs: delay, slots });
|
|
510
|
-
await job
|
|
511
|
-
throw new DelayedError();
|
|
724
|
+
await deferTo(job, token, nowMs + delay, "wait-throttled", { project });
|
|
512
725
|
}
|
|
513
726
|
|
|
514
727
|
// Declared outside the try below because the branches AFTER it read both.
|
|
@@ -537,12 +750,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
537
750
|
checkSlots.release(WAIT_CHECK_KEY);
|
|
538
751
|
const denials = await waitState.noteThrottle(job.id, { denied: true });
|
|
539
752
|
if (denials === THROTTLE_ALARM) deps?.log?.("wait_capacity_exceeded", { jobId: job.id, denials, slots: checkSlotCount(), where: "fleet", hint: "raise PI_WAIT_CHECK_SLOTS or PI_CONCURRENCY, lengthen PI_WAIT_INTERVAL_MS, or hold fewer jobs" });
|
|
540
|
-
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs() });
|
|
753
|
+
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs(), afterMs: holdUntil });
|
|
541
754
|
const wait = Math.max(THROTTLE_FLOOR_MS, Math.floor(waitBackoffMs(intervalMs(), held) / 4));
|
|
542
755
|
const delay = wait + Math.floor(wait * 0.1 * random());
|
|
543
756
|
deps?.log?.("wait_check_throttled", { jobId: job.id, delayMs: delay, slots: checkSlotCount(), where: "fleet" });
|
|
544
|
-
await job
|
|
545
|
-
throw new DelayedError();
|
|
757
|
+
await deferTo(job, token, nowMs + delay, "wait-throttled", { project });
|
|
546
758
|
}
|
|
547
759
|
}
|
|
548
760
|
// THE LEASE IS HELD FROM THE `tryAcquire` ABOVE, so every exit from here down must release it.
|
|
@@ -563,8 +775,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
563
775
|
}
|
|
564
776
|
if (claim.retry) {
|
|
565
777
|
deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
|
|
566
|
-
await job
|
|
567
|
-
throw new DelayedError();
|
|
778
|
+
await deferTo(job, token, nowMs + SUPERSEDE_RECHECK_MS, "wait-supersede", { project, holder: claim.holder });
|
|
568
779
|
}
|
|
569
780
|
|
|
570
781
|
await waitState.noteThrottle(job.id, { denied: false }); // granted: the run of denials ends here
|
|
@@ -574,6 +785,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
574
785
|
for (const profile of profiles) {
|
|
575
786
|
checked = profile;
|
|
576
787
|
verdict = await deps.checkWait(profile, targetFor(job.data?.kind, job.data), { signal });
|
|
788
|
+
// What this check said, for a console to show beside the hold (issue #737), in the gate's own words;
|
|
789
|
+
// nothing for a check that said nothing about the condition. Fail-open. Optional-called: an injected
|
|
790
|
+
// wait state written before the answers existed records none.
|
|
791
|
+
const answer = answerOf(verdict);
|
|
792
|
+
if (answer !== null) await waitState.noteAnswer?.(job.id, { profile, answer });
|
|
577
793
|
if (verdict?.profileUnknown || verdict?.verdict !== "go") break;
|
|
578
794
|
}
|
|
579
795
|
} finally {
|
|
@@ -602,8 +818,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
602
818
|
// fault, or a rolling deploy would spend a job's whole budget on its own restarts and then
|
|
603
819
|
// blame the operator's script for it.
|
|
604
820
|
deps?.log?.("wait_check_aborted", { jobId: job.id, profile: checked });
|
|
605
|
-
await job
|
|
606
|
-
throw new DelayedError();
|
|
821
|
+
await deferTo(job, token, nowMs + THROTTLE_FLOOR_MS, "wait-aborted", { project });
|
|
607
822
|
}
|
|
608
823
|
|
|
609
824
|
if (verdict?.verdict === "hold") {
|
|
@@ -644,10 +859,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
644
859
|
const jittered = base + Math.floor(base * 0.1 * random());
|
|
645
860
|
const remaining = Math.max(0, maxWaitMs() - held);
|
|
646
861
|
const delay = Math.max(1000, Math.min(jittered, remaining));
|
|
647
|
-
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + delay });
|
|
862
|
+
await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + delay, afterMs: holdUntil });
|
|
648
863
|
deps?.log?.("wait_deferred", { jobId: job.id, profile: checked, fault, heldForMs: held, delayMs: delay });
|
|
649
|
-
await job
|
|
650
|
-
throw new DelayedError();
|
|
864
|
+
await deferTo(job, token, nowMs + delay, "wait-hold", { project });
|
|
651
865
|
}
|
|
652
866
|
|
|
653
867
|
// FAIL CLOSED on anything that is not literally go. Everything above tests for a specific
|
|
@@ -661,8 +875,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
661
875
|
deps?.log?.("wait_check_unintelligible", { jobId: job.id, profile: checked });
|
|
662
876
|
await waitState.noteCheck(job.id, { fault: true });
|
|
663
877
|
const base = waitBackoffMs(intervalMs(), held);
|
|
664
|
-
await job
|
|
665
|
-
throw new DelayedError();
|
|
878
|
+
await deferTo(job, token, nowMs + base + Math.floor(base * 0.1 * random()), "wait-unclear", { project });
|
|
666
879
|
}
|
|
667
880
|
|
|
668
881
|
// Every profile answered go. Record the last check so the count bound sees it.
|
|
@@ -684,9 +897,10 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
684
897
|
// not burn re-check wakes) and after the wait gate (a job holding until tomorrow must not sit on a
|
|
685
898
|
// folder while it does), and STRICTLY above the `try` below, like the pause gate and for the same two
|
|
686
899
|
// reasons: a DelayedError thrown inside the try would be converted to UnrecoverableError by the
|
|
687
|
-
// catch, and a
|
|
688
|
-
//
|
|
689
|
-
//
|
|
900
|
+
// catch, and a move rejection here must escape RAW into BullMQ's normal failed-attempt handling
|
|
901
|
+
// exactly as the pause gate's does (inside the try it would become a permanent failure plus a failure
|
|
902
|
+
// record for what was a transient blip). `deferTo` rethrows a move's rejection unchanged after
|
|
903
|
+
// removing the reason it wrote for it, so that holds through it. The limits snapshot is read ONCE here and
|
|
690
904
|
// shared with `scopedLedgers` below, so the gate and the money ledger cannot disagree mid-job.
|
|
691
905
|
// tryAcquire is a synchronous check-and-increment -- no await between read and take, so Node's
|
|
692
906
|
// single thread makes it atomic at any concurrency -- and the local-folder limit is a structural 1
|
|
@@ -727,6 +941,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
727
941
|
// This pickup's entry in the running jobs (issue #599, phase 2), null until it is admitted below.
|
|
728
942
|
let runningKey = null;
|
|
729
943
|
let stopDidNotTake = false;
|
|
944
|
+
// Issue #644: the attempt's live publisher finish, handed over by runContainer's `onLive` (null until it is open).
|
|
945
|
+
let finishLive = null;
|
|
730
946
|
let name;
|
|
731
947
|
let venue;
|
|
732
948
|
// THE SCOPE HOLDS, in acquire order (issue #499 part B): the repo or folder slot, then the project slot. Each is
|
|
@@ -739,12 +955,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
739
955
|
};
|
|
740
956
|
// THE ENDPOINT HOLDS (issue #503), `{ id, fleet }` each, in id order.
|
|
741
957
|
const endpointHolds = [];
|
|
742
|
-
const releaseEndpointHolds = () => {
|
|
743
|
-
const taken = endpointHolds.splice(0).reverse();
|
|
958
|
+
const releaseEndpointHolds = (subset = null) => {
|
|
959
|
+
const taken = subset === null ? endpointHolds.splice(0).reverse() : subset.splice(0).reverse();
|
|
744
960
|
for (const hold of taken) endpointSlots.release(hold.id);
|
|
745
961
|
return Promise.all(taken.map((hold) => hold.fleet?.release?.()));
|
|
746
962
|
};
|
|
747
|
-
const releaseAllHolds = ({ orphan = false } = {}) => {
|
|
963
|
+
const releaseAllHolds = ({ orphan = false, candidateEndpoints = null } = {}) => {
|
|
964
|
+
if (candidateEndpoints !== null) return releaseEndpointHolds(candidateEndpoints);
|
|
748
965
|
// The running-jobs entry first (issue #599, phase 2): this pickup no longer runs the job, orphan or not.
|
|
749
966
|
if (runningKey !== null) {
|
|
750
967
|
runningJobs.remove(runningKey);
|
|
@@ -770,8 +987,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
770
987
|
if (hostBound && !hostBudget) {
|
|
771
988
|
if (!hostBound.slots.tryAcquire(HOST_SLOT_KEY, hostBound.limit())) {
|
|
772
989
|
deps?.log?.("host_busy_deferred", { jobId: job.id, delayMs: SCOPE_BUSY_RECHECK_MS });
|
|
773
|
-
await job
|
|
774
|
-
throw new DelayedError();
|
|
990
|
+
await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, "host-slot", { project });
|
|
775
991
|
}
|
|
776
992
|
hostHeld = true;
|
|
777
993
|
}
|
|
@@ -800,29 +1016,33 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
800
1016
|
// `capacity` (issue #599) rides the same binding, and is null until the job is admitted below (where `startedAt` is
|
|
801
1017
|
// set): every record of a refusal before that point says, by its null, that the job never held a slot.
|
|
802
1018
|
let capacity = null;
|
|
803
|
-
|
|
1019
|
+
let pickupMaxTurns = null;
|
|
1020
|
+
const recordAfterGate = (args) => recordRun({ ...args, project, size, capacity, earlier, maxTurns: pickupMaxTurns });
|
|
804
1021
|
|
|
805
1022
|
// The MATCHED ROW's scope keys both the in-process slot and the fleet lease (issue #498), the same string
|
|
806
1023
|
// `budgetCapsFor` hashes below and the boot sweeper hashes from the file: a qualified `github:acme/web` row holds
|
|
807
1024
|
// GitHub jobs only, a bare `acme/web` row holds every forge's under the key it always had. With no row it is the
|
|
808
1025
|
// job's canonical scope, so the folder mutex is keyed exactly as before.
|
|
809
1026
|
const scope = rowScopeFor(job.data, limits);
|
|
810
|
-
// Give every hold back (scope and host), then defer. Every scope-gate deferral goes through here
|
|
811
|
-
|
|
1027
|
+
// Give every hold back (scope and host), then defer. Every scope-gate deferral goes through here, with the reason
|
|
1028
|
+
// its caller names (issue #737); `chained` names the parent as its holder, the run whose scope it waits for.
|
|
1029
|
+
const deferScope = async (why, fields) => {
|
|
812
1030
|
// Every hold goes back before we defer, the host slot too: `makeInFlight().release` is not idempotent, so a
|
|
813
1031
|
// slot held across a deferral would be a slot this machine never gets back.
|
|
814
1032
|
await releaseAllHolds();
|
|
815
1033
|
deps?.log?.(fields.event, { jobId: job.id, kind: job.data?.kind === "local" ? "local" : "forge", delayMs: SCOPE_BUSY_RECHECK_MS, ...fields.extra });
|
|
816
|
-
await job
|
|
817
|
-
throw new DelayedError();
|
|
1034
|
+
await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, why, { project, ...(why === "chained" ? { holder: job.data.parentJobId } : {}) });
|
|
818
1035
|
};
|
|
1036
|
+
// The repo or folder slot's reason. A chained child (its parent enqueued it before the parent's finally gave the
|
|
1037
|
+
// folder back) waits on that parent's run; any other job on the scope's own limit, by its kind.
|
|
1038
|
+
const scopeWhy = typeof job.data?.parentJobId === "string" ? "chained" : job.data?.kind === "local" ? "scope-folder" : "scope-repo";
|
|
819
1039
|
if (scope) {
|
|
820
1040
|
const ceiling = concurrencyFor(job.data, limits);
|
|
821
1041
|
if (!inFlight.tryAcquire(scope, ceiling)) {
|
|
822
1042
|
// Optional-chained: makeProcessor gives `deps` no default and bare wirings pass deps: {}.
|
|
823
1043
|
// The scope itself stays out of the log line (no-pii-in-logs -- a local scope is a full
|
|
824
1044
|
// host path); the delayed count and the job id are what an operator needs to see it.
|
|
825
|
-
await deferScope({ event: "scope_busy_deferred" });
|
|
1045
|
+
await deferScope(scopeWhy, { event: "scope_busy_deferred" });
|
|
826
1046
|
}
|
|
827
1047
|
const repoHold = { key: scope, fleet: null };
|
|
828
1048
|
scopeHolds.push(repoHold);
|
|
@@ -843,7 +1063,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
843
1063
|
// matching row, so a deployment with no scoped-limits file issues no command at all.
|
|
844
1064
|
if (scopeLease && job.data?.kind !== "local" && Number.isFinite(ceiling)) {
|
|
845
1065
|
repoHold.fleet = await scopeLease.acquire(job.id, { slots: ceiling, keyArgs: [hash16(scope)] });
|
|
846
|
-
if (!repoHold.fleet) await deferScope({ event: "scope_busy_deferred", extra: { where: "fleet" } });
|
|
1066
|
+
if (!repoHold.fleet) await deferScope(scopeWhy, { event: "scope_busy_deferred", extra: { where: "fleet" } });
|
|
847
1067
|
}
|
|
848
1068
|
}
|
|
849
1069
|
|
|
@@ -857,13 +1077,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
857
1077
|
const projectRow = projectRowFor(limits, project);
|
|
858
1078
|
if (projectRow && Number.isSafeInteger(projectRow.concurrent)) {
|
|
859
1079
|
if (!inFlight.tryAcquire(projectRow.scope, projectRow.concurrent)) {
|
|
860
|
-
await deferScope({ event: "scope_busy_deferred", extra: { ledger: "project" } });
|
|
1080
|
+
await deferScope("scope-project", { event: "scope_busy_deferred", extra: { ledger: "project" } });
|
|
861
1081
|
}
|
|
862
1082
|
const projectHold = { key: projectRow.scope, fleet: null };
|
|
863
1083
|
scopeHolds.push(projectHold);
|
|
864
1084
|
if (scopeLease) {
|
|
865
1085
|
projectHold.fleet = await scopeLease.acquire(job.id, { slots: projectRow.concurrent, keyArgs: [hash16(projectRow.scope)] });
|
|
866
|
-
if (!projectHold.fleet) await deferScope({ event: "scope_busy_deferred", extra: { ledger: "project", where: "fleet" } });
|
|
1086
|
+
if (!projectHold.fleet) await deferScope("scope-project", { event: "scope_busy_deferred", extra: { ledger: "project", where: "fleet" } });
|
|
867
1087
|
}
|
|
868
1088
|
}
|
|
869
1089
|
|
|
@@ -881,6 +1101,145 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
881
1101
|
settingsThrew = true;
|
|
882
1102
|
settingsError = error;
|
|
883
1103
|
}
|
|
1104
|
+
// Issue #761: an overlay written by a newer pi-dispatch is not a broken file, it is one this build cannot read. The
|
|
1105
|
+
// settings-overlay-invalid refusal below is for a broken file; this job instead waits for this host's upgrade (or a
|
|
1106
|
+
// readable file), deferred `newer-release`: a refusal would use up a forge delivery and spend a one-shot over a file
|
|
1107
|
+
// nobody got wrong. HERE, right after the read and above every gate that reads the settings, and outside the main
|
|
1108
|
+
// `try` (a DelayedError inside it would become an UnrecoverableError), every hold given back first, as each gate's
|
|
1109
|
+
// deferral does. Nothing is settled, recorded, minted or reserved. The processor's head asks the same question
|
|
1110
|
+
// before any branch runs, so a file that was newer then never gets here; this one covers a file that turned newer
|
|
1111
|
+
// between that read and this one.
|
|
1112
|
+
if (!settingsThrew && settings?.invalid && settings.newer === true) {
|
|
1113
|
+
await releaseAllHolds();
|
|
1114
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "the settings file", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
1115
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Failover selection precedes endpoint leases and every paid gate. Capture failures here and report them
|
|
1119
|
+
// inside the ordinary record/release try below; no early throw may strand the scope slots already held.
|
|
1120
|
+
let selectionError = null;
|
|
1121
|
+
let selectionRefused = null;
|
|
1122
|
+
let selectionWait = null;
|
|
1123
|
+
let failoverSnapshot = null;
|
|
1124
|
+
let chosenModel = null;
|
|
1125
|
+
// Set by the selection when a retry can be judged only by a record a newer pi-dispatch wrote (issue #761): the job
|
|
1126
|
+
// then waits below, outside this block's `try`, instead of being refused.
|
|
1127
|
+
let priorRecordNewer = false;
|
|
1128
|
+
const modelAttemptId = modelAttemptIdOf(job.queueName ?? QUEUE, job.id);
|
|
1129
|
+
if (!settingsThrew && !settings?.invalid) {
|
|
1130
|
+
try {
|
|
1131
|
+
let effective = resolveModelRoles(effectiveJobOf(job.data, settings, deps?.allowedModels ?? null, (event, fields) => deps?.log?.(event, { jobId: job.id, ...fields })), job.data.watch || job.data.answerDraft ? {} : await readModelRoles(), project, { authoredProvider: job.data.provider });
|
|
1132
|
+
selectedEffective = effective;
|
|
1133
|
+
if (effective.fallbackModels !== undefined || (effective.recover === true && effective.recoveryOf)) {
|
|
1134
|
+
const endpoints = modelEndpoints?.() ?? [];
|
|
1135
|
+
let models;
|
|
1136
|
+
try { models = overlayModels(); }
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
if (error?.piDispatchConfig || error?.overlayLink || error?.overlayNotAFile || (typeof error?.code === "string" && !isTransientOverlayRead(error.code))) {
|
|
1139
|
+
error.modelSelectionRefused = true;
|
|
1140
|
+
}
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
failoverSnapshot = { endpoints, models, set: [] };
|
|
1144
|
+
let recoveryModel = null;
|
|
1145
|
+
if (effective.recover === true && effective.recoveryOf) {
|
|
1146
|
+
if (!deps?.workspaceCheckpointer) { const error = new Error("the recovery store is absent"); error.modelSelectionReason = "recovery-checkpoint-absent"; throw error; }
|
|
1147
|
+
const read = await deps.workspaceCheckpointer.readManifest(effective.recoveryOf);
|
|
1148
|
+
if (read?.ok && read.manifest?.provider && read.manifest?.model) recoveryModel = `${read.manifest.provider}/${read.manifest.model}`;
|
|
1149
|
+
// Issue #761: a checkpoint a newer pi-dispatch wrote is not a missing one. The recovery waits for a worker
|
|
1150
|
+
// that reads it (`newer-release`, deferred below as the processor's own pickup would), never refused here,
|
|
1151
|
+
// where the refusal would use up its one attempt over a checkpoint nothing is wrong with.
|
|
1152
|
+
else if (read?.newer === true) throw new NewerReleaseHold("the run's checkpoint");
|
|
1153
|
+
// A missing checkpoint is refused by runJob's existing free gate, never replaced by a fresh model.
|
|
1154
|
+
else { const error = new Error("the recovery model is absent"); error.modelSelectionReason = "recovery-checkpoint-absent"; throw error; }
|
|
1155
|
+
const ref = splitModelEntry(recoveryModel);
|
|
1156
|
+
if (!ref) { const error = new Error("the recovery model is invalid"); error.modelSelectionReason = "recovery-checkpoint-absent"; throw error; }
|
|
1157
|
+
effective = { ...effective, provider: ref.provider, model: ref.model };
|
|
1158
|
+
}
|
|
1159
|
+
const retry = Number(job.attemptsMade) > 0 || Number(job.stalledCounter) > 0;
|
|
1160
|
+
// Issue #761: a retry whose previous attempt's record a newer pi-dispatch wrote, with no model attempt key to
|
|
1161
|
+
// lock to. That record's `reason`, `budgetReserved` and `exitCode` are outside its stable core, so it cannot
|
|
1162
|
+
// say the attempt never started a container, and the selection would refuse the retry
|
|
1163
|
+
// (`model-failover-no-candidate`) rather than start over from the primary. That refusal comments, records,
|
|
1164
|
+
// uses up a forge delivery and disarms a one-shot over work nothing is wrong with; the job waits instead, as
|
|
1165
|
+
// it does for every store this build cannot read, until a host that reads the record takes it or a later
|
|
1166
|
+
// record replaces it. Exactly that case and no other: a model attempt key keeps the lock to its model, and a
|
|
1167
|
+
// record this build can read keeps today's answers. The key is read here only in that case (the selection
|
|
1168
|
+
// reads it again when it is there), and a throw is the selection's own read failing, handled below as such.
|
|
1169
|
+
// The record is the pickup's (`priorAttemptRecord`), which counts only when it is this job's previous
|
|
1170
|
+
// attempt's (`recordOfThisJob`, `ownPrior` at the pickup's read): one an older job left under a reused id, or an
|
|
1171
|
+
// earlier attempt's, says nothing about the attempt being retried, so the retry is judged as one with no
|
|
1172
|
+
// record, never held by it. That read held each side to the rule before the higher attempt was kept, so a
|
|
1173
|
+
// stale record at a higher attempt on one side hides nothing on the other, and one read serves the hold,
|
|
1174
|
+
// `retryNeverStarted` below, `earlier` and the touches alike (issue #811).
|
|
1175
|
+
const waitMayApply = effective.fallbackModels !== undefined && retry && !recoveryModel;
|
|
1176
|
+
priorRecordNewer = waitMayApply && priorAttemptRecord !== null && !recordReadable(priorAttemptRecord) && (await readModelAttempt(redis, modelAttemptId)) === null;
|
|
1177
|
+
const selection = priorRecordNewer ? {} : effective.fallbackModels === undefined ? { job: effective } : await selectFailoverJob(effective, {
|
|
1178
|
+
redis, id: modelAttemptId, retry,
|
|
1179
|
+
// Read from a record this build can read only (issue #761): a newer one's `reason`, `budgetReserved` and
|
|
1180
|
+
// `exitCode` are outside its stable core. Such a record is no usable prior record, so the retry goes by the
|
|
1181
|
+
// model attempt key alone, locked to the model it names (with no key the job waited above), never started
|
|
1182
|
+
// over from the primary on a record this build cannot vouch for. Nor on another job's (issue #811): the
|
|
1183
|
+
// record is this job's previous attempt's or none, so an older job's `container-never-started` under a
|
|
1184
|
+
// reused id cannot start this retry over from the primary after an attempt that may have spent.
|
|
1185
|
+
recoveryModel, retryNeverStarted: recordReadable(priorAttemptRecord) && priorAttemptRecord.reason === "container-never-started" && priorAttemptRecord.budgetReserved === false && priorAttemptRecord.exitCode === null, models, limits, now, log: deps?.log,
|
|
1186
|
+
reservationFor: (candidate) => zeroRatedFor(candidate, { modelEndpoints: failoverSnapshot, builtinModel: deps?.builtinModel }).zeroRated ? 0 : candidate.maxCostMicros,
|
|
1187
|
+
subscriptionCheck: deps?.subscriptionRuntime ? (candidate) => deps.subscriptionRuntime.check(candidate) : null,
|
|
1188
|
+
checkCandidate: (candidate) => deps?.checkFailoverCandidate ? deps.checkFailoverCandidate(candidate, { models, endpoints }) : checkFailoverCandidate(candidate, { models, endpoints, credential: deps?.checkProviderCredential, lookup: deps?.builtinModel }),
|
|
1189
|
+
acquireEndpoints: async (candidate) => {
|
|
1190
|
+
const set = [...new Map(endpointSetFor({ models, job: candidate, endpoints }).map((endpoint) => [endpoint.id, endpoint])).values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
1191
|
+
const held = [];
|
|
1192
|
+
for (const endpoint of set) {
|
|
1193
|
+
if (!endpointSlots.tryAcquire(endpoint.id, endpoint.slots)) {
|
|
1194
|
+
await releaseAllHolds({ candidateEndpoints: held });
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
let fleet = null;
|
|
1198
|
+
try { if (endpointLease && !isPerMachineHost(endpoint.host)) fleet = await endpointLease.acquire(job.id, { slots: endpoint.slots, keyArgs: [hash16(endpoint.id)] }); }
|
|
1199
|
+
catch (error) { held.push({ id: endpoint.id, fleet: null }); await releaseAllHolds({ candidateEndpoints: held }); throw error; }
|
|
1200
|
+
held.push({ id: endpoint.id, fleet });
|
|
1201
|
+
if (endpointLease && !isPerMachineHost(endpoint.host) && !fleet) {
|
|
1202
|
+
await releaseAllHolds({ candidateEndpoints: held });
|
|
1203
|
+
return false;
|
|
1204
|
+
}
|
|
1205
|
+
if (fleet?.degraded) deps?.log?.("endpoint_lease_degraded", { jobId: job.id, endpoint: endpoint.id });
|
|
1206
|
+
}
|
|
1207
|
+
endpointHolds.push(...held);
|
|
1208
|
+
failoverSnapshot.set = set;
|
|
1209
|
+
return true;
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
selectedEffective = selection.job ?? null;
|
|
1213
|
+
chosenModel = selection.ref ?? null;
|
|
1214
|
+
if (chosenModel) await writeModelAttempt(redis, modelAttemptId, { ref: chosenModel, modelWhy: selectedEffective.modelWhy });
|
|
1215
|
+
selectionRefused = selection.refused ?? null;
|
|
1216
|
+
selectionWait = selection.wait ? selection : null;
|
|
1217
|
+
}
|
|
1218
|
+
if (selectedEffective && !selectionRefused && !selectionWait && deps?.subscriptionRuntime) {
|
|
1219
|
+
const subscription = await deps.subscriptionRuntime.check(selectedEffective);
|
|
1220
|
+
if (!subscription.ok) {
|
|
1221
|
+
if (subscription.unavailable) throw new Error("subscription state unavailable");
|
|
1222
|
+
if (subscription.wait) selectionWait = subscription;
|
|
1223
|
+
else selectionRefused = subscription.reason;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
if (error?.modelSelectionReason) selectionRefused = error.modelSelectionReason;
|
|
1228
|
+
else if (error?.modelSelectionRefused) selectionRefused = "model-failover-no-candidate";
|
|
1229
|
+
else selectionError = error instanceof InfraRetry ? error : new InfraRetry("the model candidates could not be read", { cause: error, reason: "container-never-started", budgetReserved: false });
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// The selection's two deferrals, through one exit that gives every hold back first: a retry whose previous record a
|
|
1233
|
+
// newer pi-dispatch wrote (issue #761, above; the selection did not run, so it named no wait), or a wait the
|
|
1234
|
+
// selection or the subscription check named.
|
|
1235
|
+
if (priorRecordNewer || selectionWait) {
|
|
1236
|
+
await releaseAllHolds();
|
|
1237
|
+
if (priorRecordNewer) {
|
|
1238
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "the previous attempt's run record", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
1239
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
|
|
1240
|
+
}
|
|
1241
|
+
await deferTo(job, token, selectionWait.until, selectionWait.wait, { project });
|
|
1242
|
+
}
|
|
884
1243
|
|
|
885
1244
|
// THE MODEL ENDPOINT GATE (issue #503, INT-MODEL-ENDPOINTS-FILE-CONTRACT). A declared local model server
|
|
886
1245
|
// has a fixed number of parallel slots, and nothing else bounds how many jobs pile onto it: three per host
|
|
@@ -909,8 +1268,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
909
1268
|
// One snapshot per pickup (the endpoints, the overlay models, the derived set), handed to runJob as
|
|
910
1269
|
// `modelEndpoints` so a later gate reads the same declaration this one leased against. With no endpoints
|
|
911
1270
|
// declared the overlay is not even read, and the job touches nothing new: no command, no key, no field.
|
|
912
|
-
let endpointSnapshot =
|
|
913
|
-
if (modelEndpoints && !settingsThrew && !settings?.invalid) {
|
|
1271
|
+
let endpointSnapshot = failoverSnapshot;
|
|
1272
|
+
if (failoverSnapshot === null && !selectionError && !selectionRefused && modelEndpoints && !settingsThrew && !settings?.invalid) {
|
|
914
1273
|
let endpoints = [];
|
|
915
1274
|
let models = null;
|
|
916
1275
|
let set = [];
|
|
@@ -941,7 +1300,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
941
1300
|
// ONE hold per endpoint id: a set naming an endpoint twice would take its slot and then wait on itself,
|
|
942
1301
|
// forever on `slots: 1`. Deduplicated before the sort, so the order rule sees each endpoint once.
|
|
943
1302
|
const byId = new Map();
|
|
944
|
-
for (const e of endpointSetFor({ models, job: effectiveJobOf(job.data, settings, deps?.allowedModels ?? null), endpoints })) if (!byId.has(e.id)) byId.set(e.id, e);
|
|
1303
|
+
for (const e of endpointSetFor({ models, job: selectedEffective ?? effectiveJobOf(job.data, settings, deps?.allowedModels ?? null), endpoints })) if (!byId.has(e.id)) byId.set(e.id, e);
|
|
945
1304
|
set = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
946
1305
|
}
|
|
947
1306
|
} catch (err) {
|
|
@@ -971,8 +1330,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
971
1330
|
if (where !== null) {
|
|
972
1331
|
await releaseAllHolds();
|
|
973
1332
|
deps?.log?.("endpoint_busy_deferred", { jobId: job.id, endpoint: endpoint.id, where, delayMs: ENDPOINT_BUSY_RECHECK_MS });
|
|
974
|
-
await job
|
|
975
|
-
throw new DelayedError();
|
|
1333
|
+
await deferTo(job, token, nowMs + ENDPOINT_BUSY_RECHECK_MS, "endpoint", { project, endpoint: endpoint.id });
|
|
976
1334
|
}
|
|
977
1335
|
endpointHolds.push({ id: endpoint.id, fleet });
|
|
978
1336
|
}
|
|
@@ -1005,14 +1363,14 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1005
1363
|
await releaseAllHolds();
|
|
1006
1364
|
budgetState.budgetDeferred = true;
|
|
1007
1365
|
deps?.log?.("host_budget_deferred", { jobId: job.id, project, why: verdict.why, rank: verdict.rank, memMiB: size.memMiB, cpuCenti: size.cpuCenti, delayMs: BUDGET_RECHECK_MS });
|
|
1008
|
-
await job
|
|
1009
|
-
throw new DelayedError();
|
|
1366
|
+
await deferTo(job, token, nowMs + BUDGET_RECHECK_MS, "host-budget", { project, budgetWhy: verdict.why });
|
|
1010
1367
|
}
|
|
1011
1368
|
budgetHeld = true;
|
|
1012
1369
|
}
|
|
1013
1370
|
|
|
1014
1371
|
let startedAt;
|
|
1015
1372
|
let timer;
|
|
1373
|
+
let subscriptionDeadlineAt = null;
|
|
1016
1374
|
let cancelPoll;
|
|
1017
1375
|
let cancelPolling = false;
|
|
1018
1376
|
// The poll tick in flight, KEPT (gate round 2 of PR #479), so the error handler can stop the poll and wait for it
|
|
@@ -1069,7 +1427,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1069
1427
|
capacity = capacityNow();
|
|
1070
1428
|
// This host runs the job from here on (issue #599, phase 2): its entry, at the same instant as `startedAt`, given
|
|
1071
1429
|
// back by `releaseAllHolds` on every exit. `add` is total, so this block still cannot throw.
|
|
1072
|
-
runningKey = runningJobs.add({ id: job.id, project, memMiB: size.memMiB, cpuCenti: size.cpuCenti, at: Date.parse(startedAt) });
|
|
1430
|
+
runningKey = runningJobs.add({ id: job.id, project, memMiB: size.memMiB, cpuCenti: size.cpuCenti, at: Date.parse(startedAt), holdMicros: 0, holdAt: Date.parse(startedAt) });
|
|
1073
1431
|
// The producer of the name both boot reapers sweep by substring. Built from the shared prefix
|
|
1074
1432
|
// rather than typed here, so a rename cannot land in the producer and not in the sweeps (#227).
|
|
1075
1433
|
// From the VENUE that will build the container, not from the local adapter reached for directly:
|
|
@@ -1105,6 +1463,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1105
1463
|
if (err?.code !== BACKEND_NOT_REGISTERED) throw err;
|
|
1106
1464
|
name = null;
|
|
1107
1465
|
}
|
|
1466
|
+
subscriptionDeadlineAt = now() + timeoutMs;
|
|
1108
1467
|
timer = setTimeout(() => {
|
|
1109
1468
|
// BullMQ has no per-job kill timer; this is ours. cancelJob raises the AbortSignal.
|
|
1110
1469
|
Promise.resolve(cancelJob(job.id, "job-timeout-30m")).catch(() => {});
|
|
@@ -1146,7 +1505,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1146
1505
|
cancelPolling = true;
|
|
1147
1506
|
cancelTick = (async () => {
|
|
1148
1507
|
try {
|
|
1149
|
-
const
|
|
1508
|
+
const humanReq = await redis.get(cancelReqKey(job.id));
|
|
1509
|
+
const agentReq = humanReq == null ? await redis.get(agentCancelReqKey(job.id)) : null;
|
|
1510
|
+
const req = humanReq ?? agentReq;
|
|
1150
1511
|
if (req === null || req === undefined) return;
|
|
1151
1512
|
// Stopped while this read was in flight: the outcome is being decided, so this request is left
|
|
1152
1513
|
// unanswered (its requester's timeout re-reads the job), never acknowledged after the fact.
|
|
@@ -1154,12 +1515,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1154
1515
|
// Bound to this worker (the injection comment in createWorker): `false` means the job
|
|
1155
1516
|
// left the tracked map in this same instant, in which case the finally below clears
|
|
1156
1517
|
// this interval anyway and the request's TTL reaps the key.
|
|
1157
|
-
const took = await Promise.resolve(cancelJob(job.id, "operator-cancel")).catch(() => false);
|
|
1518
|
+
const took = await Promise.resolve(cancelJob(job.id, agentReq === null ? "operator-cancel" : "agent-cancel")).catch(() => false);
|
|
1158
1519
|
if (took === false) return;
|
|
1159
1520
|
// Ack first, then consume the request: losing the DEL to a blip costs one redundant
|
|
1160
1521
|
// re-read, losing the ack costs the operator a false "nobody answered".
|
|
1161
|
-
await redis.set(cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS).catch(() => {});
|
|
1162
|
-
await redis.del(cancelReqKey(job.id)).catch(() => {});
|
|
1522
|
+
await redis.set(agentReq === null ? cancelAckKey(job.id) : agentCancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS).catch(() => {});
|
|
1523
|
+
await redis.del(agentReq === null ? cancelReqKey(job.id) : agentCancelReqKey(job.id)).catch(() => {});
|
|
1163
1524
|
clearInterval(cancelPoll);
|
|
1164
1525
|
} catch {
|
|
1165
1526
|
// Fail open: the next tick re-asks.
|
|
@@ -1180,9 +1541,27 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1180
1541
|
throw error;
|
|
1181
1542
|
}
|
|
1182
1543
|
|
|
1544
|
+
// HELD REPLIES (issue #638, DES-AWAIT-INPUT-ENDS-THE-JOB): an awaitInput run is live on its await target from here,
|
|
1545
|
+
// after the pause, size, wait, scope, endpoint and host-slot gates have admitted it and right before the settings
|
|
1546
|
+
// overlay check and `runJob` (so before the mint and the container), until the park's take or the settle stop it,
|
|
1547
|
+
// so a reply posted between the agent's question and the park is held instead of being deduplicated against this
|
|
1548
|
+
// run or dropped for the missing marker. Only with a wired settle (unwired, a held reply would never be answered)
|
|
1549
|
+
// and a Valkey client that can hold one. Never throws.
|
|
1550
|
+
if (job.data?.awaitInput === true && typeof deps?.settleHeld === "function" && typeof redis?.multi === "function" && typeof redis?.eval === "function") {
|
|
1551
|
+
awaitKeeper = makeAwaitKeeper({ redis, job, host: hostName || "worker", now, log: deps?.log ?? (() => {}) });
|
|
1552
|
+
await awaitKeeper.start().catch(() => {});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1183
1555
|
try {
|
|
1184
1556
|
// The read itself happened once, above the endpoint gate; its throw lands HERE, where it always did.
|
|
1185
1557
|
if (settingsThrew) throw settingsError;
|
|
1558
|
+
if (selectionError) throw selectionError;
|
|
1559
|
+
if (selectionRefused) {
|
|
1560
|
+
const result = { outcome: "policy", reason: selectionRefused, exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
1561
|
+
await settleSteer({ result });
|
|
1562
|
+
recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
1563
|
+
return result;
|
|
1564
|
+
}
|
|
1186
1565
|
if (settings.invalid) {
|
|
1187
1566
|
// A present-but-invalid overlay is a POLICY refusal, RETURNED (never thrown) so BullMQ marks the
|
|
1188
1567
|
// job completed and does not retry a file that can never parse (CONST-RETRY-INFRA-ONLY). Resolved
|
|
@@ -1192,6 +1571,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1192
1571
|
// refusals are the others): each is decided before or during the settings read, so no honest effective
|
|
1193
1572
|
// value exists yet -- buildRecord defaults both null.
|
|
1194
1573
|
const result = { outcome: "policy", reason: "settings-overlay-invalid", exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
1574
|
+
await settleSteer({ result });
|
|
1195
1575
|
recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
1196
1576
|
return result;
|
|
1197
1577
|
}
|
|
@@ -1208,7 +1588,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1208
1588
|
// values reserveBudget checks, never when it runs.
|
|
1209
1589
|
// The one call that logs a malformed queued cap (#501): the endpoint gate above computes the same job and
|
|
1210
1590
|
// stays quiet, so it is reported once per pickup.
|
|
1211
|
-
const effectiveJob = effectiveJobOf(job.data, settings, deps?.allowedModels ?? null, (event, fields) => deps?.log?.(event, { jobId: job.id, ...fields }));
|
|
1591
|
+
const effectiveJob = selectedEffective ?? effectiveJobOf(job.data, settings, deps?.allowedModels ?? null, (event, fields) => deps?.log?.(event, { jobId: job.id, ...fields }));
|
|
1592
|
+
pickupMaxTurns = effectiveJob.maxTurns;
|
|
1212
1593
|
|
|
1213
1594
|
// THE ENVELOPE (issue #504 part B, DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE). With one loaded, every job is governed:
|
|
1214
1595
|
// its project's, or `_other`'s, share of the applied split narrows the dollar ledgers below, read ONCE here
|
|
@@ -1236,7 +1617,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1236
1617
|
}
|
|
1237
1618
|
|
|
1238
1619
|
const result = await runJob(effectiveJob, {
|
|
1620
|
+
queueJobId: String(job.id),
|
|
1621
|
+
deploymentMaxCostUsd: settings.maxCostUsd,
|
|
1622
|
+
rawApprovalJob: job.data,
|
|
1239
1623
|
redis,
|
|
1624
|
+
// Issue #761: the tag of the Valkey the dollar counters are in, for the record's `dollars.reservedIn`.
|
|
1625
|
+
valkeyTag,
|
|
1240
1626
|
// The three spend windows (week/month null when disabled) and the soft-hold band, resolved this
|
|
1241
1627
|
// job-start under overlay > env. reserveBudget checks them before the container.
|
|
1242
1628
|
caps: { day: settings.dailyCap, week: settings.weeklyCap, month: settings.monthlyCap },
|
|
@@ -1262,6 +1648,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1262
1648
|
// folder it RESOLVES. The processor asks which project the resolved folder belongs to, from the same projects
|
|
1263
1649
|
// snapshot, and refuses before any reserve when it is another project's.
|
|
1264
1650
|
pickupProject: project,
|
|
1651
|
+
// Issue #704 review: a bundle profile this host lacks defers a job on a multi-host worker's SHARED queue
|
|
1652
|
+
// (another host may serve it), the never-fits gate's predicate above; elsewhere it refuses.
|
|
1653
|
+
bundleProfileDeferrable: multiHost && (job.queueName ?? QUEUE) === QUEUE,
|
|
1265
1654
|
folderProject: (folder) => projectOf({ kind: "local", folder }, pickupProjects),
|
|
1266
1655
|
// Issue #596: the size resolved at pickup above, for the retained run's manifest and the container's argv.
|
|
1267
1656
|
jobSize: size,
|
|
@@ -1281,12 +1670,19 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1281
1670
|
// runJob context is unchanged.
|
|
1282
1671
|
...(endpointSnapshot ? { modelEndpoints: endpointSnapshot } : {}),
|
|
1283
1672
|
...deps,
|
|
1673
|
+
...(deps?.subscriptionRuntime ? {
|
|
1674
|
+
prepareSubscriptionAuth: async (j) => {
|
|
1675
|
+
try { return await deps.subscriptionRuntime.prepare(j, { deadlineAt: subscriptionDeadlineAt, signal, attemptId: `${modelAttemptId}:${job.attemptsMade ?? 0}:${job.stalledCounter ?? 0}` }); }
|
|
1676
|
+
catch (cause) { throw new InfraRetry("subscription authentication could not be prepared", { cause, reason: "container-never-started", budgetReserved: false }); }
|
|
1677
|
+
},
|
|
1678
|
+
validateSubscriptionAuth: (snapshots) => deps.subscriptionRuntime.validate(snapshots),
|
|
1679
|
+
} : {}),
|
|
1284
1680
|
// Issue #502: the skew check must see `models` as the job ARRIVED, not as `effectiveJobOf` filled it from
|
|
1285
1681
|
// PI_ALLOWED_MODELS, or a trigger list a stale receiver dropped would read as present and the job would run on
|
|
1286
1682
|
// the deployment's list. Only when the wiring supplies the check, so a bare processor keeps the default.
|
|
1287
1683
|
// The same for `maxCostUsd` (#501): `effectiveJobOf` keeps the arrived value under its own key and writes the
|
|
1288
1684
|
// resolved cap as `maxCostMicros`, but it is read off `job.data` here too, so no later fill can hide a drop.
|
|
1289
|
-
...(deps?.checkWaitSkew ? { checkWaitSkew: (j, ...rest) => deps.checkWaitSkew({ ...j, models: job.data?.models, maxCostUsd: job.data?.maxCostUsd }, ...rest) } : {}),
|
|
1685
|
+
...(deps?.checkWaitSkew ? { checkWaitSkew: (j, ...rest) => deps.checkWaitSkew({ ...j, models: job.data?.models, fallbackModels: job.data?.fallbackModels, maxCostUsd: job.data?.maxCostUsd }, ...rest) } : {}),
|
|
1290
1686
|
// #227. BOUNDED AFTER THE ABORT, and this is what makes `abortable` an honest declaration.
|
|
1291
1687
|
//
|
|
1292
1688
|
// `makeRunContainer`'s promise settles ONLY on the docker child's `close` or `error`. Nothing
|
|
@@ -1318,16 +1714,45 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1318
1714
|
// left for a cancel to stop. A cancel acknowledged before that moment raced the exit and lost nothing it was
|
|
1319
1715
|
// promised: the run is recorded as that cancel (the existing path, "partial work may exist") even when the
|
|
1320
1716
|
// container happened to exit on its own, because the operator was told the record would say operator-cancel.
|
|
1321
|
-
runContainer: (ctx) =>
|
|
1322
|
-
|
|
1717
|
+
runContainer: async (ctx) => {
|
|
1718
|
+
if (chosenModel) {
|
|
1719
|
+
try { await writeModelAttempt(redis, modelAttemptId, { ref: chosenModel, modelWhy: selectedEffective.modelWhy }); }
|
|
1720
|
+
catch (cause) { throw new InfraRetry("the model attempt could not be recorded", { cause, reason: "container-never-started" }); }
|
|
1721
|
+
}
|
|
1722
|
+
// `jobId` (issue #643) names the live transcript stream. Since issue #645 it is the RUN id (`runIdOf`): a recovery
|
|
1723
|
+
// (`.rN`) continues its run's stream, progress key and status comment, while its record stays its own.
|
|
1724
|
+
// Issue #644: `onLive` hands over the attempt's publisher finish, so giving up on a container whose stop did not
|
|
1725
|
+
// take also ends its progress key (outcome `unknown`: nothing saw it exit) instead of leaving its heartbeat to
|
|
1726
|
+
// rewrite the key as running for as long as the worker lives.
|
|
1727
|
+
return boundAfterAbort(deps.runContainer({ ...ctx, name, signal, jobId: runIdOf(job), onLive: (finish) => (finishLive = finish) }), signal, job, deps.log ?? (() => {}), abortGraceMs, () => {
|
|
1323
1728
|
stopDidNotTake = true;
|
|
1729
|
+
return finishLive?.("unknown");
|
|
1324
1730
|
}).then(async (r) => {
|
|
1731
|
+
// Only signed, matching terminal metadata can permit a later cross-model replay. The pessimistic
|
|
1732
|
+
// marker written before spawn survives crashes and tools that terminate the runner.
|
|
1733
|
+
if (r?.exitAuth === "verified" && r?.exitLineCode === r?.code && r?.aborted !== true) modelRolesRan = r.modelRoles ?? null;
|
|
1734
|
+
if (r?.exitAuth === "verified" && r?.exitLineCode === r?.code && r?.code === 1 && r?.aborted !== true) {
|
|
1735
|
+
providerFailureRan = r.providerFailure ?? null;
|
|
1736
|
+
try {
|
|
1737
|
+
if (chosenModel) await writeModelAttempt(redis, modelAttemptId, { ref: chosenModel, modelWhy: selectedEffective.modelWhy, safe: r.toolsStarted === false && r.replaySafe === true && !!r.providerFailure, failure: r.providerFailure });
|
|
1738
|
+
if (deps?.subscriptionRuntime) await deps.subscriptionRuntime.observe(effectiveJob, r, ctx.subscriptionAuth ?? []);
|
|
1739
|
+
if (r.providerFailure) await markModelCooldown(redis, `${effectiveJob.provider}/${effectiveJob.model}`, r.providerFailure, { now });
|
|
1740
|
+
} catch (cause) {
|
|
1741
|
+
// Issue #800: a verified line was read, and the processor never sees this result, so the throw carries what it said.
|
|
1742
|
+
throw new InfraRetry("the provider failure could not be recorded", { cause, reason: "container-infra-exit", exitCode: r.code, turns: r.turns, tokens: r.tokens, usage: r.usage, provider: effectiveJob.provider, model: effectiveJob.model, loaded: r.loaded });
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
// Issue #646: what this attempt said about steering, for the record's `steerSkipped` (settleSteer above).
|
|
1746
|
+
steerRan = r && typeof r === "object" ? { steerSkipped: r.steerSkipped ?? null } : null;
|
|
1747
|
+
liveRan = r && typeof r === "object" && typeof r.liveSkipped === "string" ? r.liveSkipped : null;
|
|
1748
|
+
openshellRan = r && typeof r === "object" && r.openshell !== null && typeof r.openshell === "object" ? r.openshell : null;
|
|
1325
1749
|
await stopCancelPoll();
|
|
1326
|
-
const raced = r && r.aborted !== true && signal.aborted === true &&
|
|
1750
|
+
const raced = r && r.aborted !== true && signal.aborted === true && ["operator-cancel", "agent-cancel"].includes(signal.reason);
|
|
1327
1751
|
if (raced) deps.log?.("cancel_acked_as_container_exited", { jobId: job.id, exitCode: r.code ?? null });
|
|
1328
1752
|
const run = raced ? { ...r, aborted: true } : r;
|
|
1329
1753
|
return run?.aborted ? { ...run, abortReason: typeof signal.reason === "string" ? signal.reason : null } : run;
|
|
1330
|
-
})
|
|
1754
|
+
});
|
|
1755
|
+
},
|
|
1331
1756
|
// REQ-TRIGGER-SECRETS. The resolver runs INSIDE the 30-minute kill timer armed above, so it has
|
|
1332
1757
|
// to be abortable for the same reason runContainer does: a resolver blocking on an unreachable
|
|
1333
1758
|
// vault would otherwise hold its slot until its own timeout, and an abort landing mid-resolution
|
|
@@ -1339,6 +1764,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1339
1764
|
// tunables above. It is bound here rather than at construction for the reason the overlay exists:
|
|
1340
1765
|
// an operator who declares a profile in the panel must not have to restart the worker.
|
|
1341
1766
|
...(deps.resolveSecrets ? { resolveSecrets: (j) => deps.resolveSecrets(j, { signal, overlayProfiles: settings.secretProfiles ?? {} }) } : {}),
|
|
1767
|
+
// Issue #704: the bundle seam, bound the same way and for the same two reasons: the read runs inside the kill
|
|
1768
|
+
// timer, so it takes the signal, and the overlay half of the bundle profiles is this job-start's settings read.
|
|
1769
|
+
...(deps.secretBundles ? { secretBundles: { lookup: (profile) => deps.secretBundles.lookup(profile, { overlayBundles: settings.secretBundles ?? {} }), resolve: (j, binding) => deps.secretBundles.resolve(j, binding, { signal, overlayBundles: settings.secretBundles ?? {} }) } } : {}),
|
|
1342
1770
|
// collectChain (INT-OUTBOX-CONTRACT) reads the completed parent's REAL BullMQ job: its `.id`
|
|
1343
1771
|
// (the parent id children carry) and `.data` (kind/chainDepth). runJob's own `job` is the
|
|
1344
1772
|
// effectiveJob -- a spread of job.data with no `.id`/`.data` -- so inject the real wrapper here,
|
|
@@ -1350,6 +1778,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1350
1778
|
// unconditionally, AFTER the `...deps` spread like the injections around it: whether the key may outlive a park
|
|
1351
1779
|
// is not a choice a wiring gets to make by omission.
|
|
1352
1780
|
releaseParkedWindow: () => releaseSemanticWindow(job, deps?.log),
|
|
1781
|
+
// Issue #638: the held replies, taken at the park on the REAL job (its id is the run's, its `queueName` the queue
|
|
1782
|
+
// the resumed job goes to), bound unconditionally like the release; it takes nothing unless wired.
|
|
1783
|
+
takeHeldReplies: () => takeHeldReplies().catch(() => null),
|
|
1353
1784
|
// The plan collector (issue #505) the same way and for the same reason: the writer it records is the REAL job's
|
|
1354
1785
|
// `.id`, and the authority it checks is `.data` as queued. The pickup's `portfolio` decision rides in `ctx`.
|
|
1355
1786
|
...(deps.collectPlan ? { collectPlan: (ctx) => deps.collectPlan({ ...ctx, job }) } : {}),
|
|
@@ -1368,6 +1799,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1368
1799
|
// The processor tests inject `prepareWorkspace` directly and never see this wrapper, which is
|
|
1369
1800
|
// why nothing caught it (REQ-RESUMABLE-SESSION).
|
|
1370
1801
|
...(deps.prepareWorkspace ? { prepareWorkspace: (j, t, opts) => deps.prepareWorkspace(j, t, { ...opts, queueJobId: job.id }) } : {}),
|
|
1802
|
+
replyReceivedAt: Number.isSafeInteger(job.data?.replyReceivedAt) && job.data.replyReceivedAt >= 0 ? job.data.replyReceivedAt : Number.isSafeInteger(job.timestamp) ? job.timestamp : null,
|
|
1371
1803
|
// The one-shot pre-spend check (issue #231) needs the REAL BullMQ job's `.id` to excuse this
|
|
1372
1804
|
// delivery's own earlier attempt -- runJob's effectiveJob has no `.id`, prepareWorkspace's
|
|
1373
1805
|
// own injection above states why, and this one mirrors it. Omitted when unwired so a bare
|
|
@@ -1376,6 +1808,15 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1376
1808
|
// this one no options today, so there is nothing to lose yet -- and the day it does, a
|
|
1377
1809
|
// replacing wrapper would lose it in the same silence.
|
|
1378
1810
|
...(deps.checkOnceSpent ? { checkOnceSpent: (j, opts) => deps.checkOnceSpent(j, { ...opts, queueJobId: job.id }) } : {}),
|
|
1811
|
+
// Issue #645: a recovery is enqueued from the REAL BullMQ job (its `.id` is the run id of an original, its `.name`
|
|
1812
|
+
// the add's name, its `.data` what the recovery copies), which only this scope holds; runJob's effectiveJob has
|
|
1813
|
+
// none of them. Bound like collectChain above, and omitted when unwired so runJob keeps its inert default.
|
|
1814
|
+
...(deps.enqueueRecovery ? { enqueueRecovery: (args) => deps.enqueueRecovery({ ...args, job }) } : {}),
|
|
1815
|
+
// The jobs running on this host (`{ list() }`, ids and admission instants), so a recovery whose base job still runs
|
|
1816
|
+
// HERE is delayed rather than started beside it. The processor's own ledger, unless a wiring hands one in.
|
|
1817
|
+
runningJobs: deps.runningJobs ?? runningJobs,
|
|
1818
|
+
// Observability follows the actual reservation, never the configured cap before admission.
|
|
1819
|
+
onDollarHold: (hold) => runningJobs.update?.(runningKey, { holdMicros: hold?.amountMicros ?? 0, holdAt: hold?.at ?? Date.parse(startedAt) }),
|
|
1379
1820
|
});
|
|
1380
1821
|
// Gate round 4 of PR #479: stopped again as runJob returns, before anything is recorded (a result with no
|
|
1381
1822
|
// container never passed the exit stop above). A cancel that lands after is never acknowledged, and its
|
|
@@ -1384,10 +1825,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1384
1825
|
// the cancel the operator was told of, the result it replaced named in the log.
|
|
1385
1826
|
await stopCancelPoll();
|
|
1386
1827
|
let outcome = result;
|
|
1387
|
-
if (signal.aborted === true &&
|
|
1828
|
+
if (signal.aborted === true && ["operator-cancel", "agent-cancel"].includes(signal.reason) && result?.reason !== signal.reason) {
|
|
1388
1829
|
deps.log?.("cancel_acked_before_result", { jobId: job.id, was: `${result?.outcome}/${result?.reason ?? ""}` });
|
|
1389
|
-
outcome = { ...result, outcome: "policy", reason:
|
|
1830
|
+
outcome = { ...result, outcome: "policy", reason: signal.reason };
|
|
1390
1831
|
}
|
|
1832
|
+
await settleSteer({ result: outcome });
|
|
1391
1833
|
recordAfterGate({ job, result: outcome, startedAt, endedAt: new Date().toISOString() });
|
|
1392
1834
|
return outcome;
|
|
1393
1835
|
} catch (error) {
|
|
@@ -1395,8 +1837,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1395
1837
|
// delayed set WITHOUT spending an attempt, the pause gate's move, since a service held up past one retry backoff
|
|
1396
1838
|
// failed a job that heals by itself (measured). Bounded by the hold's own start, stored on the job because a
|
|
1397
1839
|
// deferral leaves `attemptsMade` alone and a worker restart must not reset it; past the bound it fails for good
|
|
1398
|
-
// with its own reason token, whose comment names the restart.
|
|
1399
|
-
//
|
|
1840
|
+
// with its own reason token, whose comment names the restart. No run record is written per deferral (its defer
|
|
1841
|
+
// record says why it waits, and is replaced by the next): the job never started, and one run record a minute
|
|
1842
|
+
// for an hour would bury the run history.
|
|
1400
1843
|
//
|
|
1401
1844
|
// Gate round 3: the hour counts only while the worker kept checking. A check that comes more than two recheck
|
|
1402
1845
|
// periods after the last one (the queue was paused, `pi-dispatch pause`, or no worker ran) starts the hold
|
|
@@ -1415,22 +1858,25 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1415
1858
|
// so it survives the restart.
|
|
1416
1859
|
// `readPending: false` asks only whether the poll ALREADY acknowledged a cancel (the signal), and leaves a request it
|
|
1417
1860
|
// had not read unanswered.
|
|
1861
|
+
let cancellationReason = signal?.reason === "agent-cancel" ? "agent-cancel" : "operator-cancel";
|
|
1418
1862
|
const operatorCancelled = async ({ readPending = true } = {}) => {
|
|
1419
|
-
if (signal?.aborted === true) return
|
|
1863
|
+
if (signal?.aborted === true) return ["operator-cancel", "agent-cancel"].includes(signal.reason);
|
|
1420
1864
|
if (!readPending) return false;
|
|
1421
1865
|
if (typeof redis?.get !== "function") return false;
|
|
1422
1866
|
let req = null;
|
|
1423
1867
|
try {
|
|
1424
1868
|
req = await redis.get(cancelReqKey(job.id));
|
|
1869
|
+
if (req == null) { req = await redis.get(agentCancelReqKey(job.id)); if (req != null) cancellationReason = "agent-cancel"; }
|
|
1425
1870
|
} catch {
|
|
1426
1871
|
return false;
|
|
1427
1872
|
}
|
|
1428
1873
|
if (req === null || req === undefined) return false;
|
|
1429
|
-
await Promise.resolve(redis.set?.(cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS)).catch(() => {});
|
|
1430
|
-
await Promise.resolve(redis.del?.(cancelReqKey(job.id))).catch(() => {});
|
|
1874
|
+
await Promise.resolve(redis.set?.(cancellationReason === "agent-cancel" ? agentCancelAckKey(job.id) : cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS)).catch(() => {});
|
|
1875
|
+
await Promise.resolve(redis.del?.(cancellationReason === "agent-cancel" ? agentCancelReqKey(job.id) : cancelReqKey(job.id))).catch(() => {});
|
|
1431
1876
|
return true;
|
|
1432
1877
|
};
|
|
1433
|
-
// A retry that already spent (a container that ran and exited as infra) keeps what it spent on the record,
|
|
1878
|
+
// A retry that already spent (a container that ran and exited as infra) keeps what it spent on the record (and, issue
|
|
1879
|
+
// #644, where its run got to, `progress`, which the processor put on the error beside `resources`), and
|
|
1434
1880
|
// says the processor's own operator-cancel sentence; one that never started says it was cancelled before it did.
|
|
1435
1881
|
// The session rides along as the aborted-container path keeps it (`mergeSession`, already applied to the throw).
|
|
1436
1882
|
// A NON-retryable error (gate round 3) is not known to have started nothing, so it says the processor's own
|
|
@@ -1438,9 +1884,10 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1438
1884
|
const endCancelled = async ({ retryable }) => {
|
|
1439
1885
|
const spent = error?.budgetReserved === true;
|
|
1440
1886
|
const beforeStart = retryable && !spent;
|
|
1441
|
-
const result = { outcome: "policy", reason:
|
|
1887
|
+
const result = { outcome: "policy", reason: cancellationReason, exitCode: spent ? (error.exitCode ?? null) : null, turns: spent ? (error.turns ?? null) : null, tokens: spent ? (error.tokens ?? null) : null, ...(spent && error.usage ? { usage: error.usage } : {}), provider: error?.provider ?? null, model: error?.model ?? null, session: error?.session ?? null, budgetReserved: retryable ? spent : (error?.budgetReserved ?? null), ...(error?.dollars ? { dollars: error.dollars } : {}), ...(spent && error?.resources ? { resources: error.resources } : {}), ...(spent && error?.progress ? { progress: error.progress } : {}), ...(error?.loaded ? { loaded: error.loaded } : {}) };
|
|
1442
1888
|
deps?.log?.("job_cancelled_instead_of_retry", { jobId: job.id, spent, retryable, ...(retryable ? {} : { failure: scrubCredentials(String(error?.message ?? error)).slice(0, 300) }) });
|
|
1443
|
-
if (deps?.comment) await Promise.resolve(deps.comment(job.data, beforeStart ? CANCELLED_BEFORE_START_COMMENT : TERMINAL_COMMENTS[
|
|
1889
|
+
if (deps?.comment) await Promise.resolve(deps.comment(job.data, beforeStart && cancellationReason !== "agent-cancel" ? CANCELLED_BEFORE_START_COMMENT : TERMINAL_COMMENTS[cancellationReason])).catch(() => {});
|
|
1890
|
+
await settleSteer({ result });
|
|
1444
1891
|
recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
1445
1892
|
return result;
|
|
1446
1893
|
};
|
|
@@ -1461,7 +1908,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1461
1908
|
// other recorded. A retryable error reads that request too, since the job would otherwise run again.
|
|
1462
1909
|
await stopCancelPoll();
|
|
1463
1910
|
const retryable = error instanceof InfraRetry;
|
|
1464
|
-
if (await operatorCancelled({ readPending: retryable })) return await endCancelled({ retryable });
|
|
1911
|
+
if (await operatorCancelled({ readPending: retryable || error instanceof SubscriptionWait })) return await endCancelled({ retryable });
|
|
1912
|
+
if (error instanceof SubscriptionWait) {
|
|
1913
|
+
await releaseAllHolds();
|
|
1914
|
+
if (error.wait === "subscription-auth") await deferTo(job, token, error.until, "subscription-auth", { project });
|
|
1915
|
+
if (error.wait === "subscription-quota") await deferTo(job, token, error.until, "subscription-quota", { project });
|
|
1916
|
+
if (error.wait === "subscription-owner") await deferTo(job, token, error.until, "subscription-owner", { project });
|
|
1917
|
+
}
|
|
1465
1918
|
if (error?.holdUntilRestart === true) {
|
|
1466
1919
|
const heldAt = now();
|
|
1467
1920
|
const last = job.data?.podmanRestartHoldLastMs;
|
|
@@ -1470,13 +1923,47 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1470
1923
|
if (heldAt - since < PODMAN_RESTART_HOLD_MAX_MS) {
|
|
1471
1924
|
await job.updateData({ ...job.data, podmanRestartHoldSinceMs: since, podmanRestartHoldLastMs: heldAt });
|
|
1472
1925
|
deps?.log?.("podman_restart_hold", { jobId: job.id, heldForMs: heldAt - since, delayMs: PODMAN_RESTART_HOLD_RECHECK_MS, reason: String(error.message).slice(0, 300) });
|
|
1473
|
-
await job
|
|
1474
|
-
throw new DelayedError();
|
|
1926
|
+
await deferTo(job, token, heldAt + PODMAN_RESTART_HOLD_RECHECK_MS, "podman-restart", { project });
|
|
1475
1927
|
}
|
|
1476
1928
|
const expired = Object.assign(new UnrecoverableError(`held ${Math.round((heldAt - since) / 60_000)} min for rootful Podman's service to restart, and it did not: ${error.message}`), { reason: PODMAN_RESTART_HOLD_EXPIRED, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
|
|
1929
|
+
await settleSteer({ error: expired });
|
|
1477
1930
|
recordAfterGate({ job, error: expired, startedAt, endedAt: new Date().toISOString() });
|
|
1478
1931
|
throw expired;
|
|
1479
1932
|
}
|
|
1933
|
+
// Issue #645: a recovery job whose base attempt is still running on this host waits for it (`RecoveryWaitHold`, a
|
|
1934
|
+
// never-started InfraRetry carrying `recoveryWaitMs`). A recovery job has ONE attempt, so a plain throw would fail
|
|
1935
|
+
// it while the run is alive; the delay costs no attempt. It ends when the base settles, or at the run's recovery
|
|
1936
|
+
// deadline, which the host-lost sweep enforces.
|
|
1937
|
+
// Issue #704 review: a secret bundle profile this host does not serve (or declares twice), on a multi-host
|
|
1938
|
+
// worker's SHARED queue (`SecretBundleProfileHold`): handed back for the never-fits gate's recheck without
|
|
1939
|
+
// spending an attempt, so a host that declares the profile can take it. On a single host, or for a job on a
|
|
1940
|
+
// host's own queue, the processor refuses it instead (`secret-bundle-profile-unknown` / `-ambiguous`). Like
|
|
1941
|
+
// the never-fits deferral it waits for as long as no host serves it, and doctor fails on every host that
|
|
1942
|
+
// takes the project's jobs without the profile.
|
|
1943
|
+
//
|
|
1944
|
+
// RAISED IN THE PROCESSOR, not beside the never-fits gate above, deliberately (review round 2 asked): the
|
|
1945
|
+
// decision needs the per-job settings read (the overlay half of the bundle profiles), which comes after the
|
|
1946
|
+
// wait gate and the scope acquire, and the skip rules (chained, manual, opted-out, a changed trigger) that
|
|
1947
|
+
// must run first live in the processor's free gate. Hoisting the lookup alone would judge a job the processor
|
|
1948
|
+
// then skips, and would read the overlay a second time per pickup. The cost of staying is that each recheck
|
|
1949
|
+
// re-runs the gates above it (a held wait is re-asked, the scope is re-acquired, the image and egress
|
|
1950
|
+
// preflights re-run), all free reads, once a minute per held job.
|
|
1951
|
+
if (error?.bundleProfileHold === true) {
|
|
1952
|
+
deps?.log?.("secret_bundle_profile_hold", { jobId: job.id, delayMs: NEVER_FITS_RECHECK_MS, reason: typeof error.reason === "string" ? error.reason : null });
|
|
1953
|
+
await deferTo(job, token, now() + NEVER_FITS_RECHECK_MS, "bundle-profile", { project });
|
|
1954
|
+
}
|
|
1955
|
+
if (Number.isFinite(error?.recoveryWaitMs) && error.recoveryWaitMs > 0) {
|
|
1956
|
+
deps?.log?.("recovery_wait_hold", { jobId: job.id, delayMs: error.recoveryWaitMs });
|
|
1957
|
+
await deferTo(job, token, now() + error.recoveryWaitMs, "recovery-wait", { project, holder: job.data?.recoveryOf });
|
|
1958
|
+
}
|
|
1959
|
+
// Issue #761: a free gate of the processor read something a newer pi-dispatch wrote (`NewerReleaseHold`: the live
|
|
1960
|
+
// triggers file, for the one-shot, skew and approval checks, or a recovery's checkpoint), before the mint and every
|
|
1961
|
+
// reserve. Deferred like the recovery hold above, without spending an attempt, and with nothing settled, recorded or
|
|
1962
|
+
// posted: the job waits for a worker that reads the file. The log names the store, never the error's words.
|
|
1963
|
+
if (error?.newerRelease === true) {
|
|
1964
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: typeof error.what === "string" ? error.what : null, delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
1965
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
|
|
1966
|
+
}
|
|
1480
1967
|
// Issue #476: a job whose egress preflight found the rootless network keeper running on its own bridge but younger
|
|
1481
1968
|
// than the minimum age is HELD until it is old enough (one `moveToDelayed`, no attempt), the move the hold above
|
|
1482
1969
|
// makes, since a keeper merely young is not a keeper that does not hold: every joint start of the stack read it
|
|
@@ -1493,20 +1980,23 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1493
1980
|
if (!restarted && heldMs < NETNS_KEEPER_YOUNG_HOLD_MAX_MS) {
|
|
1494
1981
|
await job.updateData({ ...job.data, netnsKeeperHoldSinceMs: keeperHold.since, netnsKeeperHoldLastMs: keeperHold.at, netnsKeeperHoldStartedMs: young.startedMs });
|
|
1495
1982
|
deps?.log?.("netns_keeper_young_hold", { jobId: job.id, keeperAgeMs: young.ageMs, heldForMs: heldMs, delayMs: young.waitMs });
|
|
1496
|
-
await job
|
|
1497
|
-
throw new DelayedError();
|
|
1983
|
+
await deferTo(job, token, keeperHold.at + young.waitMs, "netns-keeper", { project });
|
|
1498
1984
|
}
|
|
1499
1985
|
const was = keeperHold.startedMs ?? young.startedMs;
|
|
1500
1986
|
await markKeeperLoop(job, was === young.startedMs ? [was] : [was, young.startedMs]);
|
|
1501
|
-
const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was, now: young.startedMs, heldMs, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
|
|
1987
|
+
const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was, now: young.startedMs, heldMs, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false, loaded: error.loaded });
|
|
1988
|
+
await settleSteer({ error: loop });
|
|
1502
1989
|
recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
|
|
1990
|
+
await noteRetry(job, { project });
|
|
1503
1991
|
throw loop;
|
|
1504
1992
|
}
|
|
1505
1993
|
if (error?.reason === NETNS_KEEPER_NOT_HOLDING && keeperHold.startedMs !== null) {
|
|
1506
1994
|
// The keeper this job was waiting on is no longer running on its bridge: it died young, the loop's other face.
|
|
1507
1995
|
await markKeeperLoop(job, [keeperHold.startedMs]);
|
|
1508
|
-
const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was: keeperHold.startedMs, now: null, problem: error.keeperProblem ?? null, heldMs: keeperHold.at - keeperHold.since, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
|
|
1996
|
+
const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was: keeperHold.startedMs, now: null, problem: error.keeperProblem ?? null, heldMs: keeperHold.at - keeperHold.since, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false, loaded: error.loaded });
|
|
1997
|
+
await settleSteer({ error: loop });
|
|
1509
1998
|
recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
|
|
1999
|
+
await noteRetry(job, { project });
|
|
1510
2000
|
throw loop;
|
|
1511
2001
|
}
|
|
1512
2002
|
// A LATER ATTEMPT OF A JOB THAT SAW THE LOOP (gate of PR #479). The loop's retry comes after the queue's 60 s
|
|
@@ -1516,12 +2006,20 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1516
2006
|
// failure of this job after it is still named the loop.
|
|
1517
2007
|
const loopSeen = job.data?.netnsKeeperLoopSeen;
|
|
1518
2008
|
if (error?.reason === NETNS_KEEPER_NOT_HOLDING && Array.isArray(loopSeen) && loopSeen.length > 0) {
|
|
1519
|
-
const loop = new InfraRetry(netnsKeeperLoopAgainSentence({ seen: loopSeen, problem: error.keeperProblem ?? null, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
|
|
2009
|
+
const loop = new InfraRetry(netnsKeeperLoopAgainSentence({ seen: loopSeen, problem: error.keeperProblem ?? null, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false, loaded: error.loaded });
|
|
2010
|
+
await settleSteer({ error: loop });
|
|
1520
2011
|
recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
|
|
2012
|
+
await noteRetry(job, { project });
|
|
1521
2013
|
throw loop;
|
|
1522
2014
|
}
|
|
2015
|
+
await settleSteer({ error });
|
|
1523
2016
|
recordAfterGate({ job, error, startedAt, endedAt: new Date().toISOString() });
|
|
1524
|
-
if (error instanceof InfraRetry)
|
|
2017
|
+
if (error instanceof InfraRetry) {
|
|
2018
|
+
// Retryable: BullMQ retries per attempts, after its backoff. The retry's reason is recorded first (issue #737),
|
|
2019
|
+
// since BullMQ delays the job itself after this throw, where no gate of ours can say why.
|
|
2020
|
+
await noteRetry(job, { project });
|
|
2021
|
+
throw error;
|
|
2022
|
+
}
|
|
1525
2023
|
// A non-retryable, non-infra error (our bug) must not retry forever. UnrecoverableError
|
|
1526
2024
|
// records it as failed-and-distinct in the queue's failed set without a retry.
|
|
1527
2025
|
throw new UnrecoverableError(error.message);
|
|
@@ -1539,16 +2037,119 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1539
2037
|
clearTimeout(timer);
|
|
1540
2038
|
clearInterval(cancelPoll);
|
|
1541
2039
|
signal.removeEventListener("abort", onAbort);
|
|
2040
|
+
// After the settle (issue #638): a final outcome stopped it already; an attempt that will be retried leaves its
|
|
2041
|
+
// keys to their 30 s expiry, so a reply in that gap is still held.
|
|
2042
|
+
await awaitKeeper?.stop().catch(() => {});
|
|
1542
2043
|
}
|
|
1543
2044
|
};
|
|
1544
2045
|
return async function processor(job, token, signal) {
|
|
1545
|
-
|
|
2046
|
+
// First, before any gate reads the data: a job queued by a newer pi-dispatch, whose fields this build would drop
|
|
2047
|
+
// or misread, waits for a worker that can read it (issue #761). A deferral, never a refusal: a refusal would use up
|
|
2048
|
+
// a forge delivery (its id deduplicates a redelivery for `FORGE_JOB_RETENTION_DAYS`) and spend a one-shot
|
|
2049
|
+
// trigger, and its record would be built from data this build cannot read. Nothing is spent, recorded or posted.
|
|
2050
|
+
if (jobDataNewer(job.data)) {
|
|
2051
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "job data", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2052
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2053
|
+
}
|
|
2054
|
+
// Issue #761: the triggers file on disk was written by a newer pi-dispatch (start.mjs's reload refused it and keeps
|
|
2055
|
+
// the last good schedules). ONE gate for every job, right after the job data's and before every branch below,
|
|
2056
|
+
// rather than a patch in each reader: the file is read live by many of them (the fork policy, the secret bundles,
|
|
2057
|
+
// the model roles, the answer rules, the question grouping, the approvals, the watch rules, the one-shot and skew
|
|
2058
|
+
// checks), and each would otherwise run on its last good copy or refuse. Waiting for this host's upgrade is the one
|
|
2059
|
+
// answer that neither misreads the file nor loses the work. A cron job minted from the last good schedules waits
|
|
2060
|
+
// here too, so no schedule the newer file changed runs as it was. Free, before anything is read, held or spent.
|
|
2061
|
+
if (triggersNewer() === true) {
|
|
2062
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "the triggers file", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2063
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2064
|
+
}
|
|
2065
|
+
// Issue #761: the settings file was written by a newer pi-dispatch. Asked HERE, with the same `getSettings` the
|
|
2066
|
+
// pickup reads, because several refusals run before the pickup's own read (the question grouping's, the never-fits
|
|
2067
|
+
// size, the wait gate's) and each comments, records and disarms a one-shot: one gate makes "every job defers" true
|
|
2068
|
+
// for all of them, where a check in each branch would be one more place to forget. Only the newer answer is acted
|
|
2069
|
+
// on. A throw falls through to the pickup, which reads again and handles it as it always did; nothing is held yet,
|
|
2070
|
+
// so nothing is given back.
|
|
2071
|
+
let headSettings = null;
|
|
2072
|
+
try { headSettings = await getSettings(); } catch {}
|
|
2073
|
+
if (headSettings?.invalid && headSettings.newer === true) {
|
|
2074
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "the settings file", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2075
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2076
|
+
}
|
|
2077
|
+
const sourceRefusal = await checkDraftAnswerSource(job.data, deps);
|
|
2078
|
+
if (sourceRefusal) {
|
|
2079
|
+
return recordBeforePickup(job, sourceRefusal);
|
|
2080
|
+
}
|
|
2081
|
+
if (job.data?.kind === "answer-draft" || job.data?.answerDraft) {
|
|
2082
|
+
if (!deps.answerDraftRuntime) return { outcome: "policy", reason: "answer-draft-unavailable", budgetReserved: false };
|
|
2083
|
+
const resolved = await deps.answerDraftRuntime.resolve(job.data, job.id);
|
|
2084
|
+
// Issue #761: the runtime read a triggers file a newer pi-dispatch wrote (a write that landed before the watcher's
|
|
2085
|
+
// reload said so) and answered before writing anything: the job waits as at the head, without using an attempt.
|
|
2086
|
+
if (typeof resolved.newer === "string") {
|
|
2087
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: resolved.newer, delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2088
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2089
|
+
}
|
|
2090
|
+
if (resolved.result) {
|
|
2091
|
+
await recordWatchOutcome({ job, result: resolved.result, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
|
|
2092
|
+
return resolved.result;
|
|
2093
|
+
}
|
|
2094
|
+
job.data = resolved.data;
|
|
2095
|
+
}
|
|
2096
|
+
if (job.data?.kind === "zero-touch-grouping" || job.data?.questionGrouping) {
|
|
2097
|
+
if (!deps.questionGroupRuntime) return { outcome: "policy", reason: "question-group-unavailable", budgetReserved: false };
|
|
2098
|
+
const resolved = await deps.questionGroupRuntime.resolve(job.data, job.id);
|
|
2099
|
+
// Issue #761: the runtime read a triggers or settings file a newer pi-dispatch wrote (one that turned newer after
|
|
2100
|
+
// the head's checks) and answered before setting any status: the job waits as at the head, never refused.
|
|
2101
|
+
if (typeof resolved.newer === "string") {
|
|
2102
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: resolved.newer, delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2103
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2104
|
+
}
|
|
2105
|
+
if (resolved.result) {
|
|
2106
|
+
await recordWatchOutcome({ job, result: resolved.result, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
|
|
2107
|
+
return resolved.result;
|
|
2108
|
+
}
|
|
2109
|
+
job.data = resolved.data;
|
|
2110
|
+
}
|
|
2111
|
+
if (job.data?.kind === "watch" || job.data?.watch) {
|
|
2112
|
+
if (!deps.watchRuntime) return { outcome: "policy", reason: "watch-unavailable" };
|
|
2113
|
+
// Issue #761: the watch runtime judges its own pause window below (`pauseUntil`, from the last good windows), and
|
|
2114
|
+
// it takes the finding, or files the issue, as soon as that says the target is not paused, before pickup's pause
|
|
2115
|
+
// gate ever runs. A pause windows file written by a newer pi-dispatch may pause the target, so the job waits for
|
|
2116
|
+
// this host's upgrade first, before anything is taken, as every other job does at the pause gate.
|
|
2117
|
+
if (pauseWindowsNewer() === true) {
|
|
2118
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: "the pause windows file", delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2119
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2120
|
+
}
|
|
2121
|
+
const resolved = await deps.watchRuntime.resolve(job.data, job.id);
|
|
2122
|
+
// Issue #761: the runtime read the triggers file or the settings a newer pi-dispatch wrote (a write that landed
|
|
2123
|
+
// before the watcher's reload said so), and answered BEFORE taking the finding: the job waits as at the head.
|
|
2124
|
+
if (typeof resolved.newer === "string") {
|
|
2125
|
+
deps?.log?.("newer_release_hold", { jobId: job.id, what: resolved.newer, delayMs: NEWER_RELEASE_RECHECK_MS });
|
|
2126
|
+
await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
|
|
2127
|
+
}
|
|
2128
|
+
if (resolved.deferUntil) await deferTo(job, token, resolved.deferUntil, "pause-window");
|
|
2129
|
+
if (resolved.result) {
|
|
2130
|
+
await recordWatchOutcome({ job, result: resolved.result, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
|
|
2131
|
+
return resolved.result;
|
|
2132
|
+
}
|
|
2133
|
+
job.data = resolved.data;
|
|
2134
|
+
}
|
|
2135
|
+
// Kept workspaces can hold an arbitrary prepared ref; they never become agent workspaces (#771).
|
|
2136
|
+
const sandboxBoundary = await deps.checkSandboxWorkspace?.(job.data);
|
|
2137
|
+
if (sandboxBoundary?.unavailable) throw new InfraRetry("the local workspace sandbox boundary could not be read");
|
|
2138
|
+
if (sandboxBoundary?.outcome === "policy") {
|
|
2139
|
+
return recordBeforePickup(job, sandboxBoundary);
|
|
2140
|
+
}
|
|
2141
|
+
if (!hostBudget) {
|
|
2142
|
+
const result = await pickup(job, token, signal, { budgetDeferred: false });
|
|
2143
|
+
if (job.data?.answerDraft && result?.outcome !== "completed") await deps.answerDraftRuntime?.refused(job.data, result).catch(() => {});
|
|
2144
|
+
return result;
|
|
2145
|
+
}
|
|
1546
2146
|
// The pickup's TICKET: the ledger entry this pickup takes carries it, and only this pickup's release or
|
|
1547
2147
|
// orphan can act on that entry, so a second pickup of the same job id (a stalled scheduled job handed back while
|
|
1548
2148
|
// its first attempt still runs here) can never give back the first's hold.
|
|
1549
2149
|
const budgetState = { budgetDeferred: false, ticket: hostBudget.enter(job.id) };
|
|
1550
2150
|
try {
|
|
1551
2151
|
const result = await pickup(job, token, signal, budgetState);
|
|
2152
|
+
if (job.data?.answerDraft && result?.outcome !== "completed") await deps.answerDraftRuntime?.refused(job.data, result).catch(() => {});
|
|
1552
2153
|
hostBudget.forget(job.id);
|
|
1553
2154
|
return result;
|
|
1554
2155
|
} catch (error) {
|
|
@@ -1569,19 +2170,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1569
2170
|
*/
|
|
1570
2171
|
export const CANCEL_STOP_BOUND_MS = 1_000;
|
|
1571
2172
|
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
* record and doctor name both sizes. There is no fleet refusal (gate round 1 of phase 2): a job on the shared
|
|
1576
|
-
* queue waits for a host it fits on.
|
|
1577
|
-
*/
|
|
1578
|
-
export const SIZE_REFUSAL_COMMENTS = Object.freeze({
|
|
1579
|
-
"job-size-exceeds-host": "Refused: this job's size is larger than the worker host's job budget, so it could never start there. No container was started and nothing was spent. Ask the operator to lower this project's job size or raise the host budget. Not run.",
|
|
1580
|
-
"job-size-exceeds-share": "Refused: this job's size is larger than the share of the worker host's job budget its project may use, so it could never start there. No container was started and nothing was spent. Ask the operator to lower this project's job size or raise its host share. Not run.",
|
|
1581
|
-
});
|
|
1582
|
-
|
|
1583
|
-
/** The comment for a job the operator cancelled before it started, where it would have been held or retried (gate of PR #479). */
|
|
1584
|
-
export const CANCELLED_BEFORE_START_COMMENT = "Stopped: the operator cancelled this run before it started. Nothing was spent. Not retried.";
|
|
2173
|
+
// The never-fits refusals' comments (SIZE_REFUSAL_COMMENTS, issue #596) and the cancelled-before-start one live in
|
|
2174
|
+
// comment-tables.mjs since issue #710, re-exported here under the names they always had.
|
|
2175
|
+
export { CANCELLED_BEFORE_START_COMMENT, SIZE_REFUSAL_COMMENTS };
|
|
1585
2176
|
|
|
1586
2177
|
/**
|
|
1587
2178
|
* Store the keeper starts a crash loop was seen at on the job (gate of PR #479), outside the hold's 30 s window, so a
|
|
@@ -1608,7 +2199,7 @@ export function keeperHoldState(job, at) {
|
|
|
1608
2199
|
return { at, since, startedMs };
|
|
1609
2200
|
}
|
|
1610
2201
|
|
|
1611
|
-
export function createWorker({ connection, name, stopContainer, containerName, hostQueue = null, checkLease = null, scopeLease = null, checkTimeoutMs, concurrency, getSettings, redis, deps, recordRun, settledRecord = null, previousRecord = null, limiter, pauseUntil, scopedLimits, projects, jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), waitState, afterMaxMs, checkSlots = makeInFlight(), checkSlotCount, concurrencyNow, intervalMs, maxWaitMs, maxChecks, maxFaults, hostSlots = makeInFlight(), endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels, extraClosers = [], hostBudget: hostBudgetOptions = null, runningJobs = makeRunningJobs() }) {
|
|
2202
|
+
export function createWorker({ connection, name, stopContainer, containerName, hostQueue = null, checkLease = null, scopeLease = null, checkTimeoutMs, concurrency, getSettings, readModelRoles, redis, deps, recordRun, settledRecord = null, previousRecord = null, limiter, pauseUntil, pauseWindowsNewer, triggersNewer, scopedLimits, projects, jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), waitState, afterMaxMs, checkSlots = makeInFlight(), checkSlotCount, concurrencyNow, intervalMs, maxWaitMs, maxChecks, maxFaults, hostSlots = makeInFlight(), endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels, extraClosers = [], hostBudget: hostBudgetOptions = null, runningJobs = makeRunningJobs() }) {
|
|
1612
2203
|
// One Worker per queue name (issue #57). A host-affine job -- one whose folder, secret resolver or wait
|
|
1613
2204
|
// check lives on THIS machine -- is enqueued to `pi-jobs@<name>` rather than filtered for at pickup,
|
|
1614
2205
|
// because BullMQ has no selective pop and the put-it-back alternative does not work: promotion out of
|
|
@@ -1651,6 +2242,10 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1651
2242
|
for (const queueName of names) {
|
|
1652
2243
|
let worker; // referenced by cancelJob/applyConcurrency before assignment; only called later, so the TDZ is fine
|
|
1653
2244
|
const processor = makeProcessor({
|
|
2245
|
+
// Issue #761: the endpoint tag of the Valkey this worker's clients dial (`valkeyEndpointTag`), from the judged
|
|
2246
|
+
// connection's own options, the address `parseConnection` read off the pinned URL, so it is the tag doctor computes
|
|
2247
|
+
// from the same URL. Null for a connection with none, which writes no `dollars.reservedIn`.
|
|
2248
|
+
valkeyTag: valkeyEndpointTag(connection),
|
|
1654
2249
|
// Bound to THIS worker: a job on the host queue is cancelled by the worker draining that queue,
|
|
1655
2250
|
// and the shared handle could not reach it.
|
|
1656
2251
|
cancelJob: (id, reason) => worker.cancelJob(id, reason),
|
|
@@ -1666,6 +2261,7 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1666
2261
|
containerName,
|
|
1667
2262
|
redis,
|
|
1668
2263
|
getSettings,
|
|
2264
|
+
readModelRoles,
|
|
1669
2265
|
// Issue #596, phase 2: the ONE host budget. `multiHost` is whether this worker declared a fleet (a host queue):
|
|
1670
2266
|
// without one it declares no fleet and no other host is there to wait for, so a size that never fits here is refused rather than
|
|
1671
2267
|
// deferred for a host that does not exist (gate round 2 of phase 2).
|
|
@@ -1679,6 +2275,12 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1679
2275
|
for (const w of workers) if (w.concurrency !== n) w.concurrency = n;
|
|
1680
2276
|
},
|
|
1681
2277
|
pauseUntil,
|
|
2278
|
+
// Issue #761: whether the pause windows file on disk was written by a newer pi-dispatch (start.mjs keeps it beside
|
|
2279
|
+
// the last good windows). Every job defers `newer-release` at the pause gate while it is.
|
|
2280
|
+
pauseWindowsNewer,
|
|
2281
|
+
// Issue #761: whether the triggers file on disk was written by a newer pi-dispatch (start.mjs keeps it beside the
|
|
2282
|
+
// running schedules). Every job defers `newer-release` at the processor's head while it is.
|
|
2283
|
+
triggersNewer,
|
|
1682
2284
|
// SHARED across both workers, and that sharing is the point rather than an optimisation: the
|
|
1683
2285
|
// folder mutex, the per-scope ceiling and the wait-check lease all bound the HOST, so two
|
|
1684
2286
|
// independent maps would double every one of them exactly as two Workers double concurrency.
|
|
@@ -1767,8 +2369,11 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1767
2369
|
// what a stranded loop leaks is the rest of the list: `registry.close()` is the DEL that keeps a
|
|
1768
2370
|
// stopped host from lingering as a ghost peer for its full TTL, and a ghost peer with a stale
|
|
1769
2371
|
// `fpCron` is what makes a later `reconcileGated` refuse a legitimate reconcile. The comment above
|
|
1770
|
-
// promised this isolation before the code delivered it (issue #295).
|
|
1771
|
-
// closer that never settles still blocks exit,
|
|
2372
|
+
// promised this isolation before the code delivered it (issue #295). The loop itself bounds nothing:
|
|
2373
|
+
// a closer that never settles still blocks exit, so each closer bounds its own wait. The queues
|
|
2374
|
+
// `start.mjs` hands over did not until issue #813: each one's close waited for a start that never
|
|
2375
|
+
// comes when nothing answers VALKEY_URL, so this stop waited for its supervisor's kill. They now wait
|
|
2376
|
+
// a bounded time for the start and for the close, then disconnect (`closeQueueSettled`, `settleCloseOf`).
|
|
1772
2377
|
//
|
|
1773
2378
|
// Read LATE and deliberately: `start.mjs` pushes its live-edit watchers into this array AFTER handing
|
|
1774
2379
|
// it over, because they are armed after the boot reconcile. Anything here that snapshots or copies
|