@edgehero/pi-dispatch 4.3.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import { constants as FS } from "node:fs";
|
|
2
|
+
import * as nodeFsp from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Durable runs, the live half (issue #645): WHEN to take a workspace checkpoint of a running job, and the
|
|
8
|
+
* Valkey pointer that tells a recovery where the latest one is. `workspace-checkpoint.mjs` knows HOW to
|
|
9
|
+
* snapshot a paused workspace into the per-run store; this module decides when, freezes the container around
|
|
10
|
+
* it, and publishes the result.
|
|
11
|
+
*
|
|
12
|
+
* WHY QUIET POINTS. A checkpoint is a workspace plus the transcript prefix that produced it. Taken mid tool
|
|
13
|
+
* call, the transcript ends on a call whose effects are half in the tree, and the recovery has to guess
|
|
14
|
+
* (`piDispatchRecovery: "interrupted"`). Taken where every tool call of the last assistant message has its
|
|
15
|
+
* result (or the assistant asked for none), the tree and the transcript agree, and a recovery replays
|
|
16
|
+
* nothing. So only those points TRIGGER a live checkpoint; the offset itself is still read while the
|
|
17
|
+
* container is frozen (below), because the tailer may lag the file by a poll.
|
|
18
|
+
*
|
|
19
|
+
* WHY THE OFFSET IS READ WHILE PAUSED, FROM THE FILE. The tailer's last line end is up to a poll behind: pi
|
|
20
|
+
* may have appended a tool result whose effect is already in the tree. The freezer stops every writer, so the
|
|
21
|
+
* end of the last complete line read during the pause is exactly the transcript that matches the frozen tree;
|
|
22
|
+
* a partial last line (pi mid write when frozen) is left out, never cut into.
|
|
23
|
+
*
|
|
24
|
+
* NEVER THROWS, NEVER HOLDS THE JOB. `consume` is called from the live tailer's per-line callback and
|
|
25
|
+
* returns at once; the checkpoint runs beside it. Every failure is counted, backed off and, after
|
|
26
|
+
* `maxFailures` in a row, the attempt stops taking live checkpoints (`checkpoint_degraded`): a checkpoint is
|
|
27
|
+
* optional, a paid container frozen again and again for one that keeps failing is not.
|
|
28
|
+
*
|
|
29
|
+
* THE PAUSE IS ALWAYS BALANCED. `unpause` runs in a `finally` after every pause that took (and, without
|
|
30
|
+
* escalation, after one whose answer was a timeout, since that freeze may still land). An unpause that fails
|
|
31
|
+
* `UNPAUSE_TRIES` times leaves a container that will never run again while the worker waits on it for the
|
|
32
|
+
* whole job timeout, so the handle says `{ stop: true }` and calls `onStop`: runContainer stops the container
|
|
33
|
+
* and the run ends as an infrastructure exit, which the processor recovers from the checkpoint just taken.
|
|
34
|
+
*
|
|
35
|
+
* THE POINTER FOLLOWS THE COMMIT, NEVER LEADS IT. It is written only after `snapshot` answered `ok` (its
|
|
36
|
+
* CURRENT renamed into place), so a reader that finds a pointer finds a checkpoint behind it. A pointer write
|
|
37
|
+
* that fails leaves the previous pointer (or none): stale, never ahead.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The pointer key's prefix; the key is this plus the sanitized RUN id (`recoveryOf ?? job.id`), never a job id. */
|
|
41
|
+
export const CHECKPOINT_POINTER_PREFIX = "run:checkpoint:";
|
|
42
|
+
/** The pointer's lifetime, rewritten on every committed checkpoint: the store's own TTL (DEFAULT_CHECKPOINT_LIMITS.ttlMs). */
|
|
43
|
+
export const CHECKPOINT_POINTER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
44
|
+
/** Snapshots this host runs at once, live and exit together. Each walks a tree and streams a fast-import. */
|
|
45
|
+
export const HOST_SNAPSHOT_CONCURRENCY = 2;
|
|
46
|
+
/** How often an unpause is asked before the container is given up on. */
|
|
47
|
+
export const UNPAUSE_TRIES = 3;
|
|
48
|
+
export const DEFAULT_RUN_CHECKPOINT_LIMITS = Object.freeze({ minIntervalMs: 60_000, pauseMaxMs: 15_000, maxFailures: 3 });
|
|
49
|
+
|
|
50
|
+
/** How far back from the end the paused offset read looks for a newline before it falls back to the tailer's. */
|
|
51
|
+
const OFFSET_SCAN_MAX_BYTES = 8 * 1024 * 1024;
|
|
52
|
+
const OFFSET_SCAN_CHUNK = 64 * 1024;
|
|
53
|
+
/** Each pointer call's bound: the BullMQ connection queues a command against an unreachable server forever. */
|
|
54
|
+
const POINTER_TIMEOUT_MS = 1_000;
|
|
55
|
+
/** The longest backoff, as a multiple of `minIntervalMs`. */
|
|
56
|
+
const BACKOFF_MAX_FACTOR = 16;
|
|
57
|
+
/** A `stale-seq` answer is retried with a growing jump, at most this often within one checkpoint. */
|
|
58
|
+
const SEQ_TRIES = 8;
|
|
59
|
+
|
|
60
|
+
const HOST_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
61
|
+
const QUEUE_RE = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,199}$/;
|
|
62
|
+
/**
|
|
63
|
+
* A job directory's basename as `mkdtemp(join(jobsDir, "job-"))` mints it. Never a path, and nothing else is ever joined
|
|
64
|
+
* to jobsDir. Exported so the salvage and the boot sweep (recovery-queue.mjs) judge a name by this one pattern: a second
|
|
65
|
+
* copy that drifted would let the sweep delete a directory a pointer names in a spelling it no longer matches.
|
|
66
|
+
*/
|
|
67
|
+
export const JOB_DIR_RE = /^job-[A-Za-z0-9_-]{1,64}$/;
|
|
68
|
+
const POINTER_KEYS = ["v", "host", "hostQueue", "seq", "attempt", "at", "jobDir"];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The pointer version this build WRITES (issue #761): one of the versions `CHECKPOINT_POINTER_SHAPES` reads, the newest
|
|
72
|
+
* or the one before it. A pointer is peer-written, so its reader is closed: a version the shapes do not hold is no
|
|
73
|
+
* pointer at all for routing, salvage and the next seq. What keeps a newer pointer's job directory alive is
|
|
74
|
+
* `pointerKeepEntry`, which reads any version.
|
|
75
|
+
*/
|
|
76
|
+
export const CHECKPOINT_POINTER_VERSION = 1;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The closed key set of every pointer version this build reads, by `v`: exactly the newest and the one before it, with
|
|
80
|
+
* the version written among them. The one before keeps a pointer the previous release wrote routing for one pointer
|
|
81
|
+
* lifetime (`CHECKPOINT_POINTER_TTL_MS`) after an upgrade. A newest above the version written is a release that READS the
|
|
82
|
+
* next version before any release writes it, so the release that then writes it shares a fleet, and a rollback, with
|
|
83
|
+
* readers of it: readers ship first, the writer a release later. Written and read as one number, that order could not
|
|
84
|
+
* be followed, since a release could not read a version without also writing it. A test pins both rules, so a bump
|
|
85
|
+
* that forgets the previous reader, or writes a version it does not read, fails there.
|
|
86
|
+
*/
|
|
87
|
+
export const CHECKPOINT_POINTER_SHAPES = Object.freeze({ 1: Object.freeze([...POINTER_KEYS]) });
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A pause answer that will never change for this container: live checkpoints are turned off at once rather
|
|
91
|
+
* than three times backed off. `cgroup-v1-rootless` is podman's gate (backend-podman.mjs); `not-pausable` is
|
|
92
|
+
* the registry's answer for a venue that carries no verbs.
|
|
93
|
+
*/
|
|
94
|
+
const PERMANENT_PAUSE = new Set(["cgroup-v1-rootless", "not-pausable", "invalid-name"]);
|
|
95
|
+
/** A snapshot answer that no later try in this attempt can change: the store was never primed, or never can be. */
|
|
96
|
+
const PERMANENT_SNAPSHOT = new Set(["not-primed", "base-too-large", "bad-args", "store-unreadable"]);
|
|
97
|
+
|
|
98
|
+
const isInt = (v, min = 0, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(v) && v >= min && v <= max;
|
|
99
|
+
|
|
100
|
+
/** One run's pointer key. */
|
|
101
|
+
export const checkpointPointerKey = (runId) => `${CHECKPOINT_POINTER_PREFIX}${sanitizeJobId(runId)}`;
|
|
102
|
+
|
|
103
|
+
/** The host token as workspace-checkpoint.mjs normalises it, so a pointer and a manifest name a host the same way. */
|
|
104
|
+
export function normalizeHost(hostName) {
|
|
105
|
+
const s = String(hostName ?? "");
|
|
106
|
+
return HOST_RE.test(s) ? s : s.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64) || "unknown";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The pointer, validated: a plain object with EXACTLY the keys of its own version's shape (`shapes[p.v]`), every value
|
|
111
|
+
* typed and every string a host token, a queue name or a job directory basename. Null otherwise, and null for a version
|
|
112
|
+
* the shapes do not hold (older than the oldest this build reads, or newer). It is read by another host's worker and
|
|
113
|
+
* decides where a recovery is queued and which directory a salvage looks at, so nothing loose rides along.
|
|
114
|
+
*
|
|
115
|
+
* `shapes` is a seam for a test of the version policy (a two-version table); every caller uses the default. A shape
|
|
116
|
+
* may only ADD keys to the fields typed here: one that lacks a field is refused by that field's own check.
|
|
117
|
+
*/
|
|
118
|
+
export function validPointer(p, { shapes = CHECKPOINT_POINTER_SHAPES } = {}) {
|
|
119
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return null;
|
|
120
|
+
const want = Number.isSafeInteger(p.v) && Object.hasOwn(shapes, p.v) ? shapes[p.v] : null;
|
|
121
|
+
if (!Array.isArray(want)) return null;
|
|
122
|
+
const keys = Object.keys(p).sort();
|
|
123
|
+
if (keys.length !== want.length || ![...want].sort().every((k, i) => k === keys[i])) return null;
|
|
124
|
+
if (typeof p.host !== "string" || !HOST_RE.test(p.host)) return null;
|
|
125
|
+
if (p.hostQueue !== null && (typeof p.hostQueue !== "string" || !QUEUE_RE.test(p.hostQueue))) return null;
|
|
126
|
+
if (!isInt(p.seq, 1, 999_999_999) || !isInt(p.attempt, 0, 10_000) || !isInt(p.at)) return null;
|
|
127
|
+
if (typeof p.jobDir !== "string" || !JOB_DIR_RE.test(p.jobDir)) return null;
|
|
128
|
+
return { v: p.v, host: p.host, hostQueue: p.hostQueue, seq: p.seq, attempt: p.attempt, at: p.at, jobDir: p.jobDir };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `{ host, jobDir }` for the boot sweep's keep-list from a pointer of ANY version, or null (issue #761). Only what the
|
|
133
|
+
* keep-list needs is judged: an integer `v` of 1 or more, a host token and a job directory basename; every other key,
|
|
134
|
+
* a missing one, or the type of any other field is ignored. `validPointer` stays the reader for routing, salvage and the
|
|
135
|
+
* next seq, so a newer pointer is never MISREAD; this only keeps the directory it names from being deleted under it.
|
|
136
|
+
*
|
|
137
|
+
* Why not `validPointer` here: the keep-list is the one reader whose "no" DELETES. A pointer a newer release wrote (a
|
|
138
|
+
* new key, a new `v`) would read as no pointer, and the boot sweep of an older worker on the same host (a rollback, a
|
|
139
|
+
* mixed fleet) would remove the crashed attempt's directory the newer release's salvage still needs. Kept, it costs
|
|
140
|
+
* disk until the pointer's own TTL, after which the sweep takes it as any orphan.
|
|
141
|
+
*
|
|
142
|
+
* Every later pointer version keeps `host` and `jobDir` with these meanings and these patterns; a version that cannot
|
|
143
|
+
* uses a new key prefix rather than a new `v`, so this reader stays true for every version it will ever meet.
|
|
144
|
+
*/
|
|
145
|
+
export function pointerKeepEntry(p) {
|
|
146
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) return null;
|
|
147
|
+
if (!Number.isSafeInteger(p.v) || p.v < 1) return null;
|
|
148
|
+
if (typeof p.host !== "string" || !HOST_RE.test(p.host)) return null;
|
|
149
|
+
if (typeof p.jobDir !== "string" || !JOB_DIR_RE.test(p.jobDir)) return null;
|
|
150
|
+
return { host: p.host, jobDir: p.jobDir };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bounded(promise, ms) {
|
|
154
|
+
let timer;
|
|
155
|
+
return Promise.race([
|
|
156
|
+
Promise.resolve(promise),
|
|
157
|
+
new Promise((_, reject) => {
|
|
158
|
+
timer = setTimeout(() => reject(new Error("checkpoint pointer timeout")), ms);
|
|
159
|
+
}),
|
|
160
|
+
]).finally(() => clearTimeout(timer));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The run's pointer, shape-validated, or null (none, unreadable, malformed, or Valkey did not answer). Never throws. */
|
|
164
|
+
export async function readCheckpointPointer({ redis, runId, timeoutMs = POINTER_TIMEOUT_MS } = {}) {
|
|
165
|
+
if (!redis || runId === null || runId === undefined || runId === "") return null;
|
|
166
|
+
try {
|
|
167
|
+
const raw = await bounded(redis.get(checkpointPointerKey(runId)), timeoutMs);
|
|
168
|
+
if (typeof raw !== "string" || raw.length > 4096) return null;
|
|
169
|
+
return validPointer(JSON.parse(raw));
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Remove the run's pointer at its final outcome. True when the delete was answered. Never throws. */
|
|
176
|
+
export async function removeCheckpointPointer({ redis, runId, timeoutMs = POINTER_TIMEOUT_MS } = {}) {
|
|
177
|
+
if (!redis || runId === null || runId === undefined || runId === "") return false;
|
|
178
|
+
try {
|
|
179
|
+
await bounded(redis.del(checkpointPointerKey(runId)), timeoutMs);
|
|
180
|
+
return true;
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Write a validated pointer with its TTL. True when Valkey answered OK. Never throws; refuses an invalid pointer. */
|
|
187
|
+
export async function writeCheckpointPointer({ redis, runId, pointer, timeoutMs = POINTER_TIMEOUT_MS, ttlMs = CHECKPOINT_POINTER_TTL_MS } = {}) {
|
|
188
|
+
const valid = validPointer(pointer);
|
|
189
|
+
if (!redis || !valid || runId === null || runId === undefined || runId === "") return false;
|
|
190
|
+
try {
|
|
191
|
+
const reply = await bounded(redis.set(checkpointPointerKey(runId), JSON.stringify(valid), "PX", ttlMs), timeoutMs);
|
|
192
|
+
return reply === "OK";
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A counting semaphore. `tryAcquire()` returns a release function or null; `acquire()` waits for one. A
|
|
200
|
+
* release is idempotent, so a `finally` that runs twice cannot free a slot it does not hold.
|
|
201
|
+
*
|
|
202
|
+
* A LIVE checkpoint only ever TRIES: waiting for a slot would pause the container later, at a moment that is
|
|
203
|
+
* no longer the quiet point that asked for it. The exit checkpoint waits, since its container is already dead.
|
|
204
|
+
*/
|
|
205
|
+
export function makeSemaphore(limit = HOST_SNAPSHOT_CONCURRENCY) {
|
|
206
|
+
let active = 0;
|
|
207
|
+
const waiters = [];
|
|
208
|
+
const grant = () => {
|
|
209
|
+
active += 1;
|
|
210
|
+
let released = false;
|
|
211
|
+
return () => {
|
|
212
|
+
if (released) return;
|
|
213
|
+
released = true;
|
|
214
|
+
active -= 1;
|
|
215
|
+
const next = waiters.shift();
|
|
216
|
+
if (next) next(grant());
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
get active() {
|
|
221
|
+
return active;
|
|
222
|
+
},
|
|
223
|
+
tryAcquire: () => (active < limit ? grant() : null),
|
|
224
|
+
acquire: () => (active < limit ? Promise.resolve(grant()) : new Promise((resolve) => waiters.push(resolve))),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Quiet-point detection over pi session entries (the pinned pi's `message` lines: an assistant message
|
|
230
|
+
* carries its `toolCall` parts with ids, each `toolResult` names the `toolCallId` it answers). `take(parsed)`
|
|
231
|
+
* is true when THIS line completes a quiet point: an assistant message with no tool calls, or the tool
|
|
232
|
+
* result that answers the last outstanding call of the latest assistant message. Every other entry type
|
|
233
|
+
* (compaction, model change, custom) leaves the state as it was.
|
|
234
|
+
*/
|
|
235
|
+
export function makeQuietTracker() {
|
|
236
|
+
let pending = new Set();
|
|
237
|
+
let synthetic = 0;
|
|
238
|
+
return {
|
|
239
|
+
take(parsed) {
|
|
240
|
+
if (parsed?.type !== "message") return false;
|
|
241
|
+
const message = parsed.message ?? {};
|
|
242
|
+
if (message.role === "assistant") {
|
|
243
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
244
|
+
pending = new Set();
|
|
245
|
+
for (const part of content) {
|
|
246
|
+
if (part?.type !== "toolCall") continue;
|
|
247
|
+
// A call without an id can never be answered by id, so it holds the point closed until the next
|
|
248
|
+
// assistant message: a missed checkpoint, never a checkpoint taken mid call.
|
|
249
|
+
pending.add(typeof part.id === "string" && part.id !== "" ? part.id : `\0${(synthetic += 1)}`);
|
|
250
|
+
}
|
|
251
|
+
return pending.size === 0;
|
|
252
|
+
}
|
|
253
|
+
if (message.role === "toolResult") {
|
|
254
|
+
if (pending.size === 0 || !pending.delete(message.toolCallId)) return false;
|
|
255
|
+
return pending.size === 0;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
},
|
|
259
|
+
reset() {
|
|
260
|
+
pending = new Set();
|
|
261
|
+
},
|
|
262
|
+
get pending() {
|
|
263
|
+
return pending.size;
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The end of the last COMPLETE line of `file` (the byte just past its last newline), read now. Null when it
|
|
270
|
+
* cannot be read or no newline is within `maxScanBytes` of the end. The file is the agent's (`/session` is
|
|
271
|
+
* mounted read-write), so it is opened `O_NOFOLLOW | O_NONBLOCK` and must be a regular file, as the tailer's.
|
|
272
|
+
*/
|
|
273
|
+
export async function lastCompleteLineEnd(file, { fs = nodeFsp, maxScanBytes = OFFSET_SCAN_MAX_BYTES } = {}) {
|
|
274
|
+
if (typeof file !== "string" || file === "") return null;
|
|
275
|
+
let fh = null;
|
|
276
|
+
try {
|
|
277
|
+
fh = await fs.open(file, FS.O_RDONLY | (FS.O_NOFOLLOW ?? 0) | (FS.O_NONBLOCK ?? 0));
|
|
278
|
+
const st = await fh.stat();
|
|
279
|
+
if (!st.isFile()) return null;
|
|
280
|
+
let pos = st.size;
|
|
281
|
+
let scanned = 0;
|
|
282
|
+
while (pos > 0 && scanned < maxScanBytes) {
|
|
283
|
+
const len = Math.min(OFFSET_SCAN_CHUNK, pos, maxScanBytes - scanned);
|
|
284
|
+
const buf = Buffer.alloc(len);
|
|
285
|
+
const { bytesRead } = await fh.read(buf, 0, len, pos - len);
|
|
286
|
+
if (bytesRead !== len) return null;
|
|
287
|
+
const nl = buf.lastIndexOf(0x0a);
|
|
288
|
+
if (nl !== -1) return pos - len + nl + 1;
|
|
289
|
+
pos -= len;
|
|
290
|
+
scanned += len;
|
|
291
|
+
}
|
|
292
|
+
return pos === 0 ? 0 : null;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
} finally {
|
|
296
|
+
await fh?.close().catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** One pause verb call, bounded whatever the verb does, as `{ ok, reason? }`. Never rejects. */
|
|
301
|
+
async function verbCall(fn, name, ms) {
|
|
302
|
+
let timer;
|
|
303
|
+
try {
|
|
304
|
+
return await Promise.race([
|
|
305
|
+
Promise.resolve().then(() => fn(name)),
|
|
306
|
+
new Promise((resolve) => {
|
|
307
|
+
timer = setTimeout(() => resolve({ ok: false, reason: "timeout" }), ms);
|
|
308
|
+
}),
|
|
309
|
+
]).then((r) => (r?.ok === true ? { ok: true } : { ok: false, reason: typeof r?.reason === "string" ? r.reason : "failed" }));
|
|
310
|
+
} catch {
|
|
311
|
+
return { ok: false, reason: "threw" };
|
|
312
|
+
} finally {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* `makeRunCheckpointer({ checkpointer, redis, hostName, hostQueue, now, log, limits, semaphore, fs, sleep })`
|
|
319
|
+
* returns `{ open(args) }`. One per worker process: its semaphore is the host-wide bound.
|
|
320
|
+
*
|
|
321
|
+
* `open({ runId, attempt, jobDir, workspace, sessionFile, containerName, pause, unpause, baseSha, branch,
|
|
322
|
+
* secretValues, meta, onStop })` returns a handle:
|
|
323
|
+
* - `consume(parsed, ts, endOffset, { prior, replay })`: one transcript line, fed by the live tailer. Tracks
|
|
324
|
+
* quiet points and, at one that is due, starts a live checkpoint beside the job. Returns at once, never
|
|
325
|
+
* throws. A `prior` or `replay` line (an earlier attempt's, re-read) updates the state and triggers nothing.
|
|
326
|
+
* - `reset()`: the transcript was rewritten; the quiet state starts over.
|
|
327
|
+
* - `exit({ kind })`: a checkpoint of the dead container (kind `exit`, or `salvage` for a stale job dir),
|
|
328
|
+
* no pause. `{ seq }` or `{ skipped: reason }`. Idempotent. Waits for a live one in flight first.
|
|
329
|
+
* - `close()`: no further live checkpoint; awaits the one in flight. `{ written, failed, skipped, lastSeq }`.
|
|
330
|
+
* - `stats()`: the same counts, now.
|
|
331
|
+
* - `pending()`: the live checkpoint in flight (a promise of its outcome) or null. For tests.
|
|
332
|
+
*
|
|
333
|
+
* A venue that cannot pause (`pause`/`unpause` not functions) gets no live checkpoints; `exit` still works.
|
|
334
|
+
*/
|
|
335
|
+
export function makeRunCheckpointer({
|
|
336
|
+
checkpointer,
|
|
337
|
+
forkStore = null,
|
|
338
|
+
redis = null,
|
|
339
|
+
hostName = "unknown",
|
|
340
|
+
hostQueue = null,
|
|
341
|
+
now = () => Date.now(),
|
|
342
|
+
log = () => {},
|
|
343
|
+
limits = {},
|
|
344
|
+
semaphore = makeSemaphore(HOST_SNAPSHOT_CONCURRENCY),
|
|
345
|
+
fs = nodeFsp,
|
|
346
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
347
|
+
unpauseRetryMs = 1_000,
|
|
348
|
+
pointerTimeoutMs = POINTER_TIMEOUT_MS,
|
|
349
|
+
} = {}) {
|
|
350
|
+
const L = { ...DEFAULT_RUN_CHECKPOINT_LIMITS, ...limits };
|
|
351
|
+
const host = normalizeHost(hostName);
|
|
352
|
+
const queue = typeof hostQueue === "string" && QUEUE_RE.test(hostQueue) ? hostQueue : null;
|
|
353
|
+
|
|
354
|
+
function open({ runId, attempt = 0, jobDir = null, workspace = null, sessionFile = null, containerName = null, pause = null, unpause = null, baseSha = null, branch = null, secretValues = [], meta = {}, onStop = null } = {}) {
|
|
355
|
+
const id = sanitizeJobId(runId);
|
|
356
|
+
const tracker = makeQuietTracker();
|
|
357
|
+
const counts = { written: 0, failed: 0, skipped: 0 };
|
|
358
|
+
let lastSeq = null;
|
|
359
|
+
let lastEnd = null; // the tailer's last complete line end: the fallback offset when the paused read fails
|
|
360
|
+
let closed = false;
|
|
361
|
+
let stopped = false;
|
|
362
|
+
let degraded = false;
|
|
363
|
+
let consecutive = 0;
|
|
364
|
+
let inflight = null;
|
|
365
|
+
let exiting = null;
|
|
366
|
+
let closing = null;
|
|
367
|
+
// The first live checkpoint is due one interval after open: a run that ends inside a minute needs none, and
|
|
368
|
+
// its exit checkpoint (if it crashes) covers it.
|
|
369
|
+
let nextAt = now() + L.minIntervalMs;
|
|
370
|
+
const jobDirName = typeof jobDir === "string" && jobDir !== "" ? basename(jobDir) : null;
|
|
371
|
+
|
|
372
|
+
let liveOff = null;
|
|
373
|
+
if (typeof pause !== "function" || typeof unpause !== "function") liveOff = "not-pausable";
|
|
374
|
+
else if (typeof containerName !== "string" || containerName === "") liveOff = "no-container";
|
|
375
|
+
else if (typeof sessionFile !== "string" || sessionFile === "") liveOff = "no-transcript";
|
|
376
|
+
else if (typeof workspace !== "string" || workspace === "" || typeof baseSha !== "string") liveOff = "no-workspace";
|
|
377
|
+
else if (!checkpointer || typeof checkpointer.snapshot !== "function") liveOff = "no-store";
|
|
378
|
+
if (liveOff) log("checkpoint_live_off", { runId: id, reason: liveOff });
|
|
379
|
+
const turnLiveOff = (reason) => {
|
|
380
|
+
if (liveOff) return;
|
|
381
|
+
liveOff = reason;
|
|
382
|
+
log("checkpoint_live_off", { runId: id, reason });
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// The next seq, once per attempt and BEFORE any pause: the store refuses a seq at or below its CURRENT, and a
|
|
386
|
+
// recovery attempt continues the run's numbering, so it starts past both the manifest and the pointer.
|
|
387
|
+
let seqReady = null;
|
|
388
|
+
let nextSeq = 1;
|
|
389
|
+
const initSeq = () =>
|
|
390
|
+
(seqReady ??= (async () => {
|
|
391
|
+
let base = 0;
|
|
392
|
+
try {
|
|
393
|
+
const m = await checkpointer?.readManifest?.(runId);
|
|
394
|
+
if (m?.ok && isInt(m.manifest?.seq, 1)) base = m.manifest.seq;
|
|
395
|
+
} catch {}
|
|
396
|
+
const p = await readCheckpointPointer({ redis, runId, timeoutMs: pointerTimeoutMs });
|
|
397
|
+
if (p && p.seq > base) base = p.seq;
|
|
398
|
+
nextSeq = base + 1;
|
|
399
|
+
})());
|
|
400
|
+
|
|
401
|
+
/** `checkpointer.snapshot` with the seq, past a `stale-seq` answer by a growing jump. Never throws. */
|
|
402
|
+
const snapshotAt = async ({ kind, quiesced, offset }) => {
|
|
403
|
+
let result = { skipped: "stale-seq" };
|
|
404
|
+
for (let i = 0; i < SEQ_TRIES; i++) {
|
|
405
|
+
const seq = nextSeq;
|
|
406
|
+
try {
|
|
407
|
+
result = await checkpointer.snapshot({ runId, workspace, baseSha, seq, branch, secretValues, attempt, kind, quiesced, transcript: { srcFile: sessionFile, offset }, meta });
|
|
408
|
+
} catch {
|
|
409
|
+
result = { skipped: "internal-error" };
|
|
410
|
+
}
|
|
411
|
+
if (result?.skipped !== "stale-seq") break;
|
|
412
|
+
nextSeq = seq + 2 ** i;
|
|
413
|
+
}
|
|
414
|
+
if (result?.ok && isInt(result.seq, 1)) {
|
|
415
|
+
nextSeq = result.seq + 1;
|
|
416
|
+
// Still frozen (or dead): taking this copy after thaw would bind old text to newer files.
|
|
417
|
+
// The archive has its own short deadline and point cap; a miss never invalidates recovery.
|
|
418
|
+
if (forkStore?.capturePoint) {
|
|
419
|
+
try {
|
|
420
|
+
const saved = await forkStore.capturePoint({ runId, workspace, baseSha, branch, secretValues, attempt, kind, quiesced, transcriptPath: sessionFile, offset, meta });
|
|
421
|
+
if (!saved?.ok) log("fork_checkpoint_skipped", { runId: id, reason: saved?.refused ?? saved?.skipped ?? "unavailable" });
|
|
422
|
+
} catch { log("fork_checkpoint_skipped", { runId: id, reason: "unavailable" }); }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return result ?? { skipped: "internal-error" };
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/** The pointer for a committed checkpoint, AFTER the commit. A failure is logged and leaves the old one. */
|
|
429
|
+
const publish = async (result) => {
|
|
430
|
+
const pointer = { v: CHECKPOINT_POINTER_VERSION, host: typeof result.manifest?.host === "string" ? result.manifest.host : host, hostQueue: queue, seq: result.seq, attempt, at: Math.floor(now()), jobDir: jobDirName };
|
|
431
|
+
if (!redis) return false;
|
|
432
|
+
const ok = await writeCheckpointPointer({ redis, runId, pointer, timeoutMs: pointerTimeoutMs });
|
|
433
|
+
if (!ok) log("checkpoint_pointer_failed", { runId: id, seq: result.seq, reason: validPointer(pointer) ? "write" : "invalid" });
|
|
434
|
+
return ok;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const offsetNow = async () => {
|
|
438
|
+
const read = await lastCompleteLineEnd(sessionFile, { fs });
|
|
439
|
+
return read ?? (isInt(lastEnd) ? lastEnd : 0);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const noteFailure = () => {
|
|
443
|
+
consecutive += 1;
|
|
444
|
+
nextAt = now() + L.minIntervalMs * Math.min(2 ** consecutive, BACKOFF_MAX_FACTOR);
|
|
445
|
+
if (consecutive >= L.maxFailures && !degraded) {
|
|
446
|
+
degraded = true;
|
|
447
|
+
log("checkpoint_degraded", { runId: id, failures: consecutive });
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/** Unpause, asked up to UNPAUSE_TRIES times. True when one took. */
|
|
452
|
+
const thaw = async () => {
|
|
453
|
+
for (let i = 0; i < UNPAUSE_TRIES; i++) {
|
|
454
|
+
const r = await verbCall(unpause, containerName, L.pauseMaxMs);
|
|
455
|
+
if (r.ok) return true;
|
|
456
|
+
log("checkpoint_unpause_retry", { runId: id, try: i + 1, reason: r.reason });
|
|
457
|
+
if (i + 1 < UNPAUSE_TRIES) await sleep(unpauseRetryMs).catch(() => {});
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const runLive = async (release) => {
|
|
463
|
+
let paused = false;
|
|
464
|
+
let ambiguous = false;
|
|
465
|
+
let thawed = true;
|
|
466
|
+
let result = null;
|
|
467
|
+
let frozenMs = 0;
|
|
468
|
+
try {
|
|
469
|
+
await initSeq();
|
|
470
|
+
if (closed) return { skipped: "closed" };
|
|
471
|
+
const p = await verbCall(pause, containerName, L.pauseMaxMs);
|
|
472
|
+
if (!p.ok) {
|
|
473
|
+
ambiguous = p.reason === "timeout";
|
|
474
|
+
if (PERMANENT_PAUSE.has(p.reason)) {
|
|
475
|
+
counts.skipped += 1;
|
|
476
|
+
turnLiveOff(p.reason);
|
|
477
|
+
return { skipped: p.reason };
|
|
478
|
+
}
|
|
479
|
+
counts.failed += 1;
|
|
480
|
+
log("checkpoint_pause_failed", { runId: id, reason: p.reason });
|
|
481
|
+
noteFailure();
|
|
482
|
+
return { failed: "pause" };
|
|
483
|
+
}
|
|
484
|
+
paused = true;
|
|
485
|
+
const frozenAt = now();
|
|
486
|
+
// Read while frozen: no writer can extend the file between this read and the snapshot's copy of it.
|
|
487
|
+
const offset = await offsetNow();
|
|
488
|
+
result = await snapshotAt({ kind: "live", quiesced: "frozen", offset });
|
|
489
|
+
frozenMs = now() - frozenAt;
|
|
490
|
+
} catch {
|
|
491
|
+
result = { skipped: "internal-error" };
|
|
492
|
+
} finally {
|
|
493
|
+
if (paused) thawed = await thaw();
|
|
494
|
+
else if (ambiguous) {
|
|
495
|
+
// The freeze may land after its verb timed out. Thawed without escalation: an unpause of a container
|
|
496
|
+
// that never froze fails too, and that must not stop a healthy job.
|
|
497
|
+
await verbCall(unpause, containerName, L.pauseMaxMs);
|
|
498
|
+
}
|
|
499
|
+
release();
|
|
500
|
+
}
|
|
501
|
+
const out = {};
|
|
502
|
+
if (!thawed) {
|
|
503
|
+
stopped = true;
|
|
504
|
+
log("checkpoint_unpause_failed", { runId: id, tries: UNPAUSE_TRIES });
|
|
505
|
+
out.stop = true;
|
|
506
|
+
}
|
|
507
|
+
if (result?.ok) {
|
|
508
|
+
counts.written += 1;
|
|
509
|
+
lastSeq = result.seq;
|
|
510
|
+
consecutive = 0;
|
|
511
|
+
out.seq = result.seq;
|
|
512
|
+
await publish(result);
|
|
513
|
+
if (frozenMs > L.pauseMaxMs) {
|
|
514
|
+
// Committed, but the job stood frozen past its bound: backed off like a failure, so a tree that is
|
|
515
|
+
// slow to walk is not frozen this long every minute.
|
|
516
|
+
log("checkpoint_pause_long", { runId: id, frozenMs });
|
|
517
|
+
noteFailure();
|
|
518
|
+
}
|
|
519
|
+
} else if (result) {
|
|
520
|
+
const reason = result.refused ?? result.skipped ?? "internal-error";
|
|
521
|
+
if (reason === "internal-error") counts.failed += 1;
|
|
522
|
+
else counts.skipped += 1;
|
|
523
|
+
out.skipped = reason;
|
|
524
|
+
if (PERMANENT_SNAPSHOT.has(reason)) turnLiveOff(reason);
|
|
525
|
+
else noteFailure();
|
|
526
|
+
} else {
|
|
527
|
+
counts.failed += 1;
|
|
528
|
+
out.failed = "internal";
|
|
529
|
+
noteFailure();
|
|
530
|
+
}
|
|
531
|
+
if (out.stop && typeof onStop === "function") {
|
|
532
|
+
try {
|
|
533
|
+
await onStop();
|
|
534
|
+
} catch {}
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const maybeStart = () => {
|
|
540
|
+
if (liveOff || degraded || stopped || closed || inflight || exiting) return;
|
|
541
|
+
if (now() < nextAt) return;
|
|
542
|
+
const release = semaphore.tryAcquire();
|
|
543
|
+
if (!release) {
|
|
544
|
+
// The host is already snapshotting twice. Not a failure and not backed off: the next quiet point asks again.
|
|
545
|
+
counts.skipped += 1;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
nextAt = now() + L.minIntervalMs;
|
|
549
|
+
inflight = runLive(release)
|
|
550
|
+
.catch(() => {
|
|
551
|
+
counts.failed += 1;
|
|
552
|
+
return { failed: "internal" };
|
|
553
|
+
})
|
|
554
|
+
.finally(() => {
|
|
555
|
+
inflight = null;
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const stats = () => ({ ...counts, lastSeq });
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
consume(parsed, _ts, endOffset, flags = {}) {
|
|
563
|
+
try {
|
|
564
|
+
if (isInt(endOffset)) lastEnd = endOffset;
|
|
565
|
+
const quiet = tracker.take(parsed);
|
|
566
|
+
if (!quiet || flags?.prior === true || flags?.replay === true) return;
|
|
567
|
+
maybeStart();
|
|
568
|
+
} catch {}
|
|
569
|
+
},
|
|
570
|
+
reset() {
|
|
571
|
+
try {
|
|
572
|
+
tracker.reset();
|
|
573
|
+
lastEnd = null;
|
|
574
|
+
} catch {}
|
|
575
|
+
},
|
|
576
|
+
pending: () => inflight,
|
|
577
|
+
stats,
|
|
578
|
+
close() {
|
|
579
|
+
closing ??= (async () => {
|
|
580
|
+
closed = true;
|
|
581
|
+
try {
|
|
582
|
+
await inflight;
|
|
583
|
+
} catch {}
|
|
584
|
+
})();
|
|
585
|
+
return closing.then(stats);
|
|
586
|
+
},
|
|
587
|
+
exit({ kind = "exit" } = {}) {
|
|
588
|
+
exiting ??= (async () => {
|
|
589
|
+
try {
|
|
590
|
+
try {
|
|
591
|
+
await inflight;
|
|
592
|
+
} catch {}
|
|
593
|
+
if (!checkpointer || typeof checkpointer.snapshot !== "function") {
|
|
594
|
+
counts.skipped += 1;
|
|
595
|
+
return { skipped: "no-store" };
|
|
596
|
+
}
|
|
597
|
+
const release = await semaphore.acquire();
|
|
598
|
+
let result;
|
|
599
|
+
try {
|
|
600
|
+
await initSeq();
|
|
601
|
+
const offset = await offsetNow();
|
|
602
|
+
result = await snapshotAt({ kind: kind === "salvage" ? "salvage" : "exit", quiesced: "dead", offset });
|
|
603
|
+
} finally {
|
|
604
|
+
release();
|
|
605
|
+
}
|
|
606
|
+
if (result?.ok) {
|
|
607
|
+
counts.written += 1;
|
|
608
|
+
lastSeq = result.seq;
|
|
609
|
+
await publish(result);
|
|
610
|
+
return { seq: result.seq };
|
|
611
|
+
}
|
|
612
|
+
const reason = result?.refused ?? result?.skipped ?? "internal-error";
|
|
613
|
+
if (reason === "internal-error") counts.failed += 1;
|
|
614
|
+
else counts.skipped += 1;
|
|
615
|
+
return { skipped: reason };
|
|
616
|
+
} catch {
|
|
617
|
+
counts.failed += 1;
|
|
618
|
+
return { skipped: "internal-error" };
|
|
619
|
+
}
|
|
620
|
+
})();
|
|
621
|
+
return exiting;
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return { open };
|
|
627
|
+
}
|