@edgehero/pi-dispatch 4.3.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable runs, the queue half (issue #645): putting a crashed run's continuation on the queue, taking it back off when
|
|
3
|
+
* the host that holds its checkpoint never returns, and the host-local leftovers of a crash (a stale job directory).
|
|
4
|
+
*
|
|
5
|
+
* WHY A NEW JOB AND NOT BULLMQ'S OWN RETRY. A retry re-runs the job from scratch under the same id: a fresh clone, a
|
|
6
|
+
* fresh session, and a second payment for every turn the first attempt already paid for. A recovery continues from
|
|
7
|
+
* the run's last checkpoint, which lives on ONE host (the store is host-local, `INT-WORKSPACE-CHECKPOINT-STORE`), so
|
|
8
|
+
* it has to be routed there, and it has to carry which run it continues. Neither fits the retry: a retry cannot change
|
|
9
|
+
* queue, and its data is the original's. So a recovery is its own job, `${runId}.r${n}`, with the original's data plus
|
|
10
|
+
* `recoveryOf` (the run id, a string) and `recovery` (n, an integer). Nothing ever parses the id back: those two fields
|
|
11
|
+
* carry it.
|
|
12
|
+
*
|
|
13
|
+
* THE ID, by BullMQ's own rule (`customJobIdAllowed`, bullmq-internals.mjs): a custom id containing `:` is refused
|
|
14
|
+
* ("Custom Id cannot contain :") unless it splits into exactly three parts, the legacy repeatable shape, and one that
|
|
15
|
+
* is an integer's spelling is refused too, which a `.rN` suffix never is. The suffix adds no colon either, so every run
|
|
16
|
+
* id the producers mint today maps to an accepted recovery id: the forge and local ids hold none (`gh-<guid>`,
|
|
17
|
+
* `local-<hex>`, `chain-<hex>`), and the cron and manual ids hold exactly two (`repeat:<id>:<ms>`, `manual:<id>:<ms>`),
|
|
18
|
+
* so `repeat:a:b.r1` is three parts. An id of any other colon count is refused HERE, before anything is claimed, as
|
|
19
|
+
* `job-id-refused`, rather than by a throw out of `queue.add` after the claim.
|
|
20
|
+
*
|
|
21
|
+
* NO `deduplication` on the add, deliberately: the original forge job carried a semantic key (`repo#n:flow`, ten
|
|
22
|
+
* minutes), and a recovery is the same target and flow by construction. With the key copied, a crash inside the
|
|
23
|
+
* original's window would have the recovery swallowed by the very job it continues (measured: an add WITH the key
|
|
24
|
+
* returns the original's id; one without it is stored). The custom job id is the only dedup a recovery needs.
|
|
25
|
+
*
|
|
26
|
+
* `attempts: 1`: a recovery is not retried by the queue. A crashed recovery is recovered again (n + 1, up to the cap)
|
|
27
|
+
* by the same path that recovered the original, and that path is what keeps a retry from re-paying a run from zero.
|
|
28
|
+
*
|
|
29
|
+
* Every function here is dependency-injected (redis, queue handles, clock, fs), so the decisions are testable with
|
|
30
|
+
* fakes and the claim race against a real Valkey.
|
|
31
|
+
*/
|
|
32
|
+
import * as nodeFsp from "node:fs/promises";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
import { customJobIdAllowed } from "./bullmq-internals.mjs";
|
|
35
|
+
import { dropModelAttempt, modelAttemptIdOf } from "./model-attempts.mjs";
|
|
36
|
+
import { QUEUE, hostQueueName as defaultHostQueueName } from "./queue.mjs";
|
|
37
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
38
|
+
import { FORGE_JOB_RETENTION_S } from "./retention.mjs";
|
|
39
|
+
import { JOB_DIR_RE, lastCompleteLineEnd, normalizeHost, pointerKeepEntry } from "./run-checkpoint.mjs";
|
|
40
|
+
|
|
41
|
+
/** The most recoveries one run gets: the original, then `.r1`, then `.r2`. A third crash fails the run as before. */
|
|
42
|
+
export const RECOVERY_CAP = 2;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long a queued recovery may wait for its host before the host-lost sweep takes it back off the queue and says so.
|
|
46
|
+
* Six hours: a host that reboots, or a worker that restarts on failure, is back in minutes; one that is still away after
|
|
47
|
+
* a working day's morning is an operator's problem, and the run's issue deserves to be told rather than to wait for the
|
|
48
|
+
* checkpoint's seven-day life. Not tied to that life on purpose: the pointer outliving the recovery is harmless, the
|
|
49
|
+
* reverse would strand a job.
|
|
50
|
+
*/
|
|
51
|
+
export const RECOVERY_TTL_MS = 6 * 3600 * 1000;
|
|
52
|
+
|
|
53
|
+
/** The claim's life: as long as the checkpoint pointer's, so a claim cannot expire under a run that can still recover. */
|
|
54
|
+
export const RECOVERY_CLAIM_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
55
|
+
|
|
56
|
+
/** `run:recover:claim:<sanitized run id>:<n>`, SET NX: exactly one enqueuer wins recovery n of a run. */
|
|
57
|
+
export const RECOVER_CLAIM_PREFIX = "run:recover:claim:";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The longest a recovery may be kept waiting for a host that is still ALIVE (in the host registry) or whose queue is
|
|
61
|
+
* paused: the checkpoint pointer's life, past which there is nothing left to recover from anyway.
|
|
62
|
+
*/
|
|
63
|
+
export const RECOVERY_MAX_WAIT_MS = 7 * 24 * 3600 * 1000;
|
|
64
|
+
|
|
65
|
+
/** How far one extension moves a due recovery's deadline when its host is alive or its queue paused. */
|
|
66
|
+
export const RECOVERY_EXTEND_MS = 3600 * 1000;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per-ATTEMPT hold state the processor writes onto the queue job (`job.updateData` in index.mjs: the rootful Podman
|
|
70
|
+
* restart hold, the rootless network keeper's young hold and its crash-loop marks). Each describes the attempt that
|
|
71
|
+
* wrote it, and copied into a recovery it would make the recovery's first pickup believe it had already been holding
|
|
72
|
+
* (a hold that expires at once, a crash loop counted from the crashed attempt's restarts). Stripped from the recovery's
|
|
73
|
+
* data. `enqueueNonce` is a reporting producer's mark for its own add, meaningless on any other job. A test reads every
|
|
74
|
+
* key index.mjs writes through `updateData` and requires each to be stripped here or kept on purpose
|
|
75
|
+
* (`RECOVERY_KEPT_HOLD_KEYS`), so a new hold field cannot leak in silence.
|
|
76
|
+
*/
|
|
77
|
+
export const RECOVERY_STRIPPED_KEYS = Object.freeze(["podmanRestartHoldSinceMs", "podmanRestartHoldLastMs", "netnsKeeperHoldSinceMs", "netnsKeeperHoldLastMs", "netnsKeeperHoldStartedMs", "netnsKeeperLoopSeen", "enqueueNonce"]);
|
|
78
|
+
|
|
79
|
+
/** Keys index.mjs writes through `updateData` that a recovery deliberately KEEPS. None today; the test reads this list. */
|
|
80
|
+
export const RECOVERY_KEPT_HOLD_KEYS = Object.freeze([]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The recovery's data: the original's, less every per-attempt key, plus the recovery fields: `recoveryOf`, `recovery`,
|
|
84
|
+
* `recoverBy`, and `recoveryOfCreatedAt`, the base run's creation time in millis (BullMQ's `job.timestamp`, issue #811).
|
|
85
|
+
*
|
|
86
|
+
* `recoveryOfCreatedAt` is what lets the recovery tell its own run's settled record from an older job's under the same
|
|
87
|
+
* id (processor.mjs, the superseded check): ids come back after a lost queue and a hand redelivery, while records stay
|
|
88
|
+
* for the retention window. The first recovery takes it from the crashed job itself (`createdAt`, the base run's own
|
|
89
|
+
* timestamp); a recovery of a recovery keeps the one its data carries, never its own job's timestamp, which is later
|
|
90
|
+
* than the base run's start and would make the base run's settled record look older than the run. A `.r1` queued by a
|
|
91
|
+
* build from before this field has none to keep, so its `.r2` carries none either, and its check stays the one without
|
|
92
|
+
* an age. A value that is not a non-negative safe integer is no value (left out), never guessed at.
|
|
93
|
+
*/
|
|
94
|
+
export function recoveryDataOf(data, { runId, n, recoverBy, createdAt = null }) {
|
|
95
|
+
const out = { ...data };
|
|
96
|
+
for (const key of RECOVERY_STRIPPED_KEYS) delete out[key];
|
|
97
|
+
delete out.recoveryOfCreatedAt;
|
|
98
|
+
const continued = typeof data?.recoveryOf === "string" && data.recoveryOf !== "";
|
|
99
|
+
const at = continued ? data.recoveryOfCreatedAt : createdAt;
|
|
100
|
+
return { ...out, recoveryOf: runId, recovery: n, recoverBy, ...(Number.isSafeInteger(at) && at >= 0 ? { recoveryOfCreatedAt: at } : {}) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One sorted set for the fleet: member = the recovery's queue, id, the host holding its checkpoint and when it was
|
|
105
|
+
* enqueued (for the hard maximum); score = its current deadline (`recoverBy`, moved on by each extension).
|
|
106
|
+
*/
|
|
107
|
+
export const RECOVER_PENDING_KEY = "run:recover:pending";
|
|
108
|
+
|
|
109
|
+
/** The queue states a job can still be picked up from, so the host-lost sweep may take it back. */
|
|
110
|
+
const NOT_STARTED_STATES = new Set(["waiting", "delayed", "prioritized", "paused", "waiting-children"]);
|
|
111
|
+
|
|
112
|
+
/** The worker-name charset (config.mjs), duplicated for queue.mjs's reason: a pointer is peer-written data. */
|
|
113
|
+
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What a run writes BESIDE its job directory, swept with it (issue #683): the cidfile (#345), and the openshell venue's
|
|
117
|
+
* policy file and upload and download staging directories (worker/src/backend-openshell.mjs), which a worker that died
|
|
118
|
+
* mid-job never removed. The staging directories hold a copy of the job's inputs (its prompt among them) and of its
|
|
119
|
+
* outputs, so they must not outlive the job dir they were copied from.
|
|
120
|
+
*/
|
|
121
|
+
export const BESIDE_JOB_DIR = Object.freeze([".cid", ".openshell-policy.json", ".openshell-profile.json", ".openshell-up", ".openshell-down", ".openshell-bind"]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The run a queue job belongs to: its own id, or for a recovery the id of the run it continues. Every per-RUN key (the
|
|
125
|
+
* progress key, the live stream, the status comment's id, the checkpoint pointer and store) is spelled from this, never
|
|
126
|
+
* from `job.id`; the run RECORD stays per job id (a `.r1` writes its own, carrying `recoveryOf`).
|
|
127
|
+
*/
|
|
128
|
+
export function runIdOf(job) {
|
|
129
|
+
const of = job?.data?.recoveryOf;
|
|
130
|
+
return typeof of === "string" && of !== "" ? of : job?.id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a job's data arms a recovery (v1: forge jobs only; a local job with recover is `recoverSkipped: "local"`). */
|
|
134
|
+
export function isRecoveryArmed(data) {
|
|
135
|
+
return data?.recover === true && data?.kind !== "local";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The recovery job id; null when bullmq would refuse it (`customJobIdAllowed`: a colon count other than none or two). */
|
|
139
|
+
export function recoveryJobId(runId, n) {
|
|
140
|
+
if (typeof runId !== "string" || runId === "" || !Number.isSafeInteger(n) || n < 1) return null;
|
|
141
|
+
const id = `${runId}.r${n}`;
|
|
142
|
+
return customJobIdAllowed(id) ? id : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The claim key of recovery `n` of `runId`. Sanitized like every other per-run key, so one spelling per run. */
|
|
146
|
+
export const recoveryClaimKey = (runId, n) => `${RECOVER_CLAIM_PREFIX}${sanitizeJobId(runId)}:${n}`;
|
|
147
|
+
|
|
148
|
+
/** The pending set's member, as JSON so no part has to be a separator-free string. */
|
|
149
|
+
export const pendingMember = (queueName, jobId, host, at) => JSON.stringify({ q: queueName, id: jobId, host, at });
|
|
150
|
+
|
|
151
|
+
function parsePendingMember(member) {
|
|
152
|
+
try {
|
|
153
|
+
const m = JSON.parse(String(member));
|
|
154
|
+
if (m && typeof m.q === "string" && m.q !== "" && typeof m.id === "string" && m.id !== "" && typeof m.host === "string" && HOST_RE.test(m.host) && Number.isSafeInteger(m.at)) return m;
|
|
155
|
+
} catch {}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A pointer is peer-written (another host wrote it), so its routing fields are re-checked before they route. */
|
|
160
|
+
function pointerRoute(pointer) {
|
|
161
|
+
if (!pointer || typeof pointer !== "object" || Array.isArray(pointer)) return null;
|
|
162
|
+
if (typeof pointer.host !== "string" || !HOST_RE.test(pointer.host)) return null;
|
|
163
|
+
if (pointer.hostQueue !== null && pointer.hostQueue !== undefined && typeof pointer.hostQueue !== "string") return null;
|
|
164
|
+
return { host: pointer.host, hosted: typeof pointer.hostQueue === "string" && pointer.hostQueue !== "" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const codeOf = (err) => (typeof err?.code === "string" ? err.code : typeof err?.name === "string" ? err.name : "error");
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `makeRecoveryEnqueuer({ redis, queues, hostQueueName, now, log, ttlMs, cap })` -> `enqueueRecovery({ job, cause,
|
|
171
|
+
* pointer })`.
|
|
172
|
+
*
|
|
173
|
+
* - `queues.shared()` is the shared queue's handle, `queues.host(name)` a host queue's by its full name. The recovery
|
|
174
|
+
* goes to `hostQueueName(pointer.host)` when the pointer says its host drains a host queue (`pointer.hostQueue`),
|
|
175
|
+
* else to the shared queue: an unnamed deployment is one host, and its shared queue is drained only by it.
|
|
176
|
+
* - Answers `{ enqueued: true, jobId, n, queue }` or `{ enqueued: false, reason }`; it NEVER throws, because both
|
|
177
|
+
* callers (the processor's infra exit and the stall listener) must fall back to today's path on any failure, loudly,
|
|
178
|
+
* rather than lose the run between the two.
|
|
179
|
+
*
|
|
180
|
+
* THE CLAIM. `SET run:recover:claim:<run>:<n> NX`. The winner adds the job and records it pending. A LOSER does not
|
|
181
|
+
* report "not enqueued": that would send its caller down today's path (a retry from zero beside the recovery, or a
|
|
182
|
+
* failure comment on a run that is continuing). It asks the target queue whether the job exists and, when it does not
|
|
183
|
+
* (the winner crashed between its claim and its add, or is mid-add), adds it itself: the custom id makes that add
|
|
184
|
+
* idempotent (bullmq answers an existing id with the stored job), so two enqueuers can never make two jobs, and a
|
|
185
|
+
* winner that died after claiming cannot strand the run either.
|
|
186
|
+
*/
|
|
187
|
+
export function makeRecoveryEnqueuer({ redis, queues, hostQueueName = defaultHostQueueName, now = () => Date.now(), log = () => {}, ttlMs = RECOVERY_TTL_MS, cap = RECOVERY_CAP, claimTtlMs = RECOVERY_CLAIM_TTL_MS } = {}) {
|
|
188
|
+
if (!redis || typeof queues?.shared !== "function" || typeof queues?.host !== "function") throw new TypeError("makeRecoveryEnqueuer needs redis and queues { shared, host }");
|
|
189
|
+
|
|
190
|
+
return async function enqueueRecovery({ job, cause, pointer } = {}) {
|
|
191
|
+
const data = job?.data;
|
|
192
|
+
if (!data || typeof data !== "object") return { enqueued: false, reason: "no-job" };
|
|
193
|
+
if (data.recover !== true) return { enqueued: false, reason: "not-armed" };
|
|
194
|
+
if (data.kind === "local") return { enqueued: false, reason: "local" };
|
|
195
|
+
const runId = runIdOf(job);
|
|
196
|
+
const prior = Number.isSafeInteger(data.recovery) && data.recovery >= 0 ? data.recovery : 0;
|
|
197
|
+
const n = prior + 1;
|
|
198
|
+
const logged = { jobId: job.id, runId, n, cause: cause === "stall" ? "stall" : "infra" };
|
|
199
|
+
if (n > cap) {
|
|
200
|
+
log("job_recovery_refused", { ...logged, reason: "recovery-cap" });
|
|
201
|
+
return { enqueued: false, reason: "recovery-cap" };
|
|
202
|
+
}
|
|
203
|
+
const route = pointerRoute(pointer);
|
|
204
|
+
if (!route) {
|
|
205
|
+
log("job_recovery_refused", { ...logged, reason: "pointer-invalid" });
|
|
206
|
+
return { enqueued: false, reason: "pointer-invalid" };
|
|
207
|
+
}
|
|
208
|
+
const jobId = recoveryJobId(runId, n);
|
|
209
|
+
if (!jobId) {
|
|
210
|
+
log("job_recovery_refused", { ...logged, reason: "job-id-refused" });
|
|
211
|
+
return { enqueued: false, reason: "job-id-refused" };
|
|
212
|
+
}
|
|
213
|
+
const queueName = route.hosted ? hostQueueName(route.host) : QUEUE;
|
|
214
|
+
const claimKey = recoveryClaimKey(runId, n);
|
|
215
|
+
let claimed = false;
|
|
216
|
+
try {
|
|
217
|
+
claimed = (await redis.set(claimKey, route.host, "PX", claimTtlMs, "NX")) === "OK";
|
|
218
|
+
const queue = route.hosted ? queues.host(queueName) : queues.shared();
|
|
219
|
+
if (!claimed && (await queue.getJob(jobId))) {
|
|
220
|
+
log("job_recovery_enqueued", { ...logged, recoveryJobId: jobId, queue: queueName, claimed: false });
|
|
221
|
+
return { enqueued: true, jobId, n, queue: queueName };
|
|
222
|
+
}
|
|
223
|
+
const at = now();
|
|
224
|
+
const recoverBy = at + ttlMs;
|
|
225
|
+
// `job` is the REAL queue job on both callers (the processor's infra exit is bound to it in index.mjs, the stall
|
|
226
|
+
// listener is handed it by BullMQ), so its `timestamp` is the crashed job's creation time.
|
|
227
|
+
const recoveryData = recoveryDataOf(data, { runId, n, recoverBy, createdAt: job.timestamp });
|
|
228
|
+
await queue.add(job.name ?? data.kind, recoveryData, {
|
|
229
|
+
jobId,
|
|
230
|
+
attempts: 1,
|
|
231
|
+
// The forge jobs' retention (queue.mjs enqueueForgeJob), so a recovery is as visible as the run it continues.
|
|
232
|
+
removeOnComplete: { age: FORGE_JOB_RETENTION_S },
|
|
233
|
+
removeOnFail: { age: FORGE_JOB_RETENTION_S },
|
|
234
|
+
});
|
|
235
|
+
// Whoever ADDED records it pending, the winner or a loser that found the job missing: a loser's add is the only
|
|
236
|
+
// add a stranded run gets, and without its member the host-lost backstop would never look at it. Two members for
|
|
237
|
+
// one job (a winner mid-add and a loser) are harmless: the second the sweep reads finds the job gone or started.
|
|
238
|
+
try {
|
|
239
|
+
await redis.zadd(RECOVER_PENDING_KEY, recoverBy, pendingMember(queueName, jobId, route.host, at));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
// The job is queued; only the host-lost backstop is missing for it. Said, never swallowed.
|
|
242
|
+
log("job_recovery_pending_failed", { ...logged, recoveryJobId: jobId, code: codeOf(err) });
|
|
243
|
+
}
|
|
244
|
+
log("job_recovery_enqueued", { ...logged, recoveryJobId: jobId, queue: queueName, claimed });
|
|
245
|
+
return { enqueued: true, jobId, n, queue: queueName };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Release a claim this call took, so the caller's fallback (an InfraRetry, whose next attempt reaches here
|
|
248
|
+
// again with the same n) is not answered "already claimed" for a job that was never added.
|
|
249
|
+
if (claimed) await Promise.resolve(redis.del(claimKey)).catch(() => {});
|
|
250
|
+
log("job_recovery_enqueue_failed", { ...logged, code: codeOf(err) });
|
|
251
|
+
return { enqueued: false, reason: "enqueue-failed" };
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The host-lost sweep: a recovery that is still waiting past its `recoverBy`, whose host is GONE, is taken off the queue,
|
|
258
|
+
* and `onLost(job)` tells its run (the start wiring posts the `recovery-host-lost` comment and fires on_failure).
|
|
259
|
+
*
|
|
260
|
+
* A DEADLINE IS NOT A VERDICT. A recovery can wait past it on a host that is perfectly alive: a scoped pause window
|
|
261
|
+
* delays it, an operator paused the queue, a saturated host has no slot. Failing those runs with "the worker has not
|
|
262
|
+
* come back" would be a false comment and a false page. So at the deadline the sweep asks: is the checkpoint's host in
|
|
263
|
+
* the host registry (`liveHosts()`, its heartbeat rows, a Set of names, or null when the registry could not be read), or
|
|
264
|
+
* is the queue paused? Either, or a registry it could not read (a Valkey blip is not a lost host), EXTENDS the deadline
|
|
265
|
+
* by `extendMs` instead. Only a host absent from a readable registry is lost, and the extensions stop at the hard
|
|
266
|
+
* maximum (`maxWaitMs` after the enqueue, the checkpoint pointer's life): past it the run is lost whatever the host says,
|
|
267
|
+
* since there is nothing left to recover from.
|
|
268
|
+
*
|
|
269
|
+
* Fleet-safe without a lock: each due member is CLAIMED by its `ZREM` (1 for exactly one caller), so two hosts sweeping
|
|
270
|
+
* at once act on disjoint members. A member whose handling faults after its claim is put back with its own score, so the
|
|
271
|
+
* next sweep tries it again rather than the run waiting for ever with nothing said. A recovery that already started,
|
|
272
|
+
* finished or vanished is only dropped from the set: it is not lost, it is past needing the backstop.
|
|
273
|
+
*/
|
|
274
|
+
export function makeHostLostSweep({ redis, queues, now = () => Date.now(), log = () => {}, onLost = async () => {}, liveHosts = async () => null, extendMs = RECOVERY_EXTEND_MS, maxWaitMs = RECOVERY_MAX_WAIT_MS, batch = 100 } = {}) {
|
|
275
|
+
return async function sweepHostLost() {
|
|
276
|
+
// Read once per sweep, and only when something is due: a sweep with nothing due costs one ZRANGEBYSCORE.
|
|
277
|
+
let hosts;
|
|
278
|
+
const hostsNow = async () => {
|
|
279
|
+
if (hosts === undefined) {
|
|
280
|
+
try {
|
|
281
|
+
hosts = await liveHosts();
|
|
282
|
+
} catch {
|
|
283
|
+
hosts = null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return hosts;
|
|
287
|
+
};
|
|
288
|
+
let due;
|
|
289
|
+
try {
|
|
290
|
+
due = await redis.zrangebyscore(RECOVER_PENDING_KEY, "-inf", now(), "WITHSCORES", "LIMIT", 0, batch);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
log("recovery_host_lost_sweep_failed", { code: codeOf(err) });
|
|
293
|
+
return { skipped: "read-failed" };
|
|
294
|
+
}
|
|
295
|
+
const out = { claimed: 0, removed: 0, settled: 0, extended: 0 };
|
|
296
|
+
for (let i = 0; i + 1 < due.length; i += 2) {
|
|
297
|
+
const member = due[i];
|
|
298
|
+
const score = due[i + 1];
|
|
299
|
+
let mine;
|
|
300
|
+
try {
|
|
301
|
+
mine = (await redis.zrem(RECOVER_PENDING_KEY, member)) === 1;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
log("recovery_host_lost_sweep_failed", { code: codeOf(err) });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!mine) continue;
|
|
307
|
+
out.claimed++;
|
|
308
|
+
const m = parsePendingMember(member);
|
|
309
|
+
if (!m) {
|
|
310
|
+
log("recovery_pending_malformed", {});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const queue = m.q === QUEUE ? queues.shared() : queues.host(m.q);
|
|
315
|
+
const job = await queue.getJob(m.id);
|
|
316
|
+
const state = job ? await job.getState() : "missing";
|
|
317
|
+
if (!job || !NOT_STARTED_STATES.has(state)) {
|
|
318
|
+
out.settled++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const hard = m.at + maxWaitMs;
|
|
322
|
+
if (now() < hard) {
|
|
323
|
+
const paused = typeof queue.isPaused === "function" ? await queue.isPaused().catch(() => false) : false;
|
|
324
|
+
const live = await hostsNow();
|
|
325
|
+
const why = paused === true ? "queue-paused" : live === null ? "registry-unreadable" : live.has(m.host) ? "host-alive" : null;
|
|
326
|
+
if (why) {
|
|
327
|
+
const next = Math.min(now() + extendMs, hard);
|
|
328
|
+
await redis.zadd(RECOVER_PENDING_KEY, next, member);
|
|
329
|
+
out.extended++;
|
|
330
|
+
log("job_recovery_deadline_extended", { jobId: m.id, queue: m.q, reason: why, until: next });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// `remove` refuses a job that became active (locked) in between: that throw lands in the catch below and puts
|
|
335
|
+
// the member back, and the next sweep finds it active and drops it.
|
|
336
|
+
await job.remove();
|
|
337
|
+
out.removed++;
|
|
338
|
+
// Issue #812: a recovery that chose a model before it was deferred holds a model attempt key, which nothing
|
|
339
|
+
// would read again. Bounded, and it never throws, so the run is still told below.
|
|
340
|
+
await dropModelAttempt(redis, modelAttemptIdOf(m.q, m.id));
|
|
341
|
+
log("job_recovery_host_lost", { jobId: m.id, runId: runIdOf(job), queue: m.q, state, reason: now() >= hard ? "max-wait" : "host-absent" });
|
|
342
|
+
try {
|
|
343
|
+
await onLost(job);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
log("job_recovery_host_lost_notify_failed", { jobId: m.id, code: codeOf(err) });
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
log("recovery_host_lost_sweep_failed", { jobId: m.id, code: codeOf(err) });
|
|
349
|
+
await Promise.resolve(redis.zadd(RECOVER_PENDING_KEY, score, member)).catch(() => {});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Salvage: a recovery job that lands on the host that crashed may find the crashed attempt's job directory still there
|
|
358
|
+
* (`pointer.jobDir` under jobsDir). Its workspace and transcript are newer than the last live checkpoint, so one more
|
|
359
|
+
* checkpoint is taken FROM them (kind `salvage`, quiesced `dead`: the boot reaper stopped every job container before
|
|
360
|
+
* this worker took a job), then the directory is removed.
|
|
361
|
+
*
|
|
362
|
+
* `salvage({ runId, pointer, attempt })` -> `{ salvaged: true, seq } | { skipped: reason } | { refused: reason }`. It
|
|
363
|
+
* never throws. It needs an existing checkpoint (`readManifest`): the base commit and the run's meta (pi version, image,
|
|
364
|
+
* venue, model) come from it, because a crashed attempt left nothing else this host can trust for them, and a salvage
|
|
365
|
+
* whose meta differed from the restore's gate would only be refused there. Paths: the only thing ever joined to jobsDir
|
|
366
|
+
* is a basename matching `job-<chars>`, lstat'd as a real directory, so a pointer cannot name `..`, a link or a file.
|
|
367
|
+
* A checkpoint a newer pi-dispatch wrote (`readManifest`'s `newer`) is `{ skipped: "checkpoint-newer" }` with the
|
|
368
|
+
* directory left in place: with `sandbox-alive`, one of the two skips after the directory was found that remove nothing
|
|
369
|
+
* (issue #761).
|
|
370
|
+
*
|
|
371
|
+
* RESIDUAL, stated: the crashed attempt's secret VALUES (its forge token, its resolved run.secrets) died with it, so the
|
|
372
|
+
* snapshot's secret scan runs without them. The store is host-private (0700) and never mounted, a forge token expires
|
|
373
|
+
* within the hour, and the restore puts the bytes back only into the same run's workspace; but a resolved secret the
|
|
374
|
+
* agent wrote into a file is stored where a live checkpoint would have refused it.
|
|
375
|
+
*/
|
|
376
|
+
/**
|
|
377
|
+
* `settleJobDir(name)` (issue #686 part 3, review round 1 of #714): asked before a lost attempt's job directory is read,
|
|
378
|
+
* and it must answer `true` only once nothing can still write it. On the `openshell-bind` venue the directory is a
|
|
379
|
+
* sandbox's bind, and a sandbox outlives a dead worker, so its process could still be writing the tree; the venue deletes
|
|
380
|
+
* that sandbox and waits until the gateway no longer lists it. `false` (or a throw) reads nothing and removes nothing:
|
|
381
|
+
* the run continues from its last committed checkpoint, which is a correct place to continue from.
|
|
382
|
+
*/
|
|
383
|
+
export function makeSalvager({ jobsDir, hostName, workspaceCheckpointer, runCheckpointer = null, fs = nodeFsp, log = () => {}, settleJobDir = async () => true } = {}) {
|
|
384
|
+
return async function salvage({ runId, pointer, attempt = 0 } = {}) {
|
|
385
|
+
try {
|
|
386
|
+
if (!workspaceCheckpointer || !jobsDir) return { skipped: "no-store" };
|
|
387
|
+
if (typeof runId !== "string" || runId === "") return { skipped: "bad-args" };
|
|
388
|
+
// The pointer's host is the run checkpointer's normalised token, so this host is compared in the same spelling.
|
|
389
|
+
if (!pointer || pointer.host !== normalizeHost(hostName)) return { skipped: "other-host" };
|
|
390
|
+
if (typeof pointer.jobDir !== "string" || !JOB_DIR_RE.test(pointer.jobDir)) return { skipped: "no-job-dir" };
|
|
391
|
+
const dir = join(jobsDir, pointer.jobDir);
|
|
392
|
+
const st = await fs.lstat(dir).catch(() => null);
|
|
393
|
+
if (!st?.isDirectory()) return { skipped: "absent" };
|
|
394
|
+
// Only for a job dir a bind sandbox may write: the `.openshell-bind` marker beside it, written at stage time
|
|
395
|
+
// (review round 2 of #714), so a host that blesses the venue does not ask the gateway on every other venue's salvage.
|
|
396
|
+
const bound = Boolean(await fs.lstat(`${dir}.openshell-bind`).catch(() => null));
|
|
397
|
+
const settled = !bound
|
|
398
|
+
? true
|
|
399
|
+
: await Promise.resolve()
|
|
400
|
+
.then(() => settleJobDir(pointer.jobDir))
|
|
401
|
+
.catch(() => false);
|
|
402
|
+
if (settled !== true) {
|
|
403
|
+
log("checkpoint_salvage", { runId: sanitizeJobId(runId), skipped: "sandbox-alive" });
|
|
404
|
+
return { skipped: "sandbox-alive" };
|
|
405
|
+
}
|
|
406
|
+
// Read BEFORE the `try` whose `finally` removes the directory (issue #761): a checkpoint a newer pi-dispatch wrote
|
|
407
|
+
// is one this build cannot continue from, and the recovery waits for a worker that can (`newer-release`, the
|
|
408
|
+
// processor's pickup). That worker's salvage needs this very directory, so nothing is removed and nothing taken;
|
|
409
|
+
// the keep-list (`pointerKeepEntry`) holds the directory until the pointer's TTL. A read that throws lands in the
|
|
410
|
+
// outer catch, which removes nothing either.
|
|
411
|
+
const read = await workspaceCheckpointer.readManifest(runId);
|
|
412
|
+
if (read?.newer === true) {
|
|
413
|
+
log("checkpoint_salvage", { runId: sanitizeJobId(runId), skipped: "checkpoint-newer" });
|
|
414
|
+
return { skipped: "checkpoint-newer" };
|
|
415
|
+
}
|
|
416
|
+
let result;
|
|
417
|
+
try {
|
|
418
|
+
result = await snapshotFrom(dir, read);
|
|
419
|
+
} finally {
|
|
420
|
+
// Removed whatever the snapshot said: the run continues from the newest checkpoint either way, and a stale job
|
|
421
|
+
// directory left behind is disk nothing will ever read again.
|
|
422
|
+
await fs.rm(dir, { recursive: true, force: true }).catch((err) => log("salvage_remove_failed", { code: codeOf(err) }));
|
|
423
|
+
// The container id file runContainer writes BESIDE the job dir (`<jobDir>.cid`) goes with it (lab of #645: one
|
|
424
|
+
// was left per crashed attempt). `force` and no recursion: a missing file is fine and a link is removed as a link.
|
|
425
|
+
for (const suffix of BESIDE_JOB_DIR) await fs.rm(`${dir}${suffix}`, { recursive: true, force: true }).catch((err) => log("salvage_remove_failed", { code: codeOf(err) }));
|
|
426
|
+
}
|
|
427
|
+
log("checkpoint_salvage", { runId: sanitizeJobId(runId), ...(result.salvaged ? { seq: result.seq } : { skipped: result.skipped ?? null, refused: result.refused ?? null }) });
|
|
428
|
+
return result;
|
|
429
|
+
} catch (err) {
|
|
430
|
+
log("checkpoint_salvage", { runId: sanitizeJobId(runId), skipped: "internal-error", code: codeOf(err) });
|
|
431
|
+
return { skipped: "internal-error" };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function snapshotFrom(dir, read) {
|
|
435
|
+
if (!read?.ok) return { skipped: "no-checkpoint" };
|
|
436
|
+
const m = read.manifest;
|
|
437
|
+
const transcriptFile = join(dir, "session", "current.jsonl");
|
|
438
|
+
// run-checkpoint.mjs's own reader: O_NOFOLLOW, a regular file only (the file is the agent's, `/session` is mounted
|
|
439
|
+
// read-write), the end of the last COMPLETE line, so a line the writer died appending is left out.
|
|
440
|
+
const offset = await lastCompleteLineEnd(transcriptFile, { fs });
|
|
441
|
+
if (offset === null) return { skipped: "no-transcript" };
|
|
442
|
+
// A transcript shorter than the one the checkpoint already holds is not newer: salvaging it would move the run
|
|
443
|
+
// BACKWARDS (a session file that was truncated, or one from another attempt).
|
|
444
|
+
if (offset < m.transcript.bytes) return { skipped: "transcript-older" };
|
|
445
|
+
const meta = { piVersion: m.piVersion, imageDigest: m.imageDigest, venue: m.venue, provider: m.provider, model: m.model };
|
|
446
|
+
const runAttempt = Number.isSafeInteger(attempt) && attempt >= 0 ? Math.min(attempt, 10_000) : 0;
|
|
447
|
+
// Through the run checkpointer when there is one (run-checkpoint.mjs): a handle opened WITHOUT pause verbs, whose
|
|
448
|
+
// `exit({ kind: "salvage" })` numbers the checkpoint past the manifest and the pointer, reads the offset itself and
|
|
449
|
+
// rewrites the pointer after the commit, exactly as an exit checkpoint does. The direct snapshot below is the
|
|
450
|
+
// fallback for a host whose run checkpointer did not load: the same checkpoint, without the pointer rewrite.
|
|
451
|
+
if (runCheckpointer && typeof runCheckpointer.open === "function") {
|
|
452
|
+
const handle = runCheckpointer.open({ runId, attempt: runAttempt, jobDir: dir, workspace: join(dir, "workspace"), sessionFile: transcriptFile, baseSha: m.workspace.baseSha, branch: m.workspace.git?.branch ?? null, secretValues: [], meta });
|
|
453
|
+
try {
|
|
454
|
+
const r = await handle.exit({ kind: "salvage" });
|
|
455
|
+
return Number.isSafeInteger(r?.seq) ? { salvaged: true, seq: r.seq } : { skipped: r?.skipped ?? "snapshot-failed" };
|
|
456
|
+
} finally {
|
|
457
|
+
await Promise.resolve(handle.close?.()).catch(() => {});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const pointerSeq = Number.isSafeInteger(pointer.seq) ? pointer.seq : 0;
|
|
461
|
+
const seq = Math.max(m.seq, pointerSeq) + 1;
|
|
462
|
+
const r = await workspaceCheckpointer.snapshot({
|
|
463
|
+
runId,
|
|
464
|
+
workspace: join(dir, "workspace"),
|
|
465
|
+
baseSha: m.workspace.baseSha,
|
|
466
|
+
seq,
|
|
467
|
+
branch: m.workspace.git?.branch ?? null,
|
|
468
|
+
secretValues: [],
|
|
469
|
+
attempt: runAttempt,
|
|
470
|
+
kind: "salvage",
|
|
471
|
+
quiesced: "dead",
|
|
472
|
+
transcript: { srcFile: transcriptFile, offset },
|
|
473
|
+
meta,
|
|
474
|
+
});
|
|
475
|
+
if (r?.ok) return { salvaged: true, seq: r.seq };
|
|
476
|
+
return r?.refused ? { refused: r.refused } : { skipped: r?.skipped ?? "snapshot-failed" };
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* The job directories a checkpoint pointer of THIS host still names: a crashed run's stale directory, kept for its
|
|
483
|
+
* recovery's salvage. SCAN over `run:checkpoint:*`, bounded; throws on a Valkey fault, so the caller skips the orphan
|
|
484
|
+
* sweep rather than delete a directory a recovery was about to salvage. A pointer of ANY version counts
|
|
485
|
+
* (`pointerKeepEntry`, issue #761): this list's "no" deletes, so a pointer a newer release wrote must keep its directory
|
|
486
|
+
* here even though this build would not route by it.
|
|
487
|
+
*/
|
|
488
|
+
export async function pointedJobDirs({ redis, hostName, maxKeys = 10_000, count = 500 } = {}) {
|
|
489
|
+
const keep = new Set();
|
|
490
|
+
let cursor = "0";
|
|
491
|
+
let seen = 0;
|
|
492
|
+
do {
|
|
493
|
+
const [next, keys] = await redis.scan(cursor, "MATCH", "run:checkpoint:*", "COUNT", count);
|
|
494
|
+
cursor = next;
|
|
495
|
+
seen += keys.length;
|
|
496
|
+
if (keys.length > 0) {
|
|
497
|
+
const values = await redis.mget(...keys);
|
|
498
|
+
for (const raw of values) {
|
|
499
|
+
try {
|
|
500
|
+
const p = pointerKeepEntry(JSON.parse(raw));
|
|
501
|
+
if (p?.host === normalizeHost(hostName)) keep.add(p.jobDir);
|
|
502
|
+
} catch {}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Over the bound, the answer is incomplete, and an incomplete keep-list deletes what it missed: refuse instead.
|
|
506
|
+
if (seen > maxKeys) throw Object.assign(new Error("too many checkpoint pointers to read at boot"), { code: "too-many-pointers" });
|
|
507
|
+
} while (cursor !== "0");
|
|
508
|
+
return keep;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The boot sweep of orphaned `jobs/job-*` directories: what a crashed worker left behind (the processor's cleanup never
|
|
513
|
+
* ran). Only a name `mkdtemp` makes, only a REAL directory (lstat: a link is never followed, never removed through), only
|
|
514
|
+
* one older than `olderThanMs` (the job timeout bounds every running job's life, so nothing running here can own it), and
|
|
515
|
+
* never one in `keep` (a pointer names it for a salvage). `fs.rm` recursive removes a link inside as the link, without
|
|
516
|
+
* following it. Never throws; per-entry faults are counted and logged by code.
|
|
517
|
+
*/
|
|
518
|
+
export async function sweepOrphanJobDirs({ jobsDir, olderThanMs, now = () => Date.now(), keep = new Set(), fs = nodeFsp, log = () => {} } = {}) {
|
|
519
|
+
const out = { removed: 0, kept: 0, failed: 0 };
|
|
520
|
+
let names;
|
|
521
|
+
try {
|
|
522
|
+
names = await fs.readdir(jobsDir);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (err?.code !== "ENOENT") log("job_dir_sweep_failed", { code: codeOf(err) });
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
const present = new Set(names);
|
|
528
|
+
for (const name of names) {
|
|
529
|
+
// A stale `<jobDir>.cid` whose job dir is already gone (a crash after the dir's removal, or one left by a salvage
|
|
530
|
+
// before #645's lab fix): only a regular file, only past the same age bound. Issue #683: the openshell venue's three
|
|
531
|
+
// beside-files take the same rule (the policy file, and the upload and download staging directories, which hold a
|
|
532
|
+
// copy of the job's inputs and outputs), a directory among them removed whole.
|
|
533
|
+
const beside = BESIDE_JOB_DIR.find((suffix) => name.endsWith(suffix) && JOB_DIR_RE.test(name.slice(0, -suffix.length)));
|
|
534
|
+
if (beside !== undefined && !present.has(name.slice(0, -beside.length))) {
|
|
535
|
+
try {
|
|
536
|
+
const st = await fs.lstat(join(jobsDir, name));
|
|
537
|
+
const removable = st.isFile() || (beside !== ".cid" && st.isDirectory());
|
|
538
|
+
if (removable && now() - st.mtimeMs > olderThanMs) {
|
|
539
|
+
await fs.rm(join(jobsDir, name), { recursive: true, force: true });
|
|
540
|
+
out.removed++;
|
|
541
|
+
} else out.kept++;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
out.failed++;
|
|
544
|
+
log("job_dir_sweep_failed", { code: codeOf(err) });
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (!JOB_DIR_RE.test(name)) continue;
|
|
549
|
+
const dir = join(jobsDir, name);
|
|
550
|
+
try {
|
|
551
|
+
const st = await fs.lstat(dir);
|
|
552
|
+
if (!st.isDirectory() || keep.has(name) || now() - st.mtimeMs <= olderThanMs) {
|
|
553
|
+
out.kept++;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
557
|
+
for (const suffix of BESIDE_JOB_DIR) await fs.rm(`${dir}${suffix}`, { recursive: true, force: true });
|
|
558
|
+
out.removed++;
|
|
559
|
+
} catch (err) {
|
|
560
|
+
out.failed++;
|
|
561
|
+
log("job_dir_sweep_failed", { code: codeOf(err) });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (out.removed || out.failed) log("job_dirs_swept", out);
|
|
565
|
+
return out;
|
|
566
|
+
}
|