@humwick/runner 0.0.0-stage → 0.130.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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +46 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,176 @@
1
+ import { errMessage } from "@humwick/sdk";
2
+ /** A stable, backend-independent failure surface for callers and escalation sinks. */
3
+ export class OffloadError extends Error {
4
+ kind;
5
+ code;
6
+ taskName;
7
+ constructor(options) {
8
+ super(options.message ?? `${options.taskName} ${options.kind}`, { cause: options.cause });
9
+ this.name = "OffloadError";
10
+ this.kind = options.kind;
11
+ this.code = options.code;
12
+ this.taskName = options.taskName;
13
+ }
14
+ }
15
+ /**
16
+ * Sliding-window failure counter driving edge-triggered escalation (#1822 F1/F4, round-3
17
+ * redesign). A task escalates when it accumulates `threshold` failures within the last
18
+ * `window` failure-attempts (counted across all tasks); a failure older than the window no
19
+ * longer counts. This is attempt-based — no wall-clock, no timers — so it is deterministic and
20
+ * needs no time injection to test.
21
+ *
22
+ * WHY windowed rather than a size-capped consecutive counter: any hard *count* cap on a
23
+ * consecutive-strike map either scans the map per failure (O(cap); an earlier attempt blocked
24
+ * the event loop ~11s at 40k / ~31s at 100k distinct failures) or silently drops a task that is
25
+ * genuinely accumulating (a missed page — strictly worse than the leak it replaced). The window
26
+ * gives all four invariants at once: no lost escalation, no page storm, amortized O(1) per
27
+ * failure, and a hard memory bound.
28
+ *
29
+ * IMPLEMENTATION — the standard sliding-window multiset over a ring of failure events:
30
+ * - `#ring[(clock-1) % window]` holds the failure event `window` attempts ago. Each new failure
31
+ * overwrites exactly that slot, and the event it evicts is exactly the one aging out — so a
32
+ * failure is pushed once and expired once: amortized O(1), never a scan.
33
+ * - `#live[name]` is the name's failure count still inside the window = its escalation score.
34
+ * - Memory is hard-bounded: the ring holds ≤ `window` events, hence ≤ `window` distinct names.
35
+ * - `succeed()` bumps a per-name `#epoch`; events carry the epoch they were recorded under, so a
36
+ * success invalidates the name's in-window failures (score resets) in O(1) without scanning
37
+ * the ring — the stale events simply don't count as they age out.
38
+ * - Latch (`trip`): a per-name flag so an escalation fires ONCE and does not re-fire while the
39
+ * task stays continuously failing; it clears when the task succeeds or every failure ages out.
40
+ *
41
+ * SEMANTIC NOTE (deliberate change from the first #1822 attempt): escalation is now "threshold
42
+ * failures WITHIN the window", not "threshold consecutive failures ever". Failures spread wider
43
+ * apart than the window decay and never trip — which is the actual flap-detection semantic.
44
+ */
45
+ export class StrikeCounter {
46
+ #ring;
47
+ /** Failures still inside the window under the name's CURRENT epoch = its escalation score. */
48
+ #live = new Map();
49
+ /** Total ring occurrences per name across all epochs — for O(1) cleanup and the memory bound. */
50
+ #present = new Map();
51
+ /** Per-name epoch, bumped by succeed() so pre-success failures stop counting. */
52
+ #epoch = new Map();
53
+ /** Names that have escalated and not yet re-armed (edge-trigger latch). */
54
+ #latched = new Set();
55
+ #clock = 0;
56
+ threshold;
57
+ window;
58
+ constructor(threshold = 3, escalationWindowAttempts = 4096) {
59
+ if (!Number.isSafeInteger(threshold) || threshold < 1)
60
+ throw new RangeError("escalationThreshold must be a positive integer");
61
+ if (!Number.isSafeInteger(escalationWindowAttempts) || escalationWindowAttempts < 1)
62
+ throw new RangeError("escalationWindowAttempts must be a positive integer");
63
+ // A window smaller than the threshold can never hold `threshold` of one name's failures at
64
+ // once (the ring evicts the oldest before the count gets there), so it would score 1,2,…,
65
+ // threshold-1 forever and SILENTLY never escalate. Reject it rather than mislead (#1822 LOW-4).
66
+ if (escalationWindowAttempts < threshold)
67
+ throw new RangeError("escalationWindowAttempts must be >= escalationThreshold");
68
+ this.threshold = threshold;
69
+ this.window = escalationWindowAttempts;
70
+ this.#ring = new Array(escalationWindowAttempts).fill(undefined);
71
+ }
72
+ /** Distinct task names with a failure still inside the window. Bounded by `window`. */
73
+ get size() {
74
+ return this.#present.size;
75
+ }
76
+ // Removes the event in `slot` (the one aging out of the window) and updates the name's counts.
77
+ #expire(slot) {
78
+ const event = this.#ring[slot];
79
+ if (event === undefined)
80
+ return;
81
+ this.#ring[slot] = undefined;
82
+ const { name, epoch } = event;
83
+ // Only decrement the score if the event is still under the name's live epoch; an event from
84
+ // before a succeed() carries a stale epoch and was never counted toward the current score.
85
+ if (epoch === (this.#epoch.get(name) ?? 0)) {
86
+ const live = (this.#live.get(name) ?? 0) - 1;
87
+ if (live <= 0) {
88
+ this.#live.delete(name);
89
+ this.#latched.delete(name);
90
+ }
91
+ else
92
+ this.#live.set(name, live);
93
+ }
94
+ const present = (this.#present.get(name) ?? 0) - 1;
95
+ if (present <= 0) {
96
+ // Every trace of the name has aged out of the window: forget it entirely (this is the
97
+ // natural memory bound, and it re-arms a latch that went quiet).
98
+ this.#present.delete(name);
99
+ this.#epoch.delete(name);
100
+ this.#live.delete(name);
101
+ this.#latched.delete(name);
102
+ }
103
+ else {
104
+ this.#present.set(name, present);
105
+ }
106
+ }
107
+ fail(taskName) {
108
+ this.#clock += 1;
109
+ const slot = (this.#clock - 1) % this.window;
110
+ // The event this slot holds is exactly `window` attempts old — evict it before reusing it.
111
+ this.#expire(slot);
112
+ const epoch = this.#epoch.get(taskName) ?? 0;
113
+ this.#ring[slot] = { name: taskName, epoch };
114
+ this.#present.set(taskName, (this.#present.get(taskName) ?? 0) + 1);
115
+ const score = (this.#live.get(taskName) ?? 0) + 1;
116
+ this.#live.set(taskName, score);
117
+ // A latched name must not re-page while it stays continuously live.
118
+ if (this.#latched.has(taskName))
119
+ return 0;
120
+ return score;
121
+ }
122
+ /** Latch an escalation so it fires once until the task re-arms (succeeds or ages out). */
123
+ trip(taskName) {
124
+ // Latch ONLY a name that has a failure in the window. Otherwise a caller (or a bug) tripping
125
+ // arbitrary names would grow #latched without any ring presence to age it out — breaking the
126
+ // "memory bounded BY the window" invariant, since `size` counts only #present (#1822 MED-3).
127
+ // In practice trip() is always called right after fail() returned >= threshold, so the name
128
+ // is present; this guard just makes the bound hold unconditionally.
129
+ if (this.#present.has(taskName))
130
+ this.#latched.add(taskName);
131
+ }
132
+ succeed(taskName) {
133
+ // Nothing tracked → nothing to reset (and never create an epoch entry for a name that only
134
+ // ever succeeds, which would leak one map entry per distinct successful task name).
135
+ if (!this.#present.has(taskName))
136
+ return;
137
+ // Bump the epoch: every in-window failure recorded under the old epoch stops counting, so
138
+ // the score resets to 0 in O(1). The stale ring events are reclaimed as they age out.
139
+ this.#epoch.set(taskName, (this.#epoch.get(taskName) ?? 0) + 1);
140
+ this.#live.delete(taskName);
141
+ this.#latched.delete(taskName);
142
+ }
143
+ }
144
+ export function normalizeError(taskName, error, fallback = "rejected") {
145
+ if (error instanceof OffloadError)
146
+ return error;
147
+ const detail = error;
148
+ return new OffloadError({
149
+ kind: fallback,
150
+ taskName,
151
+ code: typeof detail?.code === "string" ? detail.code : undefined,
152
+ message: errMessage(error),
153
+ cause: error,
154
+ });
155
+ }
156
+ export function log(logger, level, message, fields) {
157
+ const method = logger?.[level];
158
+ if (typeof method !== "function")
159
+ return;
160
+ try {
161
+ method.call(logger, message, fields);
162
+ }
163
+ catch {
164
+ // A caller-supplied transport that throws must never break the run it is describing:
165
+ // logging happens on the settle path, so an escaping throw wedges the backend (#1809 P0-1).
166
+ // There is no second transport to report this to, so it is deliberately swallowed.
167
+ }
168
+ }
169
+ export function validateTimeout(timeoutMs) {
170
+ if (timeoutMs === undefined)
171
+ return undefined;
172
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
173
+ throw new RangeError("timeoutMs must be a positive finite number");
174
+ return timeoutMs;
175
+ }
176
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ import { type OffloadDefaults, type OffloadEnvelope } from "./types.js";
2
+ export interface WorkerPoolOptions extends OffloadDefaults {
3
+ /** A module that default-exports `(input, { taskName }) => output`. Prefer a file URL. */
4
+ worker: URL | string;
5
+ size?: number;
6
+ }
7
+ export interface WorkerPoolStats {
8
+ busy: number;
9
+ idle: number;
10
+ queued: number;
11
+ }
12
+ export interface WorkerPool {
13
+ run<In, Out>(taskName: string, input: In, env?: OffloadEnvelope): Promise<Out>;
14
+ readonly stats: WorkerPoolStats;
15
+ readonly offThread: true;
16
+ destroy(): Promise<void>;
17
+ }
18
+ /**
19
+ * Fixed native Bun Worker pool. A deadline or abort replaces the whole worker because
20
+ * CPU-bound synchronous code cannot observe a cancellation message while it is busy.
21
+ */
22
+ export declare function createWorkerPool(options: WorkerPoolOptions): WorkerPool;
23
+ //# sourceMappingURL=worker-pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker-pool.d.ts","sourceRoot":"","sources":["../src/worker-pool.ts"],"names":[],"mappings":"AACA,OAAO,EAAgE,KAAK,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAEtI,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,0FAA0F;IAC1F,MAAM,EAAE,GAAG,GAAG,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/E,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAqDD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,UAAU,CAiYvE"}
@@ -0,0 +1,435 @@
1
+ import { beginRun, errorForExit, finishLog, recordResult, startLog } from "./envelope.js";
2
+ import { OffloadError, StrikeCounter, normalizeError, validateTimeout } from "./types.js";
3
+ // For a RETRY-SAFE job (env.retrySafe), a worker death with the job in flight is absorbed by
4
+ // re-dispatching to a fresh worker up to this many times. Reaching the limit means the job's OWN
5
+ // execution repeatedly killed the worker (a real fault), so it is then charged and still
6
+ // escalates. A non-retry-safe (default) job is never re-dispatched — it runs at most once
7
+ // (#1822 HIGH-1). See onWorkerDeath.
8
+ const MAX_JOB_REDISPATCHES = 2;
9
+ /**
10
+ * Fixed native Bun Worker pool. A deadline or abort replaces the whole worker because
11
+ * CPU-bound synchronous code cannot observe a cancellation message while it is busy.
12
+ */
13
+ export function createWorkerPool(options) {
14
+ const size = options.size ?? Math.max(1, (navigator.hardwareConcurrency || 2) - 1);
15
+ if (!Number.isSafeInteger(size) || size < 1)
16
+ throw new RangeError("size must be a positive integer");
17
+ const workerModule = options.worker instanceof URL ? options.worker.href : options.worker;
18
+ const slots = [];
19
+ const queued = [];
20
+ const counter = new StrikeCounter(options.escalationThreshold, options.escalationWindowAttempts);
21
+ let nextId = 1;
22
+ let destroyed = false;
23
+ let destroyPromise;
24
+ let dispatching = false;
25
+ let compactionScheduled = false;
26
+ let cancelledQueued = 0;
27
+ const createSlot = () => {
28
+ const slot = { worker: undefined, generation: 0, replacing: false };
29
+ spawnInto(slot);
30
+ return slot;
31
+ };
32
+ // A worker that dies while idle (OOM kill, external SIGKILL, a crash between jobs) leaves a
33
+ // dead handle in the slot. Respawn it so the next run() gets a live worker; nobody is charged
34
+ // because no job was running (#1822 item 2). There is deliberately NO revival breaker: a tight
35
+ // spawn-death loop would require the worker BOOTSTRAP itself to fail to load repeatedly, which
36
+ // cannot happen through the public API (the bootstrap is this package's own module), so a
37
+ // breaker for it is disproportionate surface (#1822 F3 — simplified). A job that races this
38
+ // revival is protected by re-dispatch (onWorkerDeath), never by counting idle deaths.
39
+ const reviveIdle = (slot) => {
40
+ if (destroyed || slot.replacing)
41
+ return;
42
+ void replace(slot);
43
+ };
44
+ const spawnInto = (slot) => {
45
+ if (destroyed)
46
+ return;
47
+ const generation = ++slot.generation;
48
+ // `.js` is intentional: package consumers execute the compiled dist asset. Bun maps this
49
+ // to the sibling TypeScript source while running the workspace directly in development.
50
+ const worker = new Worker(new URL("./worker-runtime.js", import.meta.url), { type: "module" });
51
+ slot.worker = worker;
52
+ worker.onmessage = (event) => {
53
+ if (slot.generation !== generation)
54
+ return;
55
+ const active = slot.active;
56
+ if (!active || active.id !== event.data.id)
57
+ return;
58
+ if (event.data.ok)
59
+ settle(slot, active, undefined, event.data.value);
60
+ else
61
+ settle(slot, active, new OffloadError({
62
+ kind: "worker-error", taskName: active.taskName, code: event.data.error?.code, message: event.data.error?.message ?? "Worker task failed",
63
+ }));
64
+ };
65
+ worker.onerror = (event) => {
66
+ if (slot.generation !== generation)
67
+ return;
68
+ const active = slot.active;
69
+ if (!active) {
70
+ reviveIdle(slot);
71
+ return;
72
+ }
73
+ // An uncaught error is by definition an abnormal termination attributable to the running job.
74
+ onWorkerDeath(slot, active, new OffloadError({ kind: "worker-error", taskName: active.taskName, message: event.message || "Worker errored", cause: event.error }), true);
75
+ };
76
+ // Bun reports process.exit(), OOM, and hard crashes through `close` without an `error` event.
77
+ // Reclaiming the slot here is essential when a caller omitted a timeout — and when the slot is
78
+ // idle, so a later run() never inherits the dead worker (#1822 item 2). The close event carries
79
+ // the exit `code`: 0 is a clean exit the worker CHOSE (not the job's fault); anything else is an
80
+ // abnormal termination attributable to the in-flight job (#1822 MED-4). A missing code is
81
+ // treated as abnormal — the safe default is to preserve escalation, not to drop it.
82
+ worker.addEventListener("close", (event) => {
83
+ if (slot.generation !== generation)
84
+ return;
85
+ const active = slot.active;
86
+ if (!active) {
87
+ reviveIdle(slot);
88
+ return;
89
+ }
90
+ const code = event.code;
91
+ const clean = code === 0;
92
+ onWorkerDeath(slot, active, errorForExit(active.taskName, clean ? undefined : String(code)), !clean);
93
+ });
94
+ };
95
+ const settle = (slot, active, error, value) => {
96
+ if (active.settled)
97
+ return;
98
+ active.settled = true;
99
+ active.context.cleanup();
100
+ active.removeAbort();
101
+ slot.active = undefined;
102
+ const labels = active.env?.labels ?? {};
103
+ const logger = active.env?.logger ?? options.logger;
104
+ finishLog(logger, "worker", active.taskName, labels, active.startedAt, error);
105
+ void recordResult({
106
+ counter, taskName: active.taskName, backend: "worker", labels, error,
107
+ sink: active.env?.onEscalate ?? options.onEscalate, logger,
108
+ });
109
+ if (error)
110
+ active.reject(error);
111
+ else
112
+ active.resolve(value);
113
+ dispatch();
114
+ };
115
+ // `error` is omitted only when reviving an idle slot: with no active job there is nothing to
116
+ // settle and nobody to charge, so the slot is simply respawned (#1822 item 2).
117
+ const replace = async (slot, error) => {
118
+ if (slot.replacing)
119
+ return;
120
+ slot.replacing = true;
121
+ const active = slot.active;
122
+ if (active && error)
123
+ settle(slot, active, error);
124
+ const old = slot.worker;
125
+ slot.generation += 1;
126
+ try {
127
+ await old.terminate();
128
+ }
129
+ catch { /* worker is already gone */ }
130
+ if (!destroyed) {
131
+ spawnInto(slot);
132
+ slot.replacing = false;
133
+ dispatch();
134
+ }
135
+ };
136
+ /** Fails exactly one job without touching the slot it was about to occupy. */
137
+ const failJob = (job, error) => {
138
+ // Every terminal path must be visible in the structured stream, including the ones that
139
+ // never reached startLog — otherwise the failure that used to wedge the pool is silent.
140
+ finishLog(job.env?.logger ?? options.logger, "worker", job.taskName, job.env?.labels ?? {}, performance.now(), error);
141
+ job.reject(error);
142
+ void recordResult({
143
+ counter, taskName: job.taskName, backend: "worker", labels: job.env?.labels ?? {}, error,
144
+ sink: job.env?.onEscalate ?? options.onEscalate, logger: job.env?.logger ?? options.logger,
145
+ });
146
+ };
147
+ // Attaches the while-queued abort wiring and enqueues a job. Shared by run() and redispatch()
148
+ // so a re-dispatched job re-arms its abort handling exactly like a fresh one. `front` puts an
149
+ // innocent, re-dispatched job ahead of newer work so its retry is prompt.
150
+ const enqueue = (job, front = false) => {
151
+ const labels = job.env?.labels ?? {};
152
+ const logger = job.env?.logger ?? options.logger;
153
+ const signal = job.env?.signal;
154
+ if (signal?.aborted) {
155
+ const error = new OffloadError({ kind: "aborted", taskName: job.taskName, message: `${job.taskName} was aborted`, cause: signal.reason });
156
+ finishLog(logger, "worker", job.taskName, labels, performance.now(), error);
157
+ job.reject(error);
158
+ return;
159
+ }
160
+ if (signal) {
161
+ // Without this, a job aborted while queued waits for a slot to free before it is
162
+ // rejected — unbounded when the occupying worker has no deadline (#1809 P1-8).
163
+ const onQueuedAbort = () => {
164
+ if (job.cancelled)
165
+ return;
166
+ job.cancelled = true;
167
+ cancelledQueued += 1;
168
+ scheduleCompaction();
169
+ const error = new OffloadError({ kind: "aborted", taskName: job.taskName, message: `${job.taskName} was aborted`, cause: signal.reason });
170
+ // An abort that lands mid-run logs `offload failed kind=aborted`; one that lands
171
+ // while queued must not be the single asymmetric case that logs nothing.
172
+ finishLog(logger, "worker", job.taskName, labels, performance.now(), error);
173
+ job.reject(error);
174
+ };
175
+ signal.addEventListener("abort", onQueuedAbort, { once: true });
176
+ job.releaseQueued = () => signal.removeEventListener("abort", onQueuedAbort);
177
+ }
178
+ if (front)
179
+ queued.unshift(job);
180
+ else
181
+ queued.push(job);
182
+ dispatch();
183
+ };
184
+ // Fails a job WITHOUT charging a strike, for a death that is not attributable to the job's own
185
+ // execution (an idle-death race — the job never ran). Still logged so the stream is complete.
186
+ const failJobUncharged = (job, error) => {
187
+ finishLog(job.env?.logger ?? options.logger, "worker", job.taskName, job.env?.labels ?? {}, performance.now(), error);
188
+ job.reject(error);
189
+ };
190
+ // A worker died with `active` still in flight and unsettled. Two axes decide the outcome:
191
+ // - RE-RUN? Only a retry-safe (idempotent) job may re-run, and only while it has retries left.
192
+ // A default job runs AT MOST ONCE, so it is never re-dispatched (#1822 HIGH-1) — re-running
193
+ // a side-effecting job would repeat its effect. A retry-safe job re-dispatches on ANY death
194
+ // (clean or abnormal) while retries remain, since re-running it is safe by construction.
195
+ // - CHARGE? Only when the death is ATTRIBUTABLE to the job's own execution, decided from the
196
+ // worker's EXIT MANNER observed by the pool: an abnormal termination (non-zero exit, signal,
197
+ // or uncaught error) with this job in flight is charged; a clean exit (code 0 — the worker
198
+ // chose to stop) or a job that never reached a worker (postMessage threw) is an idle-race /
199
+ // infra event, not a task fault, and is rejected WITHOUT a strike. Exit manner is used instead
200
+ // of a worker-sent execution-start frame precisely because that frame can be LOST when a hard
201
+ // crash races it, which would let a job that reliably crashes its worker escape escalation
202
+ // forever (#1822 MED-4). `attributable` already covers retry-exhaustion: a retry-safe job that
203
+ // crashes every worker exhausts its retries on an abnormal death, so that final death charges.
204
+ const onWorkerDeath = (slot, active, error, attributable) => {
205
+ const retrySafe = active.retrySafe;
206
+ if (retrySafe && active.redispatches < MAX_JOB_REDISPATCHES) {
207
+ redispatch(slot, active);
208
+ return;
209
+ }
210
+ if (attributable) {
211
+ // `replace` settles the still-attached active job with the error, charging it once.
212
+ void replace(slot, error);
213
+ return;
214
+ }
215
+ // Clean exit / never executed → do not charge. Detach, respawn a fresh worker, reject the caller.
216
+ slot.active = undefined;
217
+ active.removeAbort();
218
+ active.context.cleanup();
219
+ void replace(slot);
220
+ failJobUncharged(active, error);
221
+ };
222
+ // Detaches a retry-safe job from a dead slot, respawns a fresh worker there, and re-queues the
223
+ // SAME caller promise so an idempotent job that raced a worker death runs cleanly. The absolute
224
+ // deadline is carried on the job, so the retry continues the original deadline (#1822 MED-2).
225
+ const redispatch = (slot, active) => {
226
+ slot.active = undefined;
227
+ active.removeAbort();
228
+ active.context.cleanup();
229
+ const job = {
230
+ id: active.id, taskName: active.taskName, input: active.input, env: active.env,
231
+ retrySafe: active.retrySafe, timeoutMs: active.timeoutMs,
232
+ cancelled: false, redispatches: active.redispatches + 1, deadlineAt: active.deadlineAt,
233
+ releaseQueued: () => { }, resolve: active.resolve, reject: active.reject,
234
+ };
235
+ // Respawn FIRST so the slot is marked `replacing` synchronously and dispatch cannot hand the
236
+ // re-queued job straight back to the same corpse; replace()'s own dispatch then serves it.
237
+ void replace(slot);
238
+ enqueue(job, true);
239
+ };
240
+ const start = (slot, job) => {
241
+ job.releaseQueued();
242
+ let context;
243
+ let removeAbort;
244
+ try {
245
+ // Fix the deadline at the FIRST execution and carry it, so a retry-safe re-dispatch
246
+ // continues the original deadline rather than restarting the full timeout (#1822 MED-2).
247
+ // The budget comes from the SUBMISSION-TIME snapshot (`job.timeoutMs`), never from the
248
+ // mutable envelope, so a caller cannot erase or add a deadline after submission (#1822 MED).
249
+ const baseTimeoutMs = job.timeoutMs;
250
+ let effectiveTimeoutMs = baseTimeoutMs;
251
+ if (baseTimeoutMs !== undefined) {
252
+ if (job.deadlineAt === undefined)
253
+ job.deadlineAt = performance.now() + baseTimeoutMs;
254
+ const remaining = job.deadlineAt - performance.now();
255
+ if (remaining <= 0) {
256
+ // The absolute deadline set on the first execution has already elapsed. A re-dispatch
257
+ // must NOT be granted a fresh run past it (the old `Math.max(1, …)` handed out a 1ms
258
+ // execution AFTER expiry — #1822 MED). A genuine timeout is attributable, so charge it.
259
+ failJob(job, new OffloadError({ kind: "timeout", taskName: job.taskName, message: `${job.taskName} timed out after ${baseTimeoutMs}ms` }));
260
+ return;
261
+ }
262
+ effectiveTimeoutMs = remaining;
263
+ }
264
+ // Pass the ALREADY-RESOLVED timeout as the authoritative override, so beginRun installs
265
+ // exactly it and re-reads neither the envelope NOR the mutable pool default at dispatch —
266
+ // a post-submission mutation of either cannot add or erase this job's deadline (#1822 MED).
267
+ // `null` (not `undefined`) carries "no deadline" so beginRun does not mistake it for "no
268
+ // argument" and fall back to the default. The live envelope is still passed for its signal.
269
+ context = beginRun(job.taskName, job.env, options, undefined, effectiveTimeoutMs ?? null);
270
+ if (context.signal.aborted) {
271
+ const error = context.abortError();
272
+ context.cleanup();
273
+ failJob(job, error);
274
+ return;
275
+ }
276
+ const run = context;
277
+ const onAbort = () => { void replace(slot, run.abortError()); };
278
+ run.signal.addEventListener("abort", onAbort, { once: true });
279
+ removeAbort = () => run.signal.removeEventListener("abort", onAbort);
280
+ const active = {
281
+ ...job, context: run, removeAbort, settled: false,
282
+ startedAt: startLog(job.env?.logger ?? options.logger, "worker", job.taskName, job.env?.labels ?? {}),
283
+ };
284
+ slot.active = active;
285
+ try {
286
+ slot.worker.postMessage({ id: job.id, taskName: job.taskName, input: job.input, worker: workerModule });
287
+ }
288
+ catch (error) {
289
+ // postMessage throws when the worker died between dispatch and here (an idle-death race):
290
+ // the job never reached a worker, so it is NOT attributable — re-dispatch it (retrySafe)
291
+ // or reject it uncharged rather than charge it for a death it did not cause (#1822 F2).
292
+ onWorkerDeath(slot, active, normalizeError(job.taskName, error, "worker-error"), false);
293
+ }
294
+ }
295
+ catch (error) {
296
+ // Anything that throws while starting (an invalid deadline, a hostile logger) must fail
297
+ // ONLY this job. Letting it escape leaves the slot idle with the queue stuck (#1809 P0-1),
298
+ // and the caller must still see an OffloadError rather than the raw RangeError.
299
+ removeAbort?.();
300
+ context?.cleanup();
301
+ if (slot.active?.id === job.id)
302
+ slot.active = undefined;
303
+ failJob(job, normalizeError(job.taskName, error, "rejected"));
304
+ }
305
+ };
306
+ /**
307
+ * Iterative on purpose: the previous version recursed through `start()` for every
308
+ * pre-aborted job, so a mass abort over a deep queue blew the stack (#1809 P0-2).
309
+ * The guard makes the nested `dispatch()` calls on the settle path no-ops — the loop
310
+ * below re-reads slot and queue state on every turn, so no wakeup is lost.
311
+ */
312
+ const dispatch = () => {
313
+ if (destroyed || dispatching)
314
+ return;
315
+ dispatching = true;
316
+ try {
317
+ while (!destroyed && queued.length) {
318
+ const job = queued[0];
319
+ if (job.cancelled) {
320
+ queued.shift();
321
+ cancelledQueued -= 1;
322
+ continue;
323
+ }
324
+ const slot = slots.find((candidate) => !candidate.active && !candidate.replacing);
325
+ if (!slot)
326
+ break;
327
+ queued.shift();
328
+ start(slot, job);
329
+ }
330
+ }
331
+ finally {
332
+ dispatching = false;
333
+ }
334
+ };
335
+ /**
336
+ * A mass abort marks jobs in place — splicing each one out would be quadratic over a
337
+ * deep queue — so the dead entries are swept once per batch instead.
338
+ */
339
+ const scheduleCompaction = () => {
340
+ if (compactionScheduled)
341
+ return;
342
+ compactionScheduled = true;
343
+ queueMicrotask(() => {
344
+ compactionScheduled = false;
345
+ if (cancelledQueued <= 0)
346
+ return;
347
+ const kept = queued.filter((job) => !job.cancelled);
348
+ queued.length = 0;
349
+ for (const job of kept)
350
+ queued.push(job);
351
+ cancelledQueued = 0;
352
+ });
353
+ };
354
+ for (let index = 0; index < size; index += 1)
355
+ slots.push(createSlot());
356
+ return {
357
+ offThread: true,
358
+ get stats() {
359
+ const busy = slots.filter((slot) => slot.active).length;
360
+ return { busy, idle: slots.length - busy, queued: queued.length - cancelledQueued };
361
+ },
362
+ run(taskName, input, env) {
363
+ const labels = env?.labels ?? {};
364
+ const logger = env?.logger ?? options.logger;
365
+ // Rejecting before the run ever starts is still a terminal outcome, so it gets the same
366
+ // one line in the structured stream that every other settle path emits.
367
+ const rejectBeforeStart = (error) => {
368
+ finishLog(logger, "worker", taskName, labels, performance.now(), error);
369
+ return Promise.reject(error);
370
+ };
371
+ if (destroyed)
372
+ return rejectBeforeStart(new OffloadError({ kind: "worker-exit", taskName, message: "Worker pool has been destroyed" }));
373
+ // Validated here rather than at dispatch so an invalid deadline fails the caller fast,
374
+ // with the OffloadError every backend promises, even when a slot is free (#1809 P0-1). The
375
+ // validated value is SNAPSHOTTED onto the job so the deadline it drives is fixed at
376
+ // submission and immune to a later envelope mutation (#1822 MED).
377
+ let timeoutMs;
378
+ try {
379
+ timeoutMs = validateTimeout(env?.timeoutMs ?? options.defaultTimeoutMs);
380
+ }
381
+ catch (error) {
382
+ const normalized = normalizeError(taskName, error, "rejected");
383
+ // Counted as a strike like the other two backends do for the same input: an invalid
384
+ // deadline is a genuine caller failure, not a cancellation.
385
+ void recordResult({ counter, taskName, backend: "worker", labels, error: normalized, sink: env?.onEscalate ?? options.onEscalate, logger });
386
+ return rejectBeforeStart(normalized);
387
+ }
388
+ // `retrySafe` is snapshotted here too, for the same reason (#1822 HIGH): the pool must decide
389
+ // re-dispatch from the caller's intent AT SUBMISSION, not from whatever `env` holds when a
390
+ // worker later dies.
391
+ const retrySafe = env?.retrySafe === true;
392
+ return new Promise((resolve, reject) => {
393
+ const job = { id: nextId++, taskName, input, env, retrySafe, timeoutMs, cancelled: false, redispatches: 0, releaseQueued: () => { }, resolve, reject };
394
+ enqueue(job);
395
+ });
396
+ },
397
+ destroy() {
398
+ if (destroyPromise)
399
+ return destroyPromise;
400
+ destroyed = true;
401
+ // The split below is INTENTIONAL — do not unify the two.
402
+ // Cancelled-at-destroy is `aborted`, matching what the async and subprocess runners
403
+ // already report for teardown: only `aborted` is strike-exempt, so anything else
404
+ // charges a strike per in-flight job and a pool sitting at its threshold false-pages
405
+ // on the orderly shutdown every adopter runs. A `run()` arriving AFTER destroy() is a
406
+ // different event — caller misuse against a dead pool — and deliberately keeps its own
407
+ // distinguishable `worker-exit` kind.
408
+ const error = (taskName) => new OffloadError({ kind: "aborted", taskName, message: `${taskName} was cancelled: worker pool destroyed` });
409
+ while (queued.length) {
410
+ const job = queued.shift();
411
+ if (!job)
412
+ continue;
413
+ job.releaseQueued();
414
+ if (job.cancelled)
415
+ continue;
416
+ const cancellation = error(job.taskName);
417
+ finishLog(job.env?.logger ?? options.logger, "worker", job.taskName, job.env?.labels ?? {}, performance.now(), cancellation);
418
+ job.reject(cancellation);
419
+ }
420
+ cancelledQueued = 0;
421
+ const workers = slots.map(async (slot) => {
422
+ if (slot.active)
423
+ settle(slot, slot.active, error(slot.active.taskName));
424
+ slot.generation += 1;
425
+ try {
426
+ await slot.worker.terminate();
427
+ }
428
+ catch { /* already stopped */ }
429
+ });
430
+ destroyPromise = Promise.all(workers).then(() => undefined);
431
+ return destroyPromise;
432
+ },
433
+ };
434
+ }
435
+ //# sourceMappingURL=worker-pool.js.map