@edgehero/pi-dispatch 4.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/index.mjs
CHANGED
|
@@ -1,27 +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";
|
|
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";
|
|
25
40
|
|
|
26
41
|
|
|
27
42
|
export const QUEUE = "pi-jobs";
|
|
@@ -48,11 +63,18 @@ export const REJECTED_SEEN_MAX = 1000;
|
|
|
48
63
|
// docker daemon bounds any herd by its own concurrency, and a contended wake just re-defers.
|
|
49
64
|
export const SCOPE_BUSY_RECHECK_MS = 5_000;
|
|
50
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
|
+
|
|
51
72
|
// The endpoint-busy re-check (issue #503): a job whose model server has every slot held re-tests on a fixed
|
|
52
73
|
// cadence, for the scope re-check's reason (a held slot has no natural "until"). Its own value rather than a
|
|
53
|
-
// borrow of 5s
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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.
|
|
56
78
|
export const ENDPOINT_BUSY_RECHECK_MS = 7_000;
|
|
57
79
|
|
|
58
80
|
// How long a job waits before re-asking whether a target's holder is still alive (issue #230). Reached only
|
|
@@ -72,9 +94,10 @@ const THROTTLE_ALARM = 5;
|
|
|
72
94
|
|
|
73
95
|
// The floor under a throttled or aborted re-ask. Its own constant rather than a borrow of
|
|
74
96
|
// SUPERSEDE_RECHECK_MS, which documents an unrelated concern. Deliberately NOT 5s: that is
|
|
75
|
-
// SCOPE_BUSY_RECHECK_MS, and INT-WAIT-PROFILES-CONTRACT
|
|
76
|
-
//
|
|
77
|
-
// 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.
|
|
78
101
|
const THROTTLE_FLOOR_MS = 11_000;
|
|
79
102
|
|
|
80
103
|
/**
|
|
@@ -86,6 +109,13 @@ const THROTTLE_FLOOR_MS = 11_000;
|
|
|
86
109
|
*/
|
|
87
110
|
const ABORT_GRACE_MS = 30_000;
|
|
88
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
|
+
|
|
89
119
|
/**
|
|
90
120
|
* Resolve `run` normally, but stop waiting once the abort has fired and the grace has passed.
|
|
91
121
|
*
|
|
@@ -109,9 +139,21 @@ function boundAfterAbort(run, signal, job, log, graceMs = ABORT_GRACE_MS, onStop
|
|
|
109
139
|
if (settled) return;
|
|
110
140
|
settled = true;
|
|
111
141
|
log("stop_did_not_take", { job: job.id, graceMs });
|
|
112
|
-
// Issue #596, phase 2: the container may still run, so its host budget hold must outlive this job.
|
|
113
|
-
|
|
114
|
-
|
|
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 }));
|
|
115
157
|
}, graceMs);
|
|
116
158
|
// A boot-blocking handle is not wanted here: the worker should be able to exit if everything else
|
|
117
159
|
// has finished, and this timer only matters while a job is still in flight.
|
|
@@ -144,7 +186,9 @@ function boundAfterAbort(run, signal, job, log, graceMs = ABORT_GRACE_MS, onStop
|
|
|
144
186
|
* runs inside runJob against the freshly passed caps (CONST-BUDGET-BEFORE-TOKENS).
|
|
145
187
|
*
|
|
146
188
|
* The read happens once per pickup, right after the scope gate and before the model endpoint gate (issue #503),
|
|
147
|
-
* 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.
|
|
148
192
|
*/
|
|
149
193
|
/**
|
|
150
194
|
* A job's endpoint set (issues #503, #502): the endpoints its main model AND every model on its effective allowed
|
|
@@ -217,6 +261,11 @@ export function makeRunningJobs() {
|
|
|
217
261
|
entries.set(next, { ...entry });
|
|
218
262
|
return next;
|
|
219
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
|
+
},
|
|
220
269
|
/** Removes one pickup's entry; true only the first time. */
|
|
221
270
|
remove(key) {
|
|
222
271
|
return entries.delete(key);
|
|
@@ -227,13 +276,44 @@ export function makeRunningJobs() {
|
|
|
227
276
|
};
|
|
228
277
|
}
|
|
229
278
|
|
|
230
|
-
|
|
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 }) {
|
|
231
292
|
// The lost-lock gate's rejection lines, said ONCE per job id, stall count and attempt. The gate runs before every
|
|
232
293
|
// deferral (pause window, wait, scope or endpoint busy), and BullMQ never resets a job's stall count, so a stalled
|
|
233
294
|
// scheduled job whose record is refused meets the gate again on every deferred pickup: a 30-minute scope-busy hold
|
|
234
295
|
// is about 360 of them. The verdict cannot change between them (same record, same attempt), so one line says it.
|
|
235
296
|
// Bounded, oldest out first, so a long-lived worker holds at most REJECTED_SEEN_MAX keys.
|
|
236
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) });
|
|
237
317
|
const firstRejection = (key) => {
|
|
238
318
|
if (rejectedSeen.has(key)) return false;
|
|
239
319
|
rejectedSeen.add(key);
|
|
@@ -265,51 +345,187 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
265
345
|
};
|
|
266
346
|
const record = await settledRecord(job.id, { attempt, since: job.timestamp, onReject });
|
|
267
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
|
+
}
|
|
268
357
|
deps?.log?.("job_lost_lock_after_completion", { jobId: job.id, outcome: record.outcome, ...(record.reason ? { reason: record.reason } : {}) });
|
|
269
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 };
|
|
270
359
|
}
|
|
271
360
|
}
|
|
272
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
|
+
|
|
273
448
|
// THE EARLIER ATTEMPTS' SLOT TIME (issue #599): a retry, or a pickup after a stall, writes its record over the one
|
|
274
449
|
// before it (one file and one mirror key per job id), so that record is read NOW, before anything here can replace
|
|
275
450
|
// it, and its slot interval (with the ones it carried) rides every record this pickup writes (`earlier`). Only on
|
|
276
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 }));
|
|
277
469
|
let earlier = null;
|
|
470
|
+
let priorAttemptRecord = null;
|
|
278
471
|
if (typeof previousRecord === "function" && ((Number.isInteger(job.attemptsMade) && job.attemptsMade > 0) || Number(job.stalledCounter) > 0)) {
|
|
279
472
|
try {
|
|
280
|
-
|
|
473
|
+
const read = await previousRecord(job.id, { keep: ownPrior });
|
|
474
|
+
priorAttemptRecord = ownPrior(read) ? read : null;
|
|
475
|
+
earlier = earlierFrom(priorAttemptRecord);
|
|
281
476
|
} catch {
|
|
477
|
+
priorAttemptRecord = null;
|
|
282
478
|
earlier = null;
|
|
283
479
|
}
|
|
284
480
|
}
|
|
285
481
|
|
|
286
482
|
// Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): if this job's folder/repo is inside an active pause
|
|
287
483
|
// window, DEFER it to the window end via BullMQ's delayed set -- the job keeps its identity/dedup and
|
|
288
|
-
// auto-resumes when re-picked. This is FIRST, before the kill timer, the settings read, and
|
|
289
|
-
// reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
|
|
290
|
-
// (CONST-BUDGET-BEFORE-TOKENS). `
|
|
291
|
-
//
|
|
292
|
-
//
|
|
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.
|
|
293
489
|
// One clock snapshot for both the window lookup and the guard, so they cannot disagree across the
|
|
294
490
|
// call (and a test can inject a fixed clock). `now` defaults to the real wall clock in production.
|
|
295
491
|
const nowMs = now();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 });
|
|
300
522
|
}
|
|
523
|
+
const until = pauseUntil(job.data, nowMs);
|
|
524
|
+
if (until && until > nowMs + 1000) await deferTo(job, token, until, "pause-window", { project });
|
|
301
525
|
|
|
302
|
-
// The limits snapshot
|
|
303
|
-
//
|
|
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).
|
|
304
528
|
const limits = scopedLimits();
|
|
305
|
-
// The job's project (issue #499, INT-PROJECTS-FILE-CONTRACT), resolved ONCE here from one read of the projects ref,
|
|
306
|
-
// beside the limits snapshot and for its reason: the gate, the ledger and the record agree for this attempt,
|
|
307
|
-
// whatever an operator does to projects.json mid-run. A retry or a deferral is a new pickup and resolves again.
|
|
308
|
-
// Every record from the never-fits check on carries it (the refusal there by hand, every one past the wait gate
|
|
309
|
-
// through `recordAfterGate`); the wait gate's refusals carry none and are resolved from the live ref (start.mjs).
|
|
310
|
-
// An id or null, never a name.
|
|
311
|
-
const pickupProjects = projects();
|
|
312
|
-
const project = projectOf(job.data, pickupProjects);
|
|
313
529
|
// THE JOB'S SIZE (issue #596, `job-size.mjs`), resolved ONCE here from the same limits snapshot and pickup project
|
|
314
530
|
// every gate below reads: the project row's memory and CPUs, else the deployment's PI_JOB_MEMORY and PI_JOB_CPUS
|
|
315
531
|
// (`jobSizeEnv`, validated at boot), else 4g and 2. It reaches the container as an ARGUMENT (runJob's `jobSize`,
|
|
@@ -352,12 +568,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
352
568
|
const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false, hostBudget: { memMiB: budgetNow.memMiB, cpuCenti: budgetNow.cpuCenti, hostShare: share } };
|
|
353
569
|
// Above the wait gate, so through the recorder's own arguments rather than the bound one below it: the
|
|
354
570
|
// same pickup project and size, by hand once.
|
|
571
|
+
await settleSteer({ result });
|
|
355
572
|
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), project, size, earlier });
|
|
356
573
|
return result;
|
|
357
574
|
}
|
|
358
575
|
deps?.log?.("job_size_never_fits_here_deferred", { jobId: job.id, project, misfit, delayMs: NEVER_FITS_RECHECK_MS, ...sizeFields });
|
|
359
|
-
await job
|
|
360
|
-
throw new DelayedError();
|
|
576
|
+
await deferTo(job, token, nowMs + NEVER_FITS_RECHECK_MS, "size-never-fits", { project });
|
|
361
577
|
}
|
|
362
578
|
}
|
|
363
579
|
|
|
@@ -394,6 +610,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
394
610
|
// resolver path or a vault topology -- `secret-profile-unknown` sets that rule.
|
|
395
611
|
if (sentence && deps?.comment) await Promise.resolve(deps.comment(job.data, sentence)).catch(() => {});
|
|
396
612
|
const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
613
|
+
await settleSteer({ result });
|
|
397
614
|
recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), earlier });
|
|
398
615
|
return result;
|
|
399
616
|
};
|
|
@@ -463,14 +680,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
463
680
|
// for both; refusing would drop a delivery over a holder that may be gone. So decide
|
|
464
681
|
// nothing: re-defer briefly and ask again once the probe can answer.
|
|
465
682
|
deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
|
|
466
|
-
await job
|
|
467
|
-
throw new DelayedError();
|
|
683
|
+
await deferTo(job, token, nowMs + SUPERSEDE_RECHECK_MS, "wait-supersede", { project, holder: claim.holder });
|
|
468
684
|
}
|
|
469
685
|
if (claim.tookOverFrom) deps?.log?.("wait_lease_taken_over", { jobId: job.id, from: claim.tookOverFrom });
|
|
470
|
-
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 });
|
|
471
687
|
deps?.log?.("wait_deferred", { jobId: job.id, until: new Date(holdUntil).toISOString(), label: waitLabel(job.data) });
|
|
472
|
-
await job
|
|
473
|
-
throw new DelayedError();
|
|
688
|
+
await deferTo(job, token, holdUntil, "wait-after", { project });
|
|
474
689
|
}
|
|
475
690
|
|
|
476
691
|
// TIER 2: the polled conditions. Last, because it is the only part of this gate that spawns a
|
|
@@ -496,7 +711,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
496
711
|
// the lease, so a job that never wins one has no `since`, never reaches the maximum, and
|
|
497
712
|
// re-wakes forever with no record and no bound. There is no deciding check to run first
|
|
498
713
|
// here -- that is the whole condition -- so the bound applies directly.
|
|
499
|
-
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 });
|
|
500
715
|
if (held >= maxWaitMs()) {
|
|
501
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.`);
|
|
502
717
|
}
|
|
@@ -506,8 +721,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
506
721
|
const wait = Math.max(THROTTLE_FLOOR_MS, Math.floor(waitBackoffMs(intervalMs(), held) / 4));
|
|
507
722
|
const delay = wait + Math.floor(wait * 0.1 * random());
|
|
508
723
|
deps?.log?.("wait_check_throttled", { jobId: job.id, delayMs: delay, slots });
|
|
509
|
-
await job
|
|
510
|
-
throw new DelayedError();
|
|
724
|
+
await deferTo(job, token, nowMs + delay, "wait-throttled", { project });
|
|
511
725
|
}
|
|
512
726
|
|
|
513
727
|
// Declared outside the try below because the branches AFTER it read both.
|
|
@@ -536,12 +750,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
536
750
|
checkSlots.release(WAIT_CHECK_KEY);
|
|
537
751
|
const denials = await waitState.noteThrottle(job.id, { denied: true });
|
|
538
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" });
|
|
539
|
-
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 });
|
|
540
754
|
const wait = Math.max(THROTTLE_FLOOR_MS, Math.floor(waitBackoffMs(intervalMs(), held) / 4));
|
|
541
755
|
const delay = wait + Math.floor(wait * 0.1 * random());
|
|
542
756
|
deps?.log?.("wait_check_throttled", { jobId: job.id, delayMs: delay, slots: checkSlotCount(), where: "fleet" });
|
|
543
|
-
await job
|
|
544
|
-
throw new DelayedError();
|
|
757
|
+
await deferTo(job, token, nowMs + delay, "wait-throttled", { project });
|
|
545
758
|
}
|
|
546
759
|
}
|
|
547
760
|
// THE LEASE IS HELD FROM THE `tryAcquire` ABOVE, so every exit from here down must release it.
|
|
@@ -562,8 +775,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
562
775
|
}
|
|
563
776
|
if (claim.retry) {
|
|
564
777
|
deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
|
|
565
|
-
await job
|
|
566
|
-
throw new DelayedError();
|
|
778
|
+
await deferTo(job, token, nowMs + SUPERSEDE_RECHECK_MS, "wait-supersede", { project, holder: claim.holder });
|
|
567
779
|
}
|
|
568
780
|
|
|
569
781
|
await waitState.noteThrottle(job.id, { denied: false }); // granted: the run of denials ends here
|
|
@@ -573,6 +785,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
573
785
|
for (const profile of profiles) {
|
|
574
786
|
checked = profile;
|
|
575
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 });
|
|
576
793
|
if (verdict?.profileUnknown || verdict?.verdict !== "go") break;
|
|
577
794
|
}
|
|
578
795
|
} finally {
|
|
@@ -601,8 +818,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
601
818
|
// fault, or a rolling deploy would spend a job's whole budget on its own restarts and then
|
|
602
819
|
// blame the operator's script for it.
|
|
603
820
|
deps?.log?.("wait_check_aborted", { jobId: job.id, profile: checked });
|
|
604
|
-
await job
|
|
605
|
-
throw new DelayedError();
|
|
821
|
+
await deferTo(job, token, nowMs + THROTTLE_FLOOR_MS, "wait-aborted", { project });
|
|
606
822
|
}
|
|
607
823
|
|
|
608
824
|
if (verdict?.verdict === "hold") {
|
|
@@ -643,10 +859,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
643
859
|
const jittered = base + Math.floor(base * 0.1 * random());
|
|
644
860
|
const remaining = Math.max(0, maxWaitMs() - held);
|
|
645
861
|
const delay = Math.max(1000, Math.min(jittered, remaining));
|
|
646
|
-
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 });
|
|
647
863
|
deps?.log?.("wait_deferred", { jobId: job.id, profile: checked, fault, heldForMs: held, delayMs: delay });
|
|
648
|
-
await job
|
|
649
|
-
throw new DelayedError();
|
|
864
|
+
await deferTo(job, token, nowMs + delay, "wait-hold", { project });
|
|
650
865
|
}
|
|
651
866
|
|
|
652
867
|
// FAIL CLOSED on anything that is not literally go. Everything above tests for a specific
|
|
@@ -660,8 +875,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
660
875
|
deps?.log?.("wait_check_unintelligible", { jobId: job.id, profile: checked });
|
|
661
876
|
await waitState.noteCheck(job.id, { fault: true });
|
|
662
877
|
const base = waitBackoffMs(intervalMs(), held);
|
|
663
|
-
await job
|
|
664
|
-
throw new DelayedError();
|
|
878
|
+
await deferTo(job, token, nowMs + base + Math.floor(base * 0.1 * random()), "wait-unclear", { project });
|
|
665
879
|
}
|
|
666
880
|
|
|
667
881
|
// Every profile answered go. Record the last check so the count bound sees it.
|
|
@@ -683,9 +897,10 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
683
897
|
// not burn re-check wakes) and after the wait gate (a job holding until tomorrow must not sit on a
|
|
684
898
|
// folder while it does), and STRICTLY above the `try` below, like the pause gate and for the same two
|
|
685
899
|
// reasons: a DelayedError thrown inside the try would be converted to UnrecoverableError by the
|
|
686
|
-
// catch, and a
|
|
687
|
-
//
|
|
688
|
-
//
|
|
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
|
|
689
904
|
// shared with `scopedLedgers` below, so the gate and the money ledger cannot disagree mid-job.
|
|
690
905
|
// tryAcquire is a synchronous check-and-increment -- no await between read and take, so Node's
|
|
691
906
|
// single thread makes it atomic at any concurrency -- and the local-folder limit is a structural 1
|
|
@@ -726,6 +941,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
726
941
|
// This pickup's entry in the running jobs (issue #599, phase 2), null until it is admitted below.
|
|
727
942
|
let runningKey = null;
|
|
728
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;
|
|
729
946
|
let name;
|
|
730
947
|
let venue;
|
|
731
948
|
// THE SCOPE HOLDS, in acquire order (issue #499 part B): the repo or folder slot, then the project slot. Each is
|
|
@@ -738,12 +955,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
738
955
|
};
|
|
739
956
|
// THE ENDPOINT HOLDS (issue #503), `{ id, fleet }` each, in id order.
|
|
740
957
|
const endpointHolds = [];
|
|
741
|
-
const releaseEndpointHolds = () => {
|
|
742
|
-
const taken = endpointHolds.splice(0).reverse();
|
|
958
|
+
const releaseEndpointHolds = (subset = null) => {
|
|
959
|
+
const taken = subset === null ? endpointHolds.splice(0).reverse() : subset.splice(0).reverse();
|
|
743
960
|
for (const hold of taken) endpointSlots.release(hold.id);
|
|
744
961
|
return Promise.all(taken.map((hold) => hold.fleet?.release?.()));
|
|
745
962
|
};
|
|
746
|
-
const releaseAllHolds = ({ orphan = false } = {}) => {
|
|
963
|
+
const releaseAllHolds = ({ orphan = false, candidateEndpoints = null } = {}) => {
|
|
964
|
+
if (candidateEndpoints !== null) return releaseEndpointHolds(candidateEndpoints);
|
|
747
965
|
// The running-jobs entry first (issue #599, phase 2): this pickup no longer runs the job, orphan or not.
|
|
748
966
|
if (runningKey !== null) {
|
|
749
967
|
runningJobs.remove(runningKey);
|
|
@@ -769,8 +987,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
769
987
|
if (hostBound && !hostBudget) {
|
|
770
988
|
if (!hostBound.slots.tryAcquire(HOST_SLOT_KEY, hostBound.limit())) {
|
|
771
989
|
deps?.log?.("host_busy_deferred", { jobId: job.id, delayMs: SCOPE_BUSY_RECHECK_MS });
|
|
772
|
-
await job
|
|
773
|
-
throw new DelayedError();
|
|
990
|
+
await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, "host-slot", { project });
|
|
774
991
|
}
|
|
775
992
|
hostHeld = true;
|
|
776
993
|
}
|
|
@@ -799,29 +1016,33 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
799
1016
|
// `capacity` (issue #599) rides the same binding, and is null until the job is admitted below (where `startedAt` is
|
|
800
1017
|
// set): every record of a refusal before that point says, by its null, that the job never held a slot.
|
|
801
1018
|
let capacity = null;
|
|
802
|
-
|
|
1019
|
+
let pickupMaxTurns = null;
|
|
1020
|
+
const recordAfterGate = (args) => recordRun({ ...args, project, size, capacity, earlier, maxTurns: pickupMaxTurns });
|
|
803
1021
|
|
|
804
1022
|
// The MATCHED ROW's scope keys both the in-process slot and the fleet lease (issue #498), the same string
|
|
805
1023
|
// `budgetCapsFor` hashes below and the boot sweeper hashes from the file: a qualified `github:acme/web` row holds
|
|
806
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
|
|
807
1025
|
// job's canonical scope, so the folder mutex is keyed exactly as before.
|
|
808
1026
|
const scope = rowScopeFor(job.data, limits);
|
|
809
|
-
// Give every hold back (scope and host), then defer. Every scope-gate deferral goes through here
|
|
810
|
-
|
|
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) => {
|
|
811
1030
|
// Every hold goes back before we defer, the host slot too: `makeInFlight().release` is not idempotent, so a
|
|
812
1031
|
// slot held across a deferral would be a slot this machine never gets back.
|
|
813
1032
|
await releaseAllHolds();
|
|
814
1033
|
deps?.log?.(fields.event, { jobId: job.id, kind: job.data?.kind === "local" ? "local" : "forge", delayMs: SCOPE_BUSY_RECHECK_MS, ...fields.extra });
|
|
815
|
-
await job
|
|
816
|
-
throw new DelayedError();
|
|
1034
|
+
await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, why, { project, ...(why === "chained" ? { holder: job.data.parentJobId } : {}) });
|
|
817
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";
|
|
818
1039
|
if (scope) {
|
|
819
1040
|
const ceiling = concurrencyFor(job.data, limits);
|
|
820
1041
|
if (!inFlight.tryAcquire(scope, ceiling)) {
|
|
821
1042
|
// Optional-chained: makeProcessor gives `deps` no default and bare wirings pass deps: {}.
|
|
822
1043
|
// The scope itself stays out of the log line (no-pii-in-logs -- a local scope is a full
|
|
823
1044
|
// host path); the delayed count and the job id are what an operator needs to see it.
|
|
824
|
-
await deferScope({ event: "scope_busy_deferred" });
|
|
1045
|
+
await deferScope(scopeWhy, { event: "scope_busy_deferred" });
|
|
825
1046
|
}
|
|
826
1047
|
const repoHold = { key: scope, fleet: null };
|
|
827
1048
|
scopeHolds.push(repoHold);
|
|
@@ -842,7 +1063,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
842
1063
|
// matching row, so a deployment with no scoped-limits file issues no command at all.
|
|
843
1064
|
if (scopeLease && job.data?.kind !== "local" && Number.isFinite(ceiling)) {
|
|
844
1065
|
repoHold.fleet = await scopeLease.acquire(job.id, { slots: ceiling, keyArgs: [hash16(scope)] });
|
|
845
|
-
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" } });
|
|
846
1067
|
}
|
|
847
1068
|
}
|
|
848
1069
|
|
|
@@ -856,13 +1077,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
856
1077
|
const projectRow = projectRowFor(limits, project);
|
|
857
1078
|
if (projectRow && Number.isSafeInteger(projectRow.concurrent)) {
|
|
858
1079
|
if (!inFlight.tryAcquire(projectRow.scope, projectRow.concurrent)) {
|
|
859
|
-
await deferScope({ event: "scope_busy_deferred", extra: { ledger: "project" } });
|
|
1080
|
+
await deferScope("scope-project", { event: "scope_busy_deferred", extra: { ledger: "project" } });
|
|
860
1081
|
}
|
|
861
1082
|
const projectHold = { key: projectRow.scope, fleet: null };
|
|
862
1083
|
scopeHolds.push(projectHold);
|
|
863
1084
|
if (scopeLease) {
|
|
864
1085
|
projectHold.fleet = await scopeLease.acquire(job.id, { slots: projectRow.concurrent, keyArgs: [hash16(projectRow.scope)] });
|
|
865
|
-
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" } });
|
|
866
1087
|
}
|
|
867
1088
|
}
|
|
868
1089
|
|
|
@@ -880,6 +1101,145 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
880
1101
|
settingsThrew = true;
|
|
881
1102
|
settingsError = error;
|
|
882
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
|
+
}
|
|
883
1243
|
|
|
884
1244
|
// THE MODEL ENDPOINT GATE (issue #503, INT-MODEL-ENDPOINTS-FILE-CONTRACT). A declared local model server
|
|
885
1245
|
// has a fixed number of parallel slots, and nothing else bounds how many jobs pile onto it: three per host
|
|
@@ -908,8 +1268,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
908
1268
|
// One snapshot per pickup (the endpoints, the overlay models, the derived set), handed to runJob as
|
|
909
1269
|
// `modelEndpoints` so a later gate reads the same declaration this one leased against. With no endpoints
|
|
910
1270
|
// declared the overlay is not even read, and the job touches nothing new: no command, no key, no field.
|
|
911
|
-
let endpointSnapshot =
|
|
912
|
-
if (modelEndpoints && !settingsThrew && !settings?.invalid) {
|
|
1271
|
+
let endpointSnapshot = failoverSnapshot;
|
|
1272
|
+
if (failoverSnapshot === null && !selectionError && !selectionRefused && modelEndpoints && !settingsThrew && !settings?.invalid) {
|
|
913
1273
|
let endpoints = [];
|
|
914
1274
|
let models = null;
|
|
915
1275
|
let set = [];
|
|
@@ -940,7 +1300,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
940
1300
|
// ONE hold per endpoint id: a set naming an endpoint twice would take its slot and then wait on itself,
|
|
941
1301
|
// forever on `slots: 1`. Deduplicated before the sort, so the order rule sees each endpoint once.
|
|
942
1302
|
const byId = new Map();
|
|
943
|
-
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);
|
|
944
1304
|
set = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
945
1305
|
}
|
|
946
1306
|
} catch (err) {
|
|
@@ -970,8 +1330,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
970
1330
|
if (where !== null) {
|
|
971
1331
|
await releaseAllHolds();
|
|
972
1332
|
deps?.log?.("endpoint_busy_deferred", { jobId: job.id, endpoint: endpoint.id, where, delayMs: ENDPOINT_BUSY_RECHECK_MS });
|
|
973
|
-
await job
|
|
974
|
-
throw new DelayedError();
|
|
1333
|
+
await deferTo(job, token, nowMs + ENDPOINT_BUSY_RECHECK_MS, "endpoint", { project, endpoint: endpoint.id });
|
|
975
1334
|
}
|
|
976
1335
|
endpointHolds.push({ id: endpoint.id, fleet });
|
|
977
1336
|
}
|
|
@@ -1004,14 +1363,14 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1004
1363
|
await releaseAllHolds();
|
|
1005
1364
|
budgetState.budgetDeferred = true;
|
|
1006
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 });
|
|
1007
|
-
await job
|
|
1008
|
-
throw new DelayedError();
|
|
1366
|
+
await deferTo(job, token, nowMs + BUDGET_RECHECK_MS, "host-budget", { project, budgetWhy: verdict.why });
|
|
1009
1367
|
}
|
|
1010
1368
|
budgetHeld = true;
|
|
1011
1369
|
}
|
|
1012
1370
|
|
|
1013
1371
|
let startedAt;
|
|
1014
1372
|
let timer;
|
|
1373
|
+
let subscriptionDeadlineAt = null;
|
|
1015
1374
|
let cancelPoll;
|
|
1016
1375
|
let cancelPolling = false;
|
|
1017
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
|
|
@@ -1068,7 +1427,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1068
1427
|
capacity = capacityNow();
|
|
1069
1428
|
// This host runs the job from here on (issue #599, phase 2): its entry, at the same instant as `startedAt`, given
|
|
1070
1429
|
// back by `releaseAllHolds` on every exit. `add` is total, so this block still cannot throw.
|
|
1071
|
-
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) });
|
|
1072
1431
|
// The producer of the name both boot reapers sweep by substring. Built from the shared prefix
|
|
1073
1432
|
// rather than typed here, so a rename cannot land in the producer and not in the sweeps (#227).
|
|
1074
1433
|
// From the VENUE that will build the container, not from the local adapter reached for directly:
|
|
@@ -1104,6 +1463,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1104
1463
|
if (err?.code !== BACKEND_NOT_REGISTERED) throw err;
|
|
1105
1464
|
name = null;
|
|
1106
1465
|
}
|
|
1466
|
+
subscriptionDeadlineAt = now() + timeoutMs;
|
|
1107
1467
|
timer = setTimeout(() => {
|
|
1108
1468
|
// BullMQ has no per-job kill timer; this is ours. cancelJob raises the AbortSignal.
|
|
1109
1469
|
Promise.resolve(cancelJob(job.id, "job-timeout-30m")).catch(() => {});
|
|
@@ -1145,7 +1505,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1145
1505
|
cancelPolling = true;
|
|
1146
1506
|
cancelTick = (async () => {
|
|
1147
1507
|
try {
|
|
1148
|
-
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;
|
|
1149
1511
|
if (req === null || req === undefined) return;
|
|
1150
1512
|
// Stopped while this read was in flight: the outcome is being decided, so this request is left
|
|
1151
1513
|
// unanswered (its requester's timeout re-reads the job), never acknowledged after the fact.
|
|
@@ -1153,12 +1515,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1153
1515
|
// Bound to this worker (the injection comment in createWorker): `false` means the job
|
|
1154
1516
|
// left the tracked map in this same instant, in which case the finally below clears
|
|
1155
1517
|
// this interval anyway and the request's TTL reaps the key.
|
|
1156
|
-
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);
|
|
1157
1519
|
if (took === false) return;
|
|
1158
1520
|
// Ack first, then consume the request: losing the DEL to a blip costs one redundant
|
|
1159
1521
|
// re-read, losing the ack costs the operator a false "nobody answered".
|
|
1160
|
-
await redis.set(cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS).catch(() => {});
|
|
1161
|
-
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(() => {});
|
|
1162
1524
|
clearInterval(cancelPoll);
|
|
1163
1525
|
} catch {
|
|
1164
1526
|
// Fail open: the next tick re-asks.
|
|
@@ -1179,9 +1541,27 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1179
1541
|
throw error;
|
|
1180
1542
|
}
|
|
1181
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
|
+
|
|
1182
1555
|
try {
|
|
1183
1556
|
// The read itself happened once, above the endpoint gate; its throw lands HERE, where it always did.
|
|
1184
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
|
+
}
|
|
1185
1565
|
if (settings.invalid) {
|
|
1186
1566
|
// A present-but-invalid overlay is a POLICY refusal, RETURNED (never thrown) so BullMQ marks the
|
|
1187
1567
|
// job completed and does not retry a file that can never parse (CONST-RETRY-INFRA-ONLY). Resolved
|
|
@@ -1191,6 +1571,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1191
1571
|
// refusals are the others): each is decided before or during the settings read, so no honest effective
|
|
1192
1572
|
// value exists yet -- buildRecord defaults both null.
|
|
1193
1573
|
const result = { outcome: "policy", reason: "settings-overlay-invalid", exitCode: null, turns: null, tokens: null, budgetReserved: false };
|
|
1574
|
+
await settleSteer({ result });
|
|
1194
1575
|
recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
1195
1576
|
return result;
|
|
1196
1577
|
}
|
|
@@ -1207,7 +1588,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1207
1588
|
// values reserveBudget checks, never when it runs.
|
|
1208
1589
|
// The one call that logs a malformed queued cap (#501): the endpoint gate above computes the same job and
|
|
1209
1590
|
// stays quiet, so it is reported once per pickup.
|
|
1210
|
-
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;
|
|
1211
1593
|
|
|
1212
1594
|
// THE ENVELOPE (issue #504 part B, DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE). With one loaded, every job is governed:
|
|
1213
1595
|
// its project's, or `_other`'s, share of the applied split narrows the dollar ledgers below, read ONCE here
|
|
@@ -1235,7 +1617,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1235
1617
|
}
|
|
1236
1618
|
|
|
1237
1619
|
const result = await runJob(effectiveJob, {
|
|
1620
|
+
queueJobId: String(job.id),
|
|
1621
|
+
deploymentMaxCostUsd: settings.maxCostUsd,
|
|
1622
|
+
rawApprovalJob: job.data,
|
|
1238
1623
|
redis,
|
|
1624
|
+
// Issue #761: the tag of the Valkey the dollar counters are in, for the record's `dollars.reservedIn`.
|
|
1625
|
+
valkeyTag,
|
|
1239
1626
|
// The three spend windows (week/month null when disabled) and the soft-hold band, resolved this
|
|
1240
1627
|
// job-start under overlay > env. reserveBudget checks them before the container.
|
|
1241
1628
|
caps: { day: settings.dailyCap, week: settings.weeklyCap, month: settings.monthlyCap },
|
|
@@ -1261,6 +1648,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1261
1648
|
// folder it RESOLVES. The processor asks which project the resolved folder belongs to, from the same projects
|
|
1262
1649
|
// snapshot, and refuses before any reserve when it is another project's.
|
|
1263
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,
|
|
1264
1654
|
folderProject: (folder) => projectOf({ kind: "local", folder }, pickupProjects),
|
|
1265
1655
|
// Issue #596: the size resolved at pickup above, for the retained run's manifest and the container's argv.
|
|
1266
1656
|
jobSize: size,
|
|
@@ -1280,12 +1670,19 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1280
1670
|
// runJob context is unchanged.
|
|
1281
1671
|
...(endpointSnapshot ? { modelEndpoints: endpointSnapshot } : {}),
|
|
1282
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
|
+
} : {}),
|
|
1283
1680
|
// Issue #502: the skew check must see `models` as the job ARRIVED, not as `effectiveJobOf` filled it from
|
|
1284
1681
|
// PI_ALLOWED_MODELS, or a trigger list a stale receiver dropped would read as present and the job would run on
|
|
1285
1682
|
// the deployment's list. Only when the wiring supplies the check, so a bare processor keeps the default.
|
|
1286
1683
|
// The same for `maxCostUsd` (#501): `effectiveJobOf` keeps the arrived value under its own key and writes the
|
|
1287
1684
|
// resolved cap as `maxCostMicros`, but it is read off `job.data` here too, so no later fill can hide a drop.
|
|
1288
|
-
...(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) } : {}),
|
|
1289
1686
|
// #227. BOUNDED AFTER THE ABORT, and this is what makes `abortable` an honest declaration.
|
|
1290
1687
|
//
|
|
1291
1688
|
// `makeRunContainer`'s promise settles ONLY on the docker child's `close` or `error`. Nothing
|
|
@@ -1317,16 +1714,45 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1317
1714
|
// left for a cancel to stop. A cancel acknowledged before that moment raced the exit and lost nothing it was
|
|
1318
1715
|
// promised: the run is recorded as that cancel (the existing path, "partial work may exist") even when the
|
|
1319
1716
|
// container happened to exit on its own, because the operator was told the record would say operator-cancel.
|
|
1320
|
-
runContainer: (ctx) =>
|
|
1321
|
-
|
|
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, () => {
|
|
1322
1728
|
stopDidNotTake = true;
|
|
1729
|
+
return finishLive?.("unknown");
|
|
1323
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;
|
|
1324
1749
|
await stopCancelPoll();
|
|
1325
|
-
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);
|
|
1326
1751
|
if (raced) deps.log?.("cancel_acked_as_container_exited", { jobId: job.id, exitCode: r.code ?? null });
|
|
1327
1752
|
const run = raced ? { ...r, aborted: true } : r;
|
|
1328
1753
|
return run?.aborted ? { ...run, abortReason: typeof signal.reason === "string" ? signal.reason : null } : run;
|
|
1329
|
-
})
|
|
1754
|
+
});
|
|
1755
|
+
},
|
|
1330
1756
|
// REQ-TRIGGER-SECRETS. The resolver runs INSIDE the 30-minute kill timer armed above, so it has
|
|
1331
1757
|
// to be abortable for the same reason runContainer does: a resolver blocking on an unreachable
|
|
1332
1758
|
// vault would otherwise hold its slot until its own timeout, and an abort landing mid-resolution
|
|
@@ -1338,12 +1764,23 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1338
1764
|
// tunables above. It is bound here rather than at construction for the reason the overlay exists:
|
|
1339
1765
|
// an operator who declares a profile in the panel must not have to restart the worker.
|
|
1340
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 ?? {} }) } } : {}),
|
|
1341
1770
|
// collectChain (INT-OUTBOX-CONTRACT) reads the completed parent's REAL BullMQ job: its `.id`
|
|
1342
1771
|
// (the parent id children carry) and `.data` (kind/chainDepth). runJob's own `job` is the
|
|
1343
1772
|
// effectiveJob -- a spread of job.data with no `.id`/`.data` -- so inject the real wrapper here,
|
|
1344
1773
|
// mirroring the name/signal injection above. Omitted when unwired so a bare processor falls back
|
|
1345
1774
|
// to runJob's no-op default (a chain fault can never flip a completed outcome either way).
|
|
1346
1775
|
...(deps.collectChain ? { collectChain: (ctx) => deps.collectChain({ ...ctx, job }) } : {}),
|
|
1776
|
+
// DES-AWAIT-INPUT-ENDS-THE-JOB: a parked run's semantic window is closed on the REAL BullMQ job, which holds the
|
|
1777
|
+
// dedup id the add stored and the compare-and-delete that frees only a key it still owns (queue.mjs). Bound
|
|
1778
|
+
// unconditionally, AFTER the `...deps` spread like the injections around it: whether the key may outlive a park
|
|
1779
|
+
// is not a choice a wiring gets to make by omission.
|
|
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),
|
|
1347
1784
|
// The plan collector (issue #505) the same way and for the same reason: the writer it records is the REAL job's
|
|
1348
1785
|
// `.id`, and the authority it checks is `.data` as queued. The pickup's `portfolio` decision rides in `ctx`.
|
|
1349
1786
|
...(deps.collectPlan ? { collectPlan: (ctx) => deps.collectPlan({ ...ctx, job }) } : {}),
|
|
@@ -1362,6 +1799,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1362
1799
|
// The processor tests inject `prepareWorkspace` directly and never see this wrapper, which is
|
|
1363
1800
|
// why nothing caught it (REQ-RESUMABLE-SESSION).
|
|
1364
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,
|
|
1365
1803
|
// The one-shot pre-spend check (issue #231) needs the REAL BullMQ job's `.id` to excuse this
|
|
1366
1804
|
// delivery's own earlier attempt -- runJob's effectiveJob has no `.id`, prepareWorkspace's
|
|
1367
1805
|
// own injection above states why, and this one mirrors it. Omitted when unwired so a bare
|
|
@@ -1370,6 +1808,15 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1370
1808
|
// this one no options today, so there is nothing to lose yet -- and the day it does, a
|
|
1371
1809
|
// replacing wrapper would lose it in the same silence.
|
|
1372
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) }),
|
|
1373
1820
|
});
|
|
1374
1821
|
// Gate round 4 of PR #479: stopped again as runJob returns, before anything is recorded (a result with no
|
|
1375
1822
|
// container never passed the exit stop above). A cancel that lands after is never acknowledged, and its
|
|
@@ -1378,10 +1825,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1378
1825
|
// the cancel the operator was told of, the result it replaced named in the log.
|
|
1379
1826
|
await stopCancelPoll();
|
|
1380
1827
|
let outcome = result;
|
|
1381
|
-
if (signal.aborted === true &&
|
|
1828
|
+
if (signal.aborted === true && ["operator-cancel", "agent-cancel"].includes(signal.reason) && result?.reason !== signal.reason) {
|
|
1382
1829
|
deps.log?.("cancel_acked_before_result", { jobId: job.id, was: `${result?.outcome}/${result?.reason ?? ""}` });
|
|
1383
|
-
outcome = { ...result, outcome: "policy", reason:
|
|
1830
|
+
outcome = { ...result, outcome: "policy", reason: signal.reason };
|
|
1384
1831
|
}
|
|
1832
|
+
await settleSteer({ result: outcome });
|
|
1385
1833
|
recordAfterGate({ job, result: outcome, startedAt, endedAt: new Date().toISOString() });
|
|
1386
1834
|
return outcome;
|
|
1387
1835
|
} catch (error) {
|
|
@@ -1389,8 +1837,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1389
1837
|
// delayed set WITHOUT spending an attempt, the pause gate's move, since a service held up past one retry backoff
|
|
1390
1838
|
// failed a job that heals by itself (measured). Bounded by the hold's own start, stored on the job because a
|
|
1391
1839
|
// deferral leaves `attemptsMade` alone and a worker restart must not reset it; past the bound it fails for good
|
|
1392
|
-
// with its own reason token, whose comment names the restart.
|
|
1393
|
-
//
|
|
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.
|
|
1394
1843
|
//
|
|
1395
1844
|
// Gate round 3: the hour counts only while the worker kept checking. A check that comes more than two recheck
|
|
1396
1845
|
// periods after the last one (the queue was paused, `pi-dispatch pause`, or no worker ran) starts the hold
|
|
@@ -1409,22 +1858,25 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1409
1858
|
// so it survives the restart.
|
|
1410
1859
|
// `readPending: false` asks only whether the poll ALREADY acknowledged a cancel (the signal), and leaves a request it
|
|
1411
1860
|
// had not read unanswered.
|
|
1861
|
+
let cancellationReason = signal?.reason === "agent-cancel" ? "agent-cancel" : "operator-cancel";
|
|
1412
1862
|
const operatorCancelled = async ({ readPending = true } = {}) => {
|
|
1413
|
-
if (signal?.aborted === true) return
|
|
1863
|
+
if (signal?.aborted === true) return ["operator-cancel", "agent-cancel"].includes(signal.reason);
|
|
1414
1864
|
if (!readPending) return false;
|
|
1415
1865
|
if (typeof redis?.get !== "function") return false;
|
|
1416
1866
|
let req = null;
|
|
1417
1867
|
try {
|
|
1418
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"; }
|
|
1419
1870
|
} catch {
|
|
1420
1871
|
return false;
|
|
1421
1872
|
}
|
|
1422
1873
|
if (req === null || req === undefined) return false;
|
|
1423
|
-
await Promise.resolve(redis.set?.(cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS)).catch(() => {});
|
|
1424
|
-
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(() => {});
|
|
1425
1876
|
return true;
|
|
1426
1877
|
};
|
|
1427
|
-
// 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
|
|
1428
1880
|
// says the processor's own operator-cancel sentence; one that never started says it was cancelled before it did.
|
|
1429
1881
|
// The session rides along as the aborted-container path keeps it (`mergeSession`, already applied to the throw).
|
|
1430
1882
|
// A NON-retryable error (gate round 3) is not known to have started nothing, so it says the processor's own
|
|
@@ -1432,9 +1884,10 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1432
1884
|
const endCancelled = async ({ retryable }) => {
|
|
1433
1885
|
const spent = error?.budgetReserved === true;
|
|
1434
1886
|
const beforeStart = retryable && !spent;
|
|
1435
|
-
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 } : {}) };
|
|
1436
1888
|
deps?.log?.("job_cancelled_instead_of_retry", { jobId: job.id, spent, retryable, ...(retryable ? {} : { failure: scrubCredentials(String(error?.message ?? error)).slice(0, 300) }) });
|
|
1437
|
-
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 });
|
|
1438
1891
|
recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
|
|
1439
1892
|
return result;
|
|
1440
1893
|
};
|
|
@@ -1455,7 +1908,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1455
1908
|
// other recorded. A retryable error reads that request too, since the job would otherwise run again.
|
|
1456
1909
|
await stopCancelPoll();
|
|
1457
1910
|
const retryable = error instanceof InfraRetry;
|
|
1458
|
-
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
|
+
}
|
|
1459
1918
|
if (error?.holdUntilRestart === true) {
|
|
1460
1919
|
const heldAt = now();
|
|
1461
1920
|
const last = job.data?.podmanRestartHoldLastMs;
|
|
@@ -1464,13 +1923,47 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1464
1923
|
if (heldAt - since < PODMAN_RESTART_HOLD_MAX_MS) {
|
|
1465
1924
|
await job.updateData({ ...job.data, podmanRestartHoldSinceMs: since, podmanRestartHoldLastMs: heldAt });
|
|
1466
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) });
|
|
1467
|
-
await job
|
|
1468
|
-
throw new DelayedError();
|
|
1926
|
+
await deferTo(job, token, heldAt + PODMAN_RESTART_HOLD_RECHECK_MS, "podman-restart", { project });
|
|
1469
1927
|
}
|
|
1470
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 });
|
|
1471
1930
|
recordAfterGate({ job, error: expired, startedAt, endedAt: new Date().toISOString() });
|
|
1472
1931
|
throw expired;
|
|
1473
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
|
+
}
|
|
1474
1967
|
// Issue #476: a job whose egress preflight found the rootless network keeper running on its own bridge but younger
|
|
1475
1968
|
// than the minimum age is HELD until it is old enough (one `moveToDelayed`, no attempt), the move the hold above
|
|
1476
1969
|
// makes, since a keeper merely young is not a keeper that does not hold: every joint start of the stack read it
|
|
@@ -1487,20 +1980,23 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1487
1980
|
if (!restarted && heldMs < NETNS_KEEPER_YOUNG_HOLD_MAX_MS) {
|
|
1488
1981
|
await job.updateData({ ...job.data, netnsKeeperHoldSinceMs: keeperHold.since, netnsKeeperHoldLastMs: keeperHold.at, netnsKeeperHoldStartedMs: young.startedMs });
|
|
1489
1982
|
deps?.log?.("netns_keeper_young_hold", { jobId: job.id, keeperAgeMs: young.ageMs, heldForMs: heldMs, delayMs: young.waitMs });
|
|
1490
|
-
await job
|
|
1491
|
-
throw new DelayedError();
|
|
1983
|
+
await deferTo(job, token, keeperHold.at + young.waitMs, "netns-keeper", { project });
|
|
1492
1984
|
}
|
|
1493
1985
|
const was = keeperHold.startedMs ?? young.startedMs;
|
|
1494
1986
|
await markKeeperLoop(job, was === young.startedMs ? [was] : [was, young.startedMs]);
|
|
1495
|
-
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 });
|
|
1496
1989
|
recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
|
|
1990
|
+
await noteRetry(job, { project });
|
|
1497
1991
|
throw loop;
|
|
1498
1992
|
}
|
|
1499
1993
|
if (error?.reason === NETNS_KEEPER_NOT_HOLDING && keeperHold.startedMs !== null) {
|
|
1500
1994
|
// The keeper this job was waiting on is no longer running on its bridge: it died young, the loop's other face.
|
|
1501
1995
|
await markKeeperLoop(job, [keeperHold.startedMs]);
|
|
1502
|
-
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 });
|
|
1503
1998
|
recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
|
|
1999
|
+
await noteRetry(job, { project });
|
|
1504
2000
|
throw loop;
|
|
1505
2001
|
}
|
|
1506
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
|
|
@@ -1510,12 +2006,20 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1510
2006
|
// failure of this job after it is still named the loop.
|
|
1511
2007
|
const loopSeen = job.data?.netnsKeeperLoopSeen;
|
|
1512
2008
|
if (error?.reason === NETNS_KEEPER_NOT_HOLDING && Array.isArray(loopSeen) && loopSeen.length > 0) {
|
|
1513
|
-
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 });
|
|
1514
2011
|
recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
|
|
2012
|
+
await noteRetry(job, { project });
|
|
1515
2013
|
throw loop;
|
|
1516
2014
|
}
|
|
2015
|
+
await settleSteer({ error });
|
|
1517
2016
|
recordAfterGate({ job, error, startedAt, endedAt: new Date().toISOString() });
|
|
1518
|
-
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
|
+
}
|
|
1519
2023
|
// A non-retryable, non-infra error (our bug) must not retry forever. UnrecoverableError
|
|
1520
2024
|
// records it as failed-and-distinct in the queue's failed set without a retry.
|
|
1521
2025
|
throw new UnrecoverableError(error.message);
|
|
@@ -1533,16 +2037,119 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1533
2037
|
clearTimeout(timer);
|
|
1534
2038
|
clearInterval(cancelPoll);
|
|
1535
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(() => {});
|
|
1536
2043
|
}
|
|
1537
2044
|
};
|
|
1538
2045
|
return async function processor(job, token, signal) {
|
|
1539
|
-
|
|
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
|
+
}
|
|
1540
2146
|
// The pickup's TICKET: the ledger entry this pickup takes carries it, and only this pickup's release or
|
|
1541
2147
|
// orphan can act on that entry, so a second pickup of the same job id (a stalled scheduled job handed back while
|
|
1542
2148
|
// its first attempt still runs here) can never give back the first's hold.
|
|
1543
2149
|
const budgetState = { budgetDeferred: false, ticket: hostBudget.enter(job.id) };
|
|
1544
2150
|
try {
|
|
1545
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(() => {});
|
|
1546
2153
|
hostBudget.forget(job.id);
|
|
1547
2154
|
return result;
|
|
1548
2155
|
} catch (error) {
|
|
@@ -1563,19 +2170,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
|
|
|
1563
2170
|
*/
|
|
1564
2171
|
export const CANCEL_STOP_BOUND_MS = 1_000;
|
|
1565
2172
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
* record and doctor name both sizes. There is no fleet refusal (gate round 1 of phase 2): a job on the shared
|
|
1570
|
-
* queue waits for a host it fits on.
|
|
1571
|
-
*/
|
|
1572
|
-
export const SIZE_REFUSAL_COMMENTS = Object.freeze({
|
|
1573
|
-
"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.",
|
|
1574
|
-
"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.",
|
|
1575
|
-
});
|
|
1576
|
-
|
|
1577
|
-
/** The comment for a job the operator cancelled before it started, where it would have been held or retried (gate of PR #479). */
|
|
1578
|
-
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 };
|
|
1579
2176
|
|
|
1580
2177
|
/**
|
|
1581
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
|
|
@@ -1602,7 +2199,7 @@ export function keeperHoldState(job, at) {
|
|
|
1602
2199
|
return { at, since, startedMs };
|
|
1603
2200
|
}
|
|
1604
2201
|
|
|
1605
|
-
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() }) {
|
|
1606
2203
|
// One Worker per queue name (issue #57). A host-affine job -- one whose folder, secret resolver or wait
|
|
1607
2204
|
// check lives on THIS machine -- is enqueued to `pi-jobs@<name>` rather than filtered for at pickup,
|
|
1608
2205
|
// because BullMQ has no selective pop and the put-it-back alternative does not work: promotion out of
|
|
@@ -1645,6 +2242,10 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1645
2242
|
for (const queueName of names) {
|
|
1646
2243
|
let worker; // referenced by cancelJob/applyConcurrency before assignment; only called later, so the TDZ is fine
|
|
1647
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),
|
|
1648
2249
|
// Bound to THIS worker: a job on the host queue is cancelled by the worker draining that queue,
|
|
1649
2250
|
// and the shared handle could not reach it.
|
|
1650
2251
|
cancelJob: (id, reason) => worker.cancelJob(id, reason),
|
|
@@ -1660,6 +2261,7 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1660
2261
|
containerName,
|
|
1661
2262
|
redis,
|
|
1662
2263
|
getSettings,
|
|
2264
|
+
readModelRoles,
|
|
1663
2265
|
// Issue #596, phase 2: the ONE host budget. `multiHost` is whether this worker declared a fleet (a host queue):
|
|
1664
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
|
|
1665
2267
|
// deferred for a host that does not exist (gate round 2 of phase 2).
|
|
@@ -1673,6 +2275,12 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1673
2275
|
for (const w of workers) if (w.concurrency !== n) w.concurrency = n;
|
|
1674
2276
|
},
|
|
1675
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,
|
|
1676
2284
|
// SHARED across both workers, and that sharing is the point rather than an optimisation: the
|
|
1677
2285
|
// folder mutex, the per-scope ceiling and the wait-check lease all bound the HOST, so two
|
|
1678
2286
|
// independent maps would double every one of them exactly as two Workers double concurrency.
|
|
@@ -1761,8 +2369,11 @@ export function createWorker({ connection, name, stopContainer, containerName, h
|
|
|
1761
2369
|
// what a stranded loop leaks is the rest of the list: `registry.close()` is the DEL that keeps a
|
|
1762
2370
|
// stopped host from lingering as a ghost peer for its full TTL, and a ghost peer with a stale
|
|
1763
2371
|
// `fpCron` is what makes a later `reconcileGated` refuse a legitimate reconcile. The comment above
|
|
1764
|
-
// promised this isolation before the code delivered it (issue #295).
|
|
1765
|
-
// 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`).
|
|
1766
2377
|
//
|
|
1767
2378
|
// Read LATE and deliberately: `start.mjs` pushes its live-edit watchers into this array AFTER handing
|
|
1768
2379
|
// it over, because they are armed after the boot reconcile. Anything here that snapshots or copies
|