@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.
Files changed (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. 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
+ }