@genroc/eval-node 0.0.0-edge.627a84e1 → 0.0.0-edge.63f3718c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/eval.js CHANGED
@@ -1,21 +1,28 @@
1
- // Imports a script module in its own realm (a Worker per execution, realm.ts), calls its default
2
- // export, and classifies every outcome (README.md). Only a killable thread bounds a synchronous
3
- // busy loop, which is what makes the budget real. Knows nothing of genroc; worker.ts does.
1
+ // Evaluation core: import a script module in its OWN realm, call its default export, and
2
+ // classify every outcome into one of the failure kinds in README.md. Nothing here knows about
3
+ // genroc — worker.ts is the only thing that talks to the queue — so this stays testable and
4
+ // the containment stays swappable.
5
+ //
6
+ // The containment is a Worker per execution (realm.ts). It is what makes the budget real:
7
+ // a synchronous busy loop never yields, so no in-process timer can interrupt it, and only a
8
+ // thread the host can kill bounds it.
4
9
  import { Worker } from "node:worker_threads";
5
10
  const DEFAULT_TIMEOUT_MS = 5_000;
6
11
  // Resolved from THIS file's own extension: run from a checkout it is `.ts`, and from the
7
12
  // published package `.js`, because Node will not strip types under node_modules.
8
13
  const REALM_URL = new URL(import.meta.url.endsWith(".ts") ? "./realm.ts" : "./realm.js", import.meta.url);
9
- /** Thrown, not returned: a realm that fails to start is the runner faulting, which worker.ts
10
- * answers by releasing the claim. */
14
+ /** Thrown, not returned: a realm that fails to start is the RUNNER faulting, which worker.ts
15
+ * answers by releasing the claim rather than by reporting an outcome. A script fault is a
16
+ * return value. */
11
17
  class RealmFault extends Error {
12
18
  constructor(message) {
13
19
  super(message);
14
20
  this.name = "RealmFault";
15
21
  }
16
22
  }
17
- /** Thrown when `signal` aborts: the answer is no longer wanted, so worker.ts releases rather
18
- * than answers. Not a RealmFault: nothing is wrong. */
23
+ /** Thrown when `signal` aborts: the work was cancelled server-side, so there is no outcome to
24
+ * report and worker.ts must release rather than answer. Distinct from RealmFault because the
25
+ * runner did not fault -- nothing is wrong, the answer is simply no longer wanted. */
19
26
  export class Cancelled extends Error {
20
27
  constructor() {
21
28
  super("cancelled");
@@ -39,8 +46,9 @@ export async function evaluate(req, signal) {
39
46
  signal.addEventListener("abort", onAbort, { once: true });
40
47
  }
41
48
  worker.once("message", (reply) => resolve(reply));
42
- // A script may `process.exit()`, which would otherwise look like a hang until the budget.
43
- // Our own terminate() fires this too, after the promise has settled.
49
+ // A script may end its own realm (`process.exit()`), which is not a throw and would
50
+ // otherwise present as a hang until the budget expired. Our own terminate() raises
51
+ // this too, by which time the promise has settled and the first result stands.
44
52
  worker.once("exit", (code) => resolve(exited(code)));
45
53
  worker.once("error", (err) => reject(new RealmFault(errorText(err))));
46
54
  worker.postMessage({ code: req.code, input: req.input });
package/dist/import.js CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, diagnostic on stderr
3
- // with a non-zero exit (specs/source-resolution.md). Two modes share one `tsc` pass: "types"
4
- // writes declarations only, "build" typechecks and bundles.
2
+ // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, non-zero exit
3
+ // with the diagnostic on stderr. genctl never parses TypeScript and this never parses YAML
4
+ // — the manifest is the whole contract. See specs/source-resolution.md.
5
+ //
6
+ // Two modes, one binary: "types" writes the declarations an editor needs and returns no
7
+ // code; "build" typechecks and bundles. A separate types hook would mean a second `tsc`
8
+ // over the same project.
5
9
  import { existsSync } from "node:fs";
6
10
  import { access, writeFile } from "node:fs/promises";
7
11
  import { builtinModules } from "node:module";
@@ -133,8 +137,9 @@ function typesPathFor(scriptPath) {
133
137
  return scriptPath.replace(/\.[^.\/]+$/, "") + ".genroc.d.ts";
134
138
  }
135
139
  // ── typecheck ──────────────────────────────────────────────────────────────────
136
- /** The tsconfig the author's editor reads, so editor and apply agree. Stops at the project
137
- * root. */
140
+ /** The nearest tsconfig above the script — the one the author's editor already reads. Two
141
+ * different configs mean a red editor over a clean apply, or the reverse. The walk stops at
142
+ * the project root: above it is not this project. */
138
143
  async function nearestTsconfig(from, root) {
139
144
  for (let dir = from;; dir = dirname(dir)) {
140
145
  const candidate = join(dir, "tsconfig.json");
@@ -170,8 +175,9 @@ async function typecheck(sites) {
170
175
  // `lib` DESCRIBES the realm and is written after `extends` so a base cannot widen it:
171
176
  // a worker thread has no document, whatever an author's config claims.
172
177
  lib: ["esnext", "webworker"],
173
- // `types` is the author's opt-in to node globals, which the realm has. With no base
174
- // config there is nothing to opt in with, so it stays none.
178
+ // `types` is the author's, and it is how a script opts into the node globals —
179
+ // the worker realm has them, so refusing the declarations would only lie. With no
180
+ // base config there is nothing to opt in with, so the default stays none.
175
181
  ...(base ? {} : { types: [] }),
176
182
  },
177
183
  files: group.flatMap((s) => [s.file, typesPathFor(s.file)]),
@@ -179,8 +185,9 @@ async function typecheck(sites) {
179
185
  // drag the author's whole tree in, to be checked under the worker lib.
180
186
  include: [],
181
187
  };
182
- // Never written; anchors `@types` lookup at the root. Not `tsconfig.json`: that is often the
183
- // base it extends, and tsc refuses a circular extends.
188
+ // Never written: the name only anchors `@types` lookup at the root. It must not be
189
+ // `tsconfig.json`: that is the base the config `extends` whenever the author keeps one at
190
+ // the root, and tsc refuses a config that extends its own path as circular.
184
191
  const parsed = ts.parseJsonConfigFileContent(config, ts.sys, root, undefined, join(root, "tsconfig.genroc.json"));
185
192
  const program = ts.createProgram({
186
193
  rootNames: parsed.fileNames,
@@ -226,9 +233,11 @@ const BUILTIN = new Set([
226
233
  ...builtinModules,
227
234
  ...builtinModules.map((m) => `node:${m}`),
228
235
  ]);
229
- /** Resolves through TYPESCRIPT under the typecheck's config, so a `paths` alias that compiles
230
- * also bundles. A `.d.ts` resolution is declined (a type, not code), leaving node_modules to
231
- * nodeResolve. */
236
+ /** Resolves imports through TYPESCRIPT, using the same config the typecheck ran under, so a
237
+ * `paths` alias that compiles also bundles. Reimplementing `paths` here would be a second
238
+ * resolver to keep in agreement with tsc; this one cannot disagree.
239
+ * A package resolving to a `.d.ts` is declined — that is a type, not the implementation —
240
+ * which leaves node_modules to nodeResolve. */
232
241
  function tsResolve(configPath) {
233
242
  let options = {};
234
243
  if (configPath) {
@@ -249,12 +258,14 @@ function tsResolve(configPath) {
249
258
  },
250
259
  };
251
260
  }
252
- /** A self-contained ES module whose default export is the author's own, unwrapped. Bundling here
253
- * means a definition version pins its code forever. */
261
+ /** Bundles to a self-contained ES module, which is what the evaluator imports: the default
262
+ * export it calls is the author's own, so nothing wraps or rewrites the code between the two.
263
+ * Bundling is entirely the importer's job, so a definition version pins its code forever. */
254
264
  async function bundle(at) {
255
265
  const site = at.site;
256
- // Builtins are externalised; anything else unresolved is a REFUSAL, since rollup's default
257
- // leaves an import that bundles clean and fails at runtime.
266
+ // Builtins are EXTERNALISED as imports the realm resolves natively. Anything else
267
+ // unresolved is a REFUSAL, not an external: rollup's default is to leave it as an import
268
+ // of a module that will not be there, which bundles clean and fails at runtime.
258
269
  const built = await rollup({
259
270
  input: at.file,
260
271
  external: (id) => BUILTIN.has(id),
@@ -262,7 +273,8 @@ async function bundle(at) {
262
273
  tsResolve(await nearestTsconfig(dirname(at.file), root)),
263
274
  nodeResolve({ extensions: [".ts", ".tsx", ".mjs", ".js", ".json"] }),
264
275
  commonjs(),
265
- // Inlines `.json` imports; without it rollup hands JSON to the JS parser.
276
+ // A `.json` import is a data file inlined at build time, which the previous bundler
277
+ // did natively; without it rollup hands the JSON to the JS parser.
266
278
  json(),
267
279
  transpile,
268
280
  ],
@@ -312,10 +324,15 @@ const located = manifest.processes.flatMap((where) => where.sites.map((site) =>
312
324
  where,
313
325
  file: resolve(where.dir, site.argument),
314
326
  })));
315
- // The `code`-field contract is this resolver's, not genctl's, so it checks the directive landed
316
- // there: in a child call (the scaffold's shape) or an external task's input.
327
+ // genctl is agnostic about what a script is for; the contract that an evaluation request carries
328
+ // its module in `code` is THIS resolver's, so it is the one that checks a directive landed there.
329
+ // `child` is the shape the scaffold uses — a call to a process that forwards to the evaluator —
330
+ // and `external` is the same request made directly.
317
331
  for (const at of located) {
318
- // The action's kind is a field; where in it the directive sits is the pointer.
332
+ // Two halves, from the two places that carry them: the action's KIND is a field, and WHERE in
333
+ // it the directive sits is the pointer. A child call is the shape the scaffold generates — to a
334
+ // process that forwards to the evaluator — and an external task is the same request made
335
+ // directly.
319
336
  const kind = at.site.level === "action" ? (at.site.action ?? "") : "";
320
337
  const slot = at.site.pointer.slice(-2).join(".");
321
338
  if ((kind !== "child" && kind !== "external") || slot !== "input.code") {
@@ -324,7 +341,8 @@ for (const at of located) {
324
341
  `${kind ? `a ${kind} task's ` : ""}\`${slot}\`.`);
325
342
  }
326
343
  }
327
- // genctl does not know the argument is a file, so this resolver reports a missing one.
344
+ // genctl no longer stats the argument — it does not know it is a file — so the resolver that
345
+ // does is the one that must say when it is not there.
328
346
  for (const at of located) {
329
347
  if (!existsSync(at.file)) {
330
348
  die(`${join(at.where.dir, at.where.file)}: ${address(at.site.pointer)}: ` +
package/dist/realm.js CHANGED
@@ -1,5 +1,9 @@
1
- // The evaluation realm: one Worker per execution, killable mid-loop by eval.ts, which owns the
2
- // budget. Everything touching the script's VALUE lives here, the only realm it exists in.
1
+ // The evaluation realm. One Worker per execution: a fresh global object per script, and a
2
+ // thread the host can kill mid-loop — the only thing that bounds a synchronous busy loop.
3
+ // eval.ts owns the budget and does the killing; nothing here knows about time.
4
+ //
5
+ // Everything that touches the script's VALUE lives on this side of the boundary — loading,
6
+ // classifying, serialising — because this is the only realm the value exists in.
3
7
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
4
8
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
5
9
  return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
@@ -10,8 +14,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
10
14
  };
11
15
  import { registerHooks } from "node:module";
12
16
  import { parentPort } from "node:worker_threads";
13
- // IMPORTED under our own URL rather than compiled from a string, so frames carry the author's
14
- // line numbers and builtin imports resolve normally.
17
+ // The script is IMPORTED as a module under a URL of our own, not compiled from a string: its
18
+ // frames then carry the author's own line numbers, and an `import` of a node builtin resolves
19
+ // the way it does everywhere else.
15
20
  const SCRIPT_URL = "script:main";
16
21
  const STACK_BYTES = 2_048;
17
22
  // The only channel a load hook has to the source. Written per request and read once — a realm
@@ -49,8 +54,9 @@ function safeText(v) {
49
54
  return String(v);
50
55
  }
51
56
  }
52
- /** Unparseable code or an unresolvable import is broken code; anything else the import throws is
53
- * the module's top level running, i.e. the script throwing. */
57
+ /** A module that will not parse, or names an import nothing resolves, is broken code — only
58
+ * editing it helps. Anything else thrown by the import is the module's top level running,
59
+ * which is the script throwing. */
54
60
  function unloadable(err) {
55
61
  const code = err?.code;
56
62
  return err instanceof SyntaxError || (typeof code === "string" && code.startsWith("ERR_MODULE"));
@@ -91,8 +97,10 @@ async function run(req) {
91
97
  return { ok: false, failure: describe(err, "nonserializable") };
92
98
  }
93
99
  }
94
- // eval.ts terminates this thread the moment the reply lands, while stdio may still be in the
95
- // pipe. An empty write's callback fires once the queue drains, making the reply a barrier.
100
+ // The realm's stdio is a pipe to the host thread, and eval.ts terminates this thread the moment
101
+ // the reply lands — so whatever a script wrote last is still in the pipe when it dies. An empty
102
+ // write's callback fires once the queue ahead of it has drained, which makes the reply a barrier
103
+ // for `console` and a direct process.stdout.write alike: both are this stream.
96
104
  function flush(stream) {
97
105
  return new Promise((resolve) => stream.write("", () => resolve()));
98
106
  }
package/dist/worker.js CHANGED
@@ -1,18 +1,31 @@
1
1
  #!/usr/bin/env node
2
- // The queue worker: claims parked `external` script tasks, evaluates each in its own realm, and
3
- // answers. The only genroc-facing half (README.md; specs/external-task-queue.md).
2
+ // The queue worker: claims parked `external` script tasks from genroc, evaluates each in its
3
+ // own realm, and answers. This is the whole genroc-facing half — eval.ts and realm.ts know
4
+ // nothing about the queue, which is what keeps the containment strategy swappable.
5
+ //
6
+ // See README.md for the contract, and specs/external-task-queue.md for the queue itself.
4
7
  import { readFileSync } from "node:fs";
5
8
  import { Cancelled, evaluate } from "./eval.js";
6
9
  const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
7
10
  const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
8
- // Needs only the `worker` permission (specs/api-auth.md §5): this credential sits on the machine
9
- // you trust least. A header, because Node's fetch refuses credentials in a URL. The file form
10
- // keeps it out of `docker inspect` and child environments; the inline variable wins.
11
+ // The credential, when the server runs with --auth token. A worker needs exactly the `worker`
12
+ // permission — the four queue verbs plus GET /api/objects — so mint it scoped rather than
13
+ // handing a worker an admin token: this is the credential most likely to sit on a machine you
14
+ // trust least. specs/api-auth.md §5.
15
+ //
16
+ // Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
17
+ // credentials ("Request cannot be constructed from a URL that includes credentials"), so the
18
+ // basic-auth-in-the-URL trick that works for genctl is not available here.
19
+ // GENROC_TOKEN_FILE is the mounted-secret shape: a credential in a file rather than an
20
+ // environment variable, so it stays out of `docker inspect` and out of the process environment
21
+ // any child inherits. The inline variable wins when both are set.
11
22
  const TOKEN = process.env.GENROC_TOKEN ??
12
23
  (process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
13
24
  const authHeaders = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
14
- // The worker claims only what it can run, so a backlog is a queue rather than threads fighting
15
- // over a core.
25
+ // Concurrency is the worker's to set, and that is the point of pulling: under the old fetch
26
+ // shape genroc decided how many scripts ran at once (--max-concurrent, default 200) and the
27
+ // evaluator accepted every one of them. Here it claims what it can run and no more, so a
28
+ // backlog is a queue rather than 200 threads fighting over a core.
16
29
  const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
17
30
  const POLL_MS = Number(process.env.POLL_MS ?? 250);
18
31
  // The visibility timeout. Short, and renewed while work is in flight: a worker that dies
@@ -21,15 +34,17 @@ const LEASE_MS = Number(process.env.LEASE_MS ?? 30_000);
21
34
  const RENEW_MS = Math.max(1_000, Math.floor(LEASE_MS / 3));
22
35
  const PROCESS_FILTER = process.env.PROCESS ?? "";
23
36
  const TASK_FILTER = process.env.TASK ?? "";
24
- // Large values arrive as refs; a bundle is one object per definition version, fetched once.
25
- // Refs are content hashes, so the cache never invalidates.
37
+ // Values too large to ship inline are listed rather than carried, and a bundle is exactly that:
38
+ // one object shared by every instance of a definition version, fetched once instead of copied
39
+ // into each task. A ref is a content hash, so it is immutable — the cache never invalidates.
26
40
  const objectCache = new Map();
27
41
  async function fetchObject(ref) {
28
42
  const cached = objectCache.get(ref);
29
43
  if (cached !== undefined)
30
44
  return cached;
31
- // Left to throw: a task whose input cannot be fetched must not run against a missing value.
32
- // The caller releases the claim.
45
+ // Left to throw on purpose: this runs while a task is IN FLIGHT, and a task whose input
46
+ // cannot be fetched must fail rather than silently run against a missing value. The caller
47
+ // releases the claim, so the task returns to the queue.
33
48
  const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
34
49
  if (!res.ok)
35
50
  throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
@@ -69,8 +84,10 @@ async function resolveObjects(job) {
69
84
  return input;
70
85
  }
71
86
  async function call(path, body) {
72
- // A network error is a REPLY (status 0, "never reached the server"), not a throw: a worker
73
- // outlives restarts and deploys, and an unhandled rejection would kill it.
87
+ // A network error is a REPLY, not a throw. A worker outlives the server it polls — a
88
+ // restart, a rolling deploy, a container coming up before genroc is listening — and an
89
+ // unhandled rejection here kills it for a condition the next poll would clear. Status 0
90
+ // says "never reached the server", which is distinct from anything genroc answers.
74
91
  let res;
75
92
  try {
76
93
  res = await fetch(SERVER + path, {
@@ -92,8 +109,9 @@ async function call(path, body) {
92
109
  }
93
110
  return { ok: res.ok, status: res.status, data };
94
111
  }
95
- /** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
96
- * the nearest permanent kind, since no retry fixes the definition. */
112
+ /** The task input IS an EvalRequest: `code` required, `input` and `timeout_ms` optional. A task
113
+ * whose input is not that shape is the definition's fault, not the script's, and is reported
114
+ * as a compile_error — the nearest permanent kind, since no retry can fix the definition. */
97
115
  function asEvalRequest(input) {
98
116
  if (typeof input !== "object" || input === null)
99
117
  return "the task input is not an object";
@@ -108,8 +126,10 @@ function asEvalRequest(input) {
108
126
  }
109
127
  const inFlight = new Map();
110
128
  let running = true;
111
- // Announce reachability TRANSITIONS only: polling several times a second would otherwise emit
112
- // thousands of lines during a restart.
129
+ // Whether the last claim reached genroc. A worker polls several times a second, so an
130
+ // unreachable server would otherwise emit a line per poll — thousands during a restart, which
131
+ // buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
132
+ // back. Silence in between is the report that nothing changed.
113
133
  let serverReachable = true;
114
134
  async function claim(n) {
115
135
  const { ok, status, data } = await call("/api/external-tasks/claim", {
@@ -120,15 +140,18 @@ async function claim(n) {
120
140
  ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
121
141
  });
122
142
  if (!ok) {
123
- // A credential problem is not transient, and polling through it looks like a healthy idle
124
- // worker. Exit, so a supervisor restarts it visibly.
143
+ // A credential problem is not transient, and polling through it looks like a healthy
144
+ // worker that never picks anything up — the worst shape for an operator to debug. Exit
145
+ // instead, so a supervisor restarts it and the failure is visible where it happened.
125
146
  if (status === 401 || status === 403) {
126
147
  console.error(`claim rejected (${status}): ${JSON.stringify(data)}\n` +
127
148
  `The server requires authentication. Set GENROC_TOKEN to a token with the 'worker' ` +
128
149
  `permission — mint one with: genctl token create --perms worker --label evaluator -q`);
129
150
  process.exit(1);
130
151
  }
131
- // status 0 (see call): a restart or blip the next poll clears, so it is reported once.
152
+ // status 0 is "never reached the server" (see call): a restart, a rolling deploy, a
153
+ // network blip. Not an error to act on — the next poll clears it — so it is reported once
154
+ // and then waited out.
132
155
  if (status === 0) {
133
156
  if (serverReachable) {
134
157
  serverReachable = false;
@@ -151,8 +174,10 @@ async function release(token) {
151
174
  if (!ok)
152
175
  console.error(`release failed: ${JSON.stringify(data)}`);
153
176
  }
154
- /** A refused outcome is not retried with another: the worker does not satisfy the declared
155
- * contract. Released, so an operator sees the task waiting rather than gone. */
177
+ /** answer submits the outcome. A refusal is NOT retried with a different one: the definition
178
+ * declared a contract this worker does not satisfy (an undeclared code, a payload that does
179
+ * not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
180
+ * the task returns to the queue and an operator sees it waiting rather than silently gone. */
156
181
  async function answer(token, outcome) {
157
182
  const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
158
183
  if (ok)
@@ -184,15 +209,18 @@ async function run(job, signal) {
184
209
  result = await evaluate(req, signal);
185
210
  }
186
211
  catch (err) {
187
- // Cancelled is not a fault. Still released: the row is terminal so nothing re-claims it,
188
- // but the release stops it waiting out a lease nobody serves.
212
+ // Cancelled is not a fault: the process was stopped while this ran. The claim still goes
213
+ // back — the row is terminal, so nothing re-claims it, and the release is what stops it
214
+ // waiting out a lease nobody is serving.
189
215
  if (err instanceof Cancelled) {
190
216
  console.log(`cancelled: ${job.token}`);
191
217
  await release(job.token);
192
218
  return;
193
219
  }
194
- // The RUNNER faulted, the one retryable class. Releasing is how a queue spells "retryable",
195
- // and it reaches another worker without spending the definition's on_error budget.
220
+ // The RUNNER faulted, not the script — the one class where a retry can help. There is no
221
+ // error code for it on purpose: releasing the claim is how a queue spells "retryable", and
222
+ // it puts the task in front of a different worker instead of burning the definition's
223
+ // on_error budget on this one's bad day.
196
224
  console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
197
225
  await release(job.token);
198
226
  return;
@@ -227,13 +255,17 @@ async function renewLoop() {
227
255
  });
228
256
  if (!ok)
229
257
  continue;
230
- // Abort first rather than let a stopped script burn a core; the release rides run()'s
231
- // catch, so nothing is released twice.
258
+ // Cancelled: the process was stopped, so the evaluation is abandoned and the claim handed
259
+ // back. Aborting first is the point of the list — waiting for the script to finish would
260
+ // keep burning a core on work an operator has already stopped. The release rides run()'s
261
+ // own catch, so nothing is released twice.
232
262
  for (const token of (data?.cancelled ?? [])) {
233
263
  inFlight.get(token)?.abort.abort();
234
264
  }
235
- // Lost: another worker holds it now and our answer will be refused; logged as the sign that
236
- // LEASE_MS is too short. NOT released: that would bump the epoch out from under the holder.
265
+ // Lost: someone else holds this claim now. The work continues and its answer will be
266
+ // refused — that is not fixable here — but say so, because it is the signal that LEASE_MS
267
+ // is too short for what these scripts actually take. Deliberately NOT released: the claim
268
+ // is the new holder's, and releasing would bump the epoch out from under it.
237
269
  const lost = (data?.lost ?? []);
238
270
  if (lost.length) {
239
271
  console.error(`lost ${lost.length}/${tokens.length} claims; a lease lapsed under load`);
package/eval.ts CHANGED
@@ -1,6 +1,11 @@
1
- // Imports a script module in its own realm (a Worker per execution, realm.ts), calls its default
2
- // export, and classifies every outcome (README.md). Only a killable thread bounds a synchronous
3
- // busy loop, which is what makes the budget real. Knows nothing of genroc; worker.ts does.
1
+ // Evaluation core: import a script module in its OWN realm, call its default export, and
2
+ // classify every outcome into one of the failure kinds in README.md. Nothing here knows about
3
+ // genroc — worker.ts is the only thing that talks to the queue — so this stays testable and
4
+ // the containment stays swappable.
5
+ //
6
+ // The containment is a Worker per execution (realm.ts). It is what makes the budget real:
7
+ // a synchronous busy loop never yields, so no in-process timer can interrupt it, and only a
8
+ // thread the host can kill bounds it.
4
9
 
5
10
  import { Worker } from "node:worker_threads";
6
11
 
@@ -11,8 +16,9 @@ export type EvalRequest = {
11
16
  timeout_ms?: number;
12
17
  };
13
18
 
14
- /** Every kind is PERMANENT: a retry fails identically. A faulting runner is not an outcome; it
15
- * releases its claim. */
19
+ /** Every kind here is PERMANENT: a retry re-runs the same code on the same input and fails
20
+ * identically. The retryable class has no kind because it is not an outcome — a runner that
21
+ * faults releases its claim and lets another worker take the task. */
16
22
  export type FailureKind = "compile_error" | "threw" | "timeout" | "nonserializable" | "exited";
17
23
 
18
24
  export type EvalFailure = {
@@ -22,8 +28,9 @@ export type EvalFailure = {
22
28
  stack?: string;
23
29
  };
24
30
 
25
- /** JSON TEXT serialised in the realm, so a nonserializable return is a script fault, not a 500,
26
- * and structured clone (which refuses a different set of values) never sees the value. */
31
+ /** `body` is JSON TEXT, not a value: serialising in the realm is what makes a nonserializable
32
+ * return a script fault rather than a 500 thrown out of the response path. It is also what
33
+ * crosses the worker boundary — structured clone would refuse a different set of values. */
27
34
  export type EvalResult =
28
35
  | { ok: true; body: string }
29
36
  | { ok: false; failure: EvalFailure };
@@ -40,8 +47,9 @@ const REALM_URL = new URL(
40
47
  import.meta.url,
41
48
  );
42
49
 
43
- /** Thrown, not returned: a realm that fails to start is the runner faulting, which worker.ts
44
- * answers by releasing the claim. */
50
+ /** Thrown, not returned: a realm that fails to start is the RUNNER faulting, which worker.ts
51
+ * answers by releasing the claim rather than by reporting an outcome. A script fault is a
52
+ * return value. */
45
53
  class RealmFault extends Error {
46
54
  constructor(message: string) {
47
55
  super(message);
@@ -49,8 +57,9 @@ class RealmFault extends Error {
49
57
  }
50
58
  }
51
59
 
52
- /** Thrown when `signal` aborts: the answer is no longer wanted, so worker.ts releases rather
53
- * than answers. Not a RealmFault: nothing is wrong. */
60
+ /** Thrown when `signal` aborts: the work was cancelled server-side, so there is no outcome to
61
+ * report and worker.ts must release rather than answer. Distinct from RealmFault because the
62
+ * runner did not fault -- nothing is wrong, the answer is simply no longer wanted. */
54
63
  export class Cancelled extends Error {
55
64
  constructor() {
56
65
  super("cancelled");
@@ -75,8 +84,9 @@ export async function evaluate(req: EvalRequest, signal?: AbortSignal): Promise<
75
84
  signal.addEventListener("abort", onAbort, { once: true });
76
85
  }
77
86
  worker.once("message", (reply: WorkerReply) => resolve(reply));
78
- // A script may `process.exit()`, which would otherwise look like a hang until the budget.
79
- // Our own terminate() fires this too, after the promise has settled.
87
+ // A script may end its own realm (`process.exit()`), which is not a throw and would
88
+ // otherwise present as a hang until the budget expired. Our own terminate() raises
89
+ // this too, by which time the promise has settled and the first result stands.
80
90
  worker.once("exit", (code: number) => resolve(exited(code)));
81
91
  worker.once("error", (err: Error) => reject(new RealmFault(errorText(err))));
82
92
  worker.postMessage({ code: req.code, input: req.input } satisfies WorkerRequest);
package/import.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, diagnostic on stderr
3
- // with a non-zero exit (specs/source-resolution.md). Two modes share one `tsc` pass: "types"
4
- // writes declarations only, "build" typechecks and bundles.
2
+ // The code-phase resolver: manifest on stdin, `{"code": [...]}` on stdout, non-zero exit
3
+ // with the diagnostic on stderr. genctl never parses TypeScript and this never parses YAML
4
+ // — the manifest is the whole contract. See specs/source-resolution.md.
5
+ //
6
+ // Two modes, one binary: "types" writes the declarations an editor needs and returns no
7
+ // code; "build" typechecks and bundles. A separate types hook would mean a second `tsc`
8
+ // over the same project.
5
9
 
6
10
  import { existsSync } from "node:fs";
7
11
  import { access, writeFile } from "node:fs/promises";
@@ -190,8 +194,9 @@ function typesPathFor(scriptPath: string): string {
190
194
 
191
195
  // ── typecheck ──────────────────────────────────────────────────────────────────
192
196
 
193
- /** The tsconfig the author's editor reads, so editor and apply agree. Stops at the project
194
- * root. */
197
+ /** The nearest tsconfig above the script — the one the author's editor already reads. Two
198
+ * different configs mean a red editor over a clean apply, or the reverse. The walk stops at
199
+ * the project root: above it is not this project. */
195
200
  async function nearestTsconfig(
196
201
  from: string,
197
202
  root: string,
@@ -228,8 +233,9 @@ async function typecheck(sites: Located[]): Promise<void> {
228
233
  // `lib` DESCRIBES the realm and is written after `extends` so a base cannot widen it:
229
234
  // a worker thread has no document, whatever an author's config claims.
230
235
  lib: ["esnext", "webworker"],
231
- // `types` is the author's opt-in to node globals, which the realm has. With no base
232
- // config there is nothing to opt in with, so it stays none.
236
+ // `types` is the author's, and it is how a script opts into the node globals —
237
+ // the worker realm has them, so refusing the declarations would only lie. With no
238
+ // base config there is nothing to opt in with, so the default stays none.
233
239
  ...(base ? {} : { types: [] }),
234
240
  },
235
241
  files: group.flatMap((s) => [s.file, typesPathFor(s.file)]),
@@ -237,8 +243,9 @@ async function typecheck(sites: Located[]): Promise<void> {
237
243
  // drag the author's whole tree in, to be checked under the worker lib.
238
244
  include: [],
239
245
  };
240
- // Never written; anchors `@types` lookup at the root. Not `tsconfig.json`: that is often the
241
- // base it extends, and tsc refuses a circular extends.
246
+ // Never written: the name only anchors `@types` lookup at the root. It must not be
247
+ // `tsconfig.json`: that is the base the config `extends` whenever the author keeps one at
248
+ // the root, and tsc refuses a config that extends its own path as circular.
242
249
  const parsed = ts.parseJsonConfigFileContent(
243
250
  config,
244
251
  ts.sys,
@@ -296,9 +303,11 @@ const BUILTIN = new Set([
296
303
  ...builtinModules.map((m) => `node:${m}`),
297
304
  ]);
298
305
 
299
- /** Resolves through TYPESCRIPT under the typecheck's config, so a `paths` alias that compiles
300
- * also bundles. A `.d.ts` resolution is declined (a type, not code), leaving node_modules to
301
- * nodeResolve. */
306
+ /** Resolves imports through TYPESCRIPT, using the same config the typecheck ran under, so a
307
+ * `paths` alias that compiles also bundles. Reimplementing `paths` here would be a second
308
+ * resolver to keep in agreement with tsc; this one cannot disagree.
309
+ * A package resolving to a `.d.ts` is declined — that is a type, not the implementation —
310
+ * which leaves node_modules to nodeResolve. */
302
311
  function tsResolve(configPath: string | null): Plugin {
303
312
  let options: ts.CompilerOptions = {};
304
313
  if (configPath) {
@@ -328,12 +337,14 @@ function tsResolve(configPath: string | null): Plugin {
328
337
  };
329
338
  }
330
339
 
331
- /** A self-contained ES module whose default export is the author's own, unwrapped. Bundling here
332
- * means a definition version pins its code forever. */
340
+ /** Bundles to a self-contained ES module, which is what the evaluator imports: the default
341
+ * export it calls is the author's own, so nothing wraps or rewrites the code between the two.
342
+ * Bundling is entirely the importer's job, so a definition version pins its code forever. */
333
343
  async function bundle(at: Located): Promise<string> {
334
344
  const site = at.site;
335
- // Builtins are externalised; anything else unresolved is a REFUSAL, since rollup's default
336
- // leaves an import that bundles clean and fails at runtime.
345
+ // Builtins are EXTERNALISED as imports the realm resolves natively. Anything else
346
+ // unresolved is a REFUSAL, not an external: rollup's default is to leave it as an import
347
+ // of a module that will not be there, which bundles clean and fails at runtime.
337
348
  const built = await rollup({
338
349
  input: at.file,
339
350
  external: (id) => BUILTIN.has(id),
@@ -341,7 +352,8 @@ async function bundle(at: Located): Promise<string> {
341
352
  tsResolve(await nearestTsconfig(dirname(at.file), root)),
342
353
  nodeResolve({ extensions: [".ts", ".tsx", ".mjs", ".js", ".json"] }),
343
354
  commonjs(),
344
- // Inlines `.json` imports; without it rollup hands JSON to the JS parser.
355
+ // A `.json` import is a data file inlined at build time, which the previous bundler
356
+ // did natively; without it rollup hands the JSON to the JS parser.
345
357
  json(),
346
358
  transpile,
347
359
  ],
@@ -384,8 +396,10 @@ const root = process.cwd();
384
396
  if (!manifest || !Array.isArray(manifest.processes))
385
397
  die("stdin is not a genroc resolver manifest");
386
398
 
387
- /** Sites in manifest order, the order `code` must answer in. The argument is joined to the
388
- * definition's directory here: only this resolver knows it is a path. */
399
+ /** Every site with the process it sits in and the file its argument names, in the manifest's own
400
+ * order — which is the order `code` must answer in. genctl passes the argument verbatim, so
401
+ * joining it to the definition's directory is this resolver's business: only it knows the
402
+ * argument is a path at all. */
389
403
  type Located = { site: Site; where: ManifestProcess; file: string };
390
404
 
391
405
  /** A pointer as the address it is, so a generated comment or an error can be pasted into
@@ -408,10 +422,15 @@ const located: Located[] = manifest.processes.flatMap((where) =>
408
422
  })),
409
423
  );
410
424
 
411
- // The `code`-field contract is this resolver's, not genctl's, so it checks the directive landed
412
- // there: in a child call (the scaffold's shape) or an external task's input.
425
+ // genctl is agnostic about what a script is for; the contract that an evaluation request carries
426
+ // its module in `code` is THIS resolver's, so it is the one that checks a directive landed there.
427
+ // `child` is the shape the scaffold uses — a call to a process that forwards to the evaluator —
428
+ // and `external` is the same request made directly.
413
429
  for (const at of located) {
414
- // The action's kind is a field; where in it the directive sits is the pointer.
430
+ // Two halves, from the two places that carry them: the action's KIND is a field, and WHERE in
431
+ // it the directive sits is the pointer. A child call is the shape the scaffold generates — to a
432
+ // process that forwards to the evaluator — and an external task is the same request made
433
+ // directly.
415
434
  const kind = at.site.level === "action" ? (at.site.action ?? "") : "";
416
435
  const slot = at.site.pointer.slice(-2).join(".");
417
436
  if ((kind !== "child" && kind !== "external") || slot !== "input.code") {
@@ -423,7 +442,8 @@ for (const at of located) {
423
442
  }
424
443
  }
425
444
 
426
- // genctl does not know the argument is a file, so this resolver reports a missing one.
445
+ // genctl no longer stats the argument — it does not know it is a file — so the resolver that
446
+ // does is the one that must say when it is not there.
427
447
  for (const at of located) {
428
448
  if (!existsSync(at.file)) {
429
449
  die(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genroc/eval-node",
3
- "version": "0.0.0-edge.627a84e1",
3
+ "version": "0.0.0-edge.63f3718c",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
package/realm.ts CHANGED
@@ -1,13 +1,18 @@
1
- // The evaluation realm: one Worker per execution, killable mid-loop by eval.ts, which owns the
2
- // budget. Everything touching the script's VALUE lives here, the only realm it exists in.
1
+ // The evaluation realm. One Worker per execution: a fresh global object per script, and a
2
+ // thread the host can kill mid-loop — the only thing that bounds a synchronous busy loop.
3
+ // eval.ts owns the budget and does the killing; nothing here knows about time.
4
+ //
5
+ // Everything that touches the script's VALUE lives on this side of the boundary — loading,
6
+ // classifying, serialising — because this is the only realm the value exists in.
3
7
 
4
8
  import { registerHooks } from "node:module";
5
9
  import { parentPort } from "node:worker_threads";
6
10
 
7
11
  import type { EvalFailure, FailureKind, WorkerReply, WorkerRequest } from "./eval.ts";
8
12
 
9
- // IMPORTED under our own URL rather than compiled from a string, so frames carry the author's
10
- // line numbers and builtin imports resolve normally.
13
+ // The script is IMPORTED as a module under a URL of our own, not compiled from a string: its
14
+ // frames then carry the author's own line numbers, and an `import` of a node builtin resolves
15
+ // the way it does everywhere else.
11
16
  const SCRIPT_URL = "script:main";
12
17
  const STACK_BYTES = 2_048;
13
18
 
@@ -49,8 +54,9 @@ function safeText(v: unknown): string {
49
54
  }
50
55
  }
51
56
 
52
- /** Unparseable code or an unresolvable import is broken code; anything else the import throws is
53
- * the module's top level running, i.e. the script throwing. */
57
+ /** A module that will not parse, or names an import nothing resolves, is broken code — only
58
+ * editing it helps. Anything else thrown by the import is the module's top level running,
59
+ * which is the script throwing. */
54
60
  function unloadable(err: unknown): boolean {
55
61
  const code = (err as { code?: unknown } | null)?.code;
56
62
  return err instanceof SyntaxError || (typeof code === "string" && code.startsWith("ERR_MODULE"));
@@ -93,8 +99,10 @@ async function run(req: WorkerRequest): Promise<WorkerReply> {
93
99
  }
94
100
  }
95
101
 
96
- // eval.ts terminates this thread the moment the reply lands, while stdio may still be in the
97
- // pipe. An empty write's callback fires once the queue drains, making the reply a barrier.
102
+ // The realm's stdio is a pipe to the host thread, and eval.ts terminates this thread the moment
103
+ // the reply lands — so whatever a script wrote last is still in the pipe when it dies. An empty
104
+ // write's callback fires once the queue ahead of it has drained, which makes the reply a barrier
105
+ // for `console` and a direct process.stdout.write alike: both are this stream.
98
106
  function flush(stream: NodeJS.WriteStream): Promise<void> {
99
107
  return new Promise((resolve) => stream.write("", () => resolve()));
100
108
  }
package/worker.ts CHANGED
@@ -1,21 +1,34 @@
1
1
  #!/usr/bin/env node
2
- // The queue worker: claims parked `external` script tasks, evaluates each in its own realm, and
3
- // answers. The only genroc-facing half (README.md; specs/external-task-queue.md).
2
+ // The queue worker: claims parked `external` script tasks from genroc, evaluates each in its
3
+ // own realm, and answers. This is the whole genroc-facing half — eval.ts and realm.ts know
4
+ // nothing about the queue, which is what keeps the containment strategy swappable.
5
+ //
6
+ // See README.md for the contract, and specs/external-task-queue.md for the queue itself.
4
7
 
5
8
  import { readFileSync } from "node:fs";
6
9
  import { Cancelled, evaluate, type EvalRequest, type FailureKind } from "./eval.ts";
7
10
 
8
11
  const SERVER = (process.env.GENROC_SERVER ?? "http://localhost:8448").replace(/\/$/, "");
9
12
  const WORKER_ID = process.env.WORKER_ID ?? `evaluator-${process.pid}`;
10
- // Needs only the `worker` permission (specs/api-auth.md §5): this credential sits on the machine
11
- // you trust least. A header, because Node's fetch refuses credentials in a URL. The file form
12
- // keeps it out of `docker inspect` and child environments; the inline variable wins.
13
+ // The credential, when the server runs with --auth token. A worker needs exactly the `worker`
14
+ // permission — the four queue verbs plus GET /api/objects — so mint it scoped rather than
15
+ // handing a worker an admin token: this is the credential most likely to sit on a machine you
16
+ // trust least. specs/api-auth.md §5.
17
+ //
18
+ // Sent as a header rather than in the URL because Node's fetch REFUSES a URL carrying
19
+ // credentials ("Request cannot be constructed from a URL that includes credentials"), so the
20
+ // basic-auth-in-the-URL trick that works for genctl is not available here.
21
+ // GENROC_TOKEN_FILE is the mounted-secret shape: a credential in a file rather than an
22
+ // environment variable, so it stays out of `docker inspect` and out of the process environment
23
+ // any child inherits. The inline variable wins when both are set.
13
24
  const TOKEN =
14
25
  process.env.GENROC_TOKEN ??
15
26
  (process.env.GENROC_TOKEN_FILE ? readFileSync(process.env.GENROC_TOKEN_FILE, "utf8").trim() : "");
16
27
  const authHeaders: Record<string, string> = TOKEN ? { authorization: `Bearer ${TOKEN}` } : {};
17
- // The worker claims only what it can run, so a backlog is a queue rather than threads fighting
18
- // over a core.
28
+ // Concurrency is the worker's to set, and that is the point of pulling: under the old fetch
29
+ // shape genroc decided how many scripts ran at once (--max-concurrent, default 200) and the
30
+ // evaluator accepted every one of them. Here it claims what it can run and no more, so a
31
+ // backlog is a queue rather than 200 threads fighting over a core.
19
32
  const CONCURRENCY = Number(process.env.CONCURRENCY ?? 4);
20
33
  const POLL_MS = Number(process.env.POLL_MS ?? 250);
21
34
  // The visibility timeout. Short, and renewed while work is in flight: a worker that dies
@@ -36,15 +49,17 @@ type QueueTask = {
36
49
  raises?: Record<string, unknown>;
37
50
  };
38
51
 
39
- // Large values arrive as refs; a bundle is one object per definition version, fetched once.
40
- // Refs are content hashes, so the cache never invalidates.
52
+ // Values too large to ship inline are listed rather than carried, and a bundle is exactly that:
53
+ // one object shared by every instance of a definition version, fetched once instead of copied
54
+ // into each task. A ref is a content hash, so it is immutable — the cache never invalidates.
41
55
  const objectCache = new Map<string, unknown>();
42
56
 
43
57
  async function fetchObject(ref: string): Promise<unknown> {
44
58
  const cached = objectCache.get(ref);
45
59
  if (cached !== undefined) return cached;
46
- // Left to throw: a task whose input cannot be fetched must not run against a missing value.
47
- // The caller releases the claim.
60
+ // Left to throw on purpose: this runs while a task is IN FLIGHT, and a task whose input
61
+ // cannot be fetched must fail rather than silently run against a missing value. The caller
62
+ // releases the claim, so the task returns to the queue.
48
63
  const res = await fetch(`${SERVER}/api/objects/${encodeURIComponent(ref)}`, { headers: authHeaders });
49
64
  if (!res.ok) throw new Error(`fetch object ${ref}: HTTP ${res.status}`);
50
65
  const { data } = (await res.json()) as { data: string };
@@ -82,8 +97,10 @@ async function resolveObjects(job: QueueTask): Promise<unknown> {
82
97
  }
83
98
 
84
99
  async function call(path: string, body: unknown): Promise<{ ok: boolean; status: number; data: any }> {
85
- // A network error is a REPLY (status 0, "never reached the server"), not a throw: a worker
86
- // outlives restarts and deploys, and an unhandled rejection would kill it.
100
+ // A network error is a REPLY, not a throw. A worker outlives the server it polls — a
101
+ // restart, a rolling deploy, a container coming up before genroc is listening — and an
102
+ // unhandled rejection here kills it for a condition the next poll would clear. Status 0
103
+ // says "never reached the server", which is distinct from anything genroc answers.
87
104
  let res: Response;
88
105
  try {
89
106
  res = await fetch(SERVER + path, {
@@ -104,8 +121,9 @@ async function call(path: string, body: unknown): Promise<{ ok: boolean; status:
104
121
  return { ok: res.ok, status: res.status, data };
105
122
  }
106
123
 
107
- /** A task input that is not an EvalRequest is the definition's fault, reported as compile_error:
108
- * the nearest permanent kind, since no retry fixes the definition. */
124
+ /** The task input IS an EvalRequest: `code` required, `input` and `timeout_ms` optional. A task
125
+ * whose input is not that shape is the definition's fault, not the script's, and is reported
126
+ * as a compile_error — the nearest permanent kind, since no retry can fix the definition. */
109
127
  function asEvalRequest(input: unknown): EvalRequest | string {
110
128
  if (typeof input !== "object" || input === null) return "the task input is not an object";
111
129
  const r = input as Record<string, unknown>;
@@ -117,15 +135,18 @@ function asEvalRequest(input: unknown): EvalRequest | string {
117
135
  };
118
136
  }
119
137
 
120
- /** genroc cannot call us, so a cancellation arrives on our own heartbeat and reaches the
121
- * evaluation through the controller. */
138
+ /** A claim this worker is currently serving. The controller is how the renewal loop reaches
139
+ * the evaluation: genroc cannot call us, so a cancellation arrives as an answer to our own
140
+ * heartbeat and has to be delivered inward from there. */
122
141
  type Running = { job: QueueTask; abort: AbortController };
123
142
 
124
143
  const inFlight = new Map<string, Running>();
125
144
  let running = true;
126
145
 
127
- // Announce reachability TRANSITIONS only: polling several times a second would otherwise emit
128
- // thousands of lines during a restart.
146
+ // Whether the last claim reached genroc. A worker polls several times a second, so an
147
+ // unreachable server would otherwise emit a line per poll — thousands during a restart, which
148
+ // buries the one line that mattered. Announce the TRANSITIONS instead: going away, and coming
149
+ // back. Silence in between is the report that nothing changed.
129
150
  let serverReachable = true;
130
151
 
131
152
  async function claim(n: number): Promise<QueueTask[]> {
@@ -137,8 +158,9 @@ async function claim(n: number): Promise<QueueTask[]> {
137
158
  ...(TASK_FILTER ? { task: TASK_FILTER } : {}),
138
159
  });
139
160
  if (!ok) {
140
- // A credential problem is not transient, and polling through it looks like a healthy idle
141
- // worker. Exit, so a supervisor restarts it visibly.
161
+ // A credential problem is not transient, and polling through it looks like a healthy
162
+ // worker that never picks anything up — the worst shape for an operator to debug. Exit
163
+ // instead, so a supervisor restarts it and the failure is visible where it happened.
142
164
  if (status === 401 || status === 403) {
143
165
  console.error(
144
166
  `claim rejected (${status}): ${JSON.stringify(data)}\n` +
@@ -147,7 +169,9 @@ async function claim(n: number): Promise<QueueTask[]> {
147
169
  );
148
170
  process.exit(1);
149
171
  }
150
- // status 0 (see call): a restart or blip the next poll clears, so it is reported once.
172
+ // status 0 is "never reached the server" (see call): a restart, a rolling deploy, a
173
+ // network blip. Not an error to act on — the next poll clears it — so it is reported once
174
+ // and then waited out.
151
175
  if (status === 0) {
152
176
  if (serverReachable) {
153
177
  serverReachable = false;
@@ -173,8 +197,10 @@ async function release(token: string): Promise<void> {
173
197
  if (!ok) console.error(`release failed: ${JSON.stringify(data)}`);
174
198
  }
175
199
 
176
- /** A refused outcome is not retried with another: the worker does not satisfy the declared
177
- * contract. Released, so an operator sees the task waiting rather than gone. */
200
+ /** answer submits the outcome. A refusal is NOT retried with a different one: the definition
201
+ * declared a contract this worker does not satisfy (an undeclared code, a payload that does
202
+ * not fit `raises`), and guessing again would only pick a second wrong answer. Release it, so
203
+ * the task returns to the queue and an operator sees it waiting rather than silently gone. */
178
204
  async function answer(token: string, outcome: Record<string, unknown>): Promise<void> {
179
205
  const { ok, data } = await call("/api/external-tasks/resolve", { token, ...outcome });
180
206
  if (ok) return;
@@ -205,15 +231,18 @@ async function run(job: QueueTask, signal: AbortSignal): Promise<void> {
205
231
  try {
206
232
  result = await evaluate(req, signal);
207
233
  } catch (err) {
208
- // Cancelled is not a fault. Still released: the row is terminal so nothing re-claims it,
209
- // but the release stops it waiting out a lease nobody serves.
234
+ // Cancelled is not a fault: the process was stopped while this ran. The claim still goes
235
+ // back — the row is terminal, so nothing re-claims it, and the release is what stops it
236
+ // waiting out a lease nobody is serving.
210
237
  if (err instanceof Cancelled) {
211
238
  console.log(`cancelled: ${job.token}`);
212
239
  await release(job.token);
213
240
  return;
214
241
  }
215
- // The RUNNER faulted, the one retryable class. Releasing is how a queue spells "retryable",
216
- // and it reaches another worker without spending the definition's on_error budget.
242
+ // The RUNNER faulted, not the script — the one class where a retry can help. There is no
243
+ // error code for it on purpose: releasing the claim is how a queue spells "retryable", and
244
+ // it puts the task in front of a different worker instead of burning the definition's
245
+ // on_error budget on this one's bad day.
217
246
  console.error(`evaluator fault on ${job.token}: ${err instanceof Error ? err.message : String(err)}`);
218
247
  await release(job.token);
219
248
  return;
@@ -249,13 +278,17 @@ async function renewLoop(): Promise<void> {
249
278
  });
250
279
  if (!ok) continue;
251
280
 
252
- // Abort first rather than let a stopped script burn a core; the release rides run()'s
253
- // catch, so nothing is released twice.
281
+ // Cancelled: the process was stopped, so the evaluation is abandoned and the claim handed
282
+ // back. Aborting first is the point of the list — waiting for the script to finish would
283
+ // keep burning a core on work an operator has already stopped. The release rides run()'s
284
+ // own catch, so nothing is released twice.
254
285
  for (const token of (data?.cancelled ?? []) as string[]) {
255
286
  inFlight.get(token)?.abort.abort();
256
287
  }
257
- // Lost: another worker holds it now and our answer will be refused; logged as the sign that
258
- // LEASE_MS is too short. NOT released: that would bump the epoch out from under the holder.
288
+ // Lost: someone else holds this claim now. The work continues and its answer will be
289
+ // refused — that is not fixable here — but say so, because it is the signal that LEASE_MS
290
+ // is too short for what these scripts actually take. Deliberately NOT released: the claim
291
+ // is the new holder's, and releasing would bump the epoch out from under it.
259
292
  const lost = (data?.lost ?? []) as string[];
260
293
  if (lost.length) {
261
294
  console.error(`lost ${lost.length}/${tokens.length} claims; a lease lapsed under load`);