beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,713 @@
1
+ // worker-plane.mjs — one evaluating engine behind a single-flight queue.
2
+ //
3
+ // The routing half of spike/serve.R, engine-neutral: everything here talks to
4
+ // an ENGINE ADAPTER (host/engine-r.mjs is the first) and to socket RECORDS
5
+ // (`rec.ws.send`), and evaluates nothing itself. Browser request ids are only
6
+ // unique inside one tab, so every command gets a supervisor-owned wire id and
7
+ // this table maps it back to the socket and id that originated it — isolation
8
+ // is a server guarantee, not a client courtesy.
9
+ //
10
+ // Rules carried over from serve.R, each with the failure that put it there:
11
+ // · The engine is serial, so the plane owns one queue and one active slot.
12
+ // · Frames are RELAYED as the engine's own bytes, only the routing id
13
+ // rewritten (relay_frame): re-encoding rounded 1.2345e-05 to 0.
14
+ // · A terminal frame is HELD until the pipes are quiet, or the page settles
15
+ // the result before a trailing stderr line arrives.
16
+ // · A deadline is a property of the COMMAND CLASS: an unanswered `env` after
17
+ // 120 s means a worker that will never answer; an unanswered `exec` after
18
+ // two hours means a model fit. Expiry fails that route and frees the slot;
19
+ // it never touches the engine.
20
+ // · Stop is a LADDER: the first re-send 60 ms after the group signal (libc
21
+ // drops the SIGINT R got while inside system()), then every 400 ms, five
22
+ // times, while the same run is still active. Every rung's signals leave
23
+ // from THIS process, guarded at the instant they are sent: a rung's
24
+ // spawned `kill` used to land after the run had settled and the next
25
+ // command was written, and that stray signal tore the command line R was
26
+ // reading (a session alive and silent — the 8-hour soak of 2026-09-16)
27
+ // or escaped the worker's handlers and ended R.
28
+ // · A command written to an idle worker is ACKNOWLEDGED (`ack`, a worker
29
+ // that declares `command_ack_v1`). One that is not acknowledged within
30
+ // COMMAND_ACK_MS never reached the worker's loop — the torn line above —
31
+ // and is failed with a sentence so the queue moves on; an exec has no
32
+ // other deadline, by design.
33
+ // · A dead engine is RESPAWNED on a bounded backoff (0.25 … 10 s, ten
34
+ // attempts); past that it is a terminal state the page is told about, and
35
+ // an explicit `restart` is the one door out of it.
36
+
37
+ import { EventEmitter } from "node:events";
38
+
39
+ export const WORKER_DEADLINE_FAST = 120;
40
+ export const WORKER_DEADLINE_SLOW = 600;
41
+ export const WORKER_DEADLINES = Object.freeze({
42
+ env: WORKER_DEADLINE_FAST, obj: WORKER_DEADLINE_FAST, struct: WORKER_DEADLINE_FAST,
43
+ packages: WORKER_DEADLINE_FAST, wd: WORKER_DEADLINE_FAST, parse: WORKER_DEADLINE_FAST,
44
+ complete: WORKER_DEADLINE_FAST, hover: WORKER_DEADLINE_FAST, help: WORKER_DEADLINE_FAST,
45
+ package_help: WORKER_DEADLINE_FAST, project_status: WORKER_DEADLINE_FAST, rm: WORKER_DEADLINE_FAST,
46
+ debug_breaks: WORKER_DEADLINE_FAST, files: WORKER_DEADLINE_FAST, mkdir: WORKER_DEADLINE_FAST,
47
+ renamepath: WORKER_DEADLINE_FAST, sniff: WORKER_DEADLINE_FAST,
48
+ view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW, import: WORKER_DEADLINE_SLOW,
49
+ format: WORKER_DEADLINE_SLOW, doctor: WORKER_DEADLINE_SLOW, readfile: WORKER_DEADLINE_SLOW,
50
+ writefile: WORKER_DEADLINE_SLOW, writefiles_atomic: WORKER_DEADLINE_SLOW,
51
+ copypath: WORKER_DEADLINE_SLOW, deletepath: WORKER_DEADLINE_SLOW, revealpath: WORKER_DEADLINE_SLOW,
52
+ });
53
+ export const INTERRUPT_LADDER_FIRST_MS = 60;
54
+ export const INTERRUPT_LADDER_STEP_MS = 400;
55
+ export const INTERRUPT_LADDER_MAX = 5;
56
+ /** How long a command written to an idle worker may go unacknowledged. An idle
57
+ * worker acks in milliseconds (a 300 KB command spilled to a file, ~30 ms);
58
+ * the only thing this ever catches is a command the worker never received. */
59
+ export const COMMAND_ACK_MS = 3000;
60
+ export const ORPHAN_BUFFER_BYTES = 262144;
61
+ const RESPAWN_DELAYS_S = [0.25, 0.5, 1, 2, 5, 10];
62
+ /** How long after the last engine event a terminal frame is released. serve.R
63
+ * waits one empty 50 ms poll; this is the same quiet window, event-driven. */
64
+ const TERMINAL_QUIET_MS = 30;
65
+ /** The step commands a paused run takes — lib/debugger.js's and lib/console.js's words. */
66
+ export const DEBUG_ACTIONS = Object.freeze(["continue", "over", "into", "out", "where", "abort", "eval"]);
67
+
68
+ const scalarChr = (x) => typeof x === "string" && x.length > 0;
69
+ const enc = (obj) => JSON.stringify(obj);
70
+
71
+ /**
72
+ * Serialise one engine frame for the browser WITHOUT re-encoding it.
73
+ * @param {object} e a frame; `__raw` carries the engine's own bytes when it came from the engine
74
+ * @param {string|null} wireId
75
+ * @param {string|null} clientId
76
+ * @returns {string}
77
+ */
78
+ export function relayFrame(e, wireId = null, clientId = null) {
79
+ const raw = e && e.__raw;
80
+ if (typeof raw === "string") {
81
+ if (wireId == null) return raw;
82
+ if (scalarChr(clientId)) {
83
+ const needle = `"id":${JSON.stringify(wireId)}`;
84
+ const at = raw.indexOf(needle);
85
+ if (at >= 0) return raw.slice(0, at) + `"id":${JSON.stringify(clientId)}` + raw.slice(at + needle.length);
86
+ }
87
+ }
88
+ const { __raw, ...plain } = e;
89
+ return enc(plain);
90
+ }
91
+
92
+ export class WorkerPlane extends EventEmitter {
93
+ /**
94
+ * @param {Object} opts
95
+ * @param {() => import("./engine-r.mjs").REngine} opts.engineFactory
96
+ * @param {(event: string, fields?: object) => void} [opts.audit]
97
+ * @param {number} [opts.deadlineScale] tests need seconds, not minutes
98
+ * @param {number} [opts.respawnMax]
99
+ * @param {(hello: object) => object} [opts.decorateReady] the host stamps protocol/build here
100
+ */
101
+ constructor({ engineFactory, name = "r", label = "R", audit = () => {}, deadlineScale = 1, respawnMax = 10, decorateReady = (x) => x }) {
102
+ super();
103
+ this.engineFactory = engineFactory;
104
+ // The engine this plane owns, and the word its sentences use. One plane
105
+ // per engine (docs/worker-protocol.md §6.2), so both are constants here.
106
+ this.name = name;
107
+ this.label = label;
108
+ this.audit = audit;
109
+ this.deadlineScale = deadlineScale;
110
+ this.respawnMax = respawnMax;
111
+ this.decorateReady = decorateReady;
112
+ this.engine = null;
113
+ this.routes = new Map(); // wireId → route
114
+ this.routeSeq = 0;
115
+ this.queue = [];
116
+ this.active = null; // wireId
117
+ this.activeSince = null;
118
+ this.activeType = null;
119
+ this.terminal = null; // {wireId, route, frame, timer}
120
+ this.running = new Set(); // exec wire ids in flight (the busy guard's evidence)
121
+ this.hello = null; // the decorated ready payload, replayed to late pages
122
+ this.notice = null; // a kept give-up notice
123
+ this.readyFrame = null;
124
+ this.workerWd = process.cwd();
125
+ this.debugPaused = false;
126
+ this.inputWaiting = false;
127
+ this.recovering = false;
128
+ this.gaveUp = false;
129
+ this.restartAttempts = 0;
130
+ this.restartAfter = 0;
131
+ this.forceStopAsked = false;
132
+ this.expectedExit = false; // a stop/restart we asked for is not a crash
133
+ this.ladder = null; // {wireId, at, count, step}
134
+ this.expectsAck = false; // the ready frame declared command_ack_v1
135
+ this.acked = false; // the active command reached the worker's loop
136
+ this.ackDue = null; // when an unacknowledged active command is given up on
137
+ this.sockets = () => []; // the host supplies the live roster
138
+ this.sweeper = setInterval(() => this.tick(), 100);
139
+ this.sweeper.unref();
140
+ }
141
+
142
+ get alive() { return Boolean(this.engine && this.engine.alive); }
143
+ get pid() { return this.engine ? this.engine.pid : null; }
144
+ get mode() { return this.engine ? this.engine.mode : "batch"; }
145
+ /** Can this engine answer a question while a cell runs? (readline, input()) */
146
+ get canInput() { return Boolean(this.engine && this.engine.canInput); }
147
+ /** Does it have a step debugger `debug_cmd` can drive? R yes, Python no. */
148
+ get canDebug() { return Boolean(this.engine && this.engine.canDebug); }
149
+ get language() { return this.engine ? this.engine.language : null; }
150
+
151
+ async start() {
152
+ this.expectedExit = false;
153
+ const engine = this.engineFactory();
154
+ this.engine = engine;
155
+ engine.on("event", (e) => { if (this.engine === engine) this.onEvent(e); });
156
+ engine.on("exit", (info) => { if (this.engine === engine) this.onExit(info); });
157
+ engine.on("spawn-error", (err) => {
158
+ this.audit("worker-spawn-error", { detail: err.message });
159
+ });
160
+ await engine.start();
161
+ return engine;
162
+ }
163
+
164
+ // ── routes ──────────────────────────────────────────────────────────────
165
+
166
+ route(cmd, rec, kind) {
167
+ this.routeSeq += 1;
168
+ const wireId = `wire-${this.routeSeq}`;
169
+ const route = { rec, clientId: cmd.id, kind, responseType: cmd.type, onReply: null,
170
+ buffer: [], bufferedBytes: 0, bufferTruncated: false, parked: null, parkedAt: null,
171
+ srcname: "", started: Date.now() };
172
+ this.routes.set(wireId, route);
173
+ return { cmd: { ...cmd, id: wireId }, wireId, route };
174
+ }
175
+
176
+ routeIsOrphan(route) {
177
+ if (!route) return true;
178
+ if (typeof route.onReply === "function") return false;
179
+ if (!route.rec) return true;
180
+ return !this.sockets().includes(route.rec);
181
+ }
182
+
183
+ /**
184
+ * Forget results nobody came back for (serve.R `sweep_parked_results`).
185
+ * Only under an explicit idle timeout: a retained session keeps a parked
186
+ * result for as long as it lives, but with a configured grace an abandoned
187
+ * route must not block the very policy the user chose — it held `running`
188
+ * open and the kernel never stopped.
189
+ */
190
+ sweepParked(lingerS) {
191
+ if (!(lingerS > 0)) return;
192
+ const now = Date.now();
193
+ for (const [wireId, route] of [...this.routes]) {
194
+ if (!route || route.parked == null) continue;
195
+ if (now - (route.parkedAt ?? now) >= lingerS * 1000) {
196
+ this.audit("result-forgotten", { srcname: route.srcname || "" });
197
+ this.dropRoute(wireId);
198
+ }
199
+ }
200
+ }
201
+
202
+ dropRoute(wireId) {
203
+ this.routes.delete(wireId);
204
+ this.running.delete(wireId);
205
+ }
206
+
207
+ deliver(route, frame) {
208
+ if (typeof route.onReply === "function") { try { route.onReply(frame); } catch { /* a continuation must not break the plane */ } }
209
+ else if (route.rec && route.rec.ws) route.rec.ws.send(enc(frame));
210
+ }
211
+
212
+ failRoutes(reason) {
213
+ for (const [wireId, route] of this.routes) {
214
+ const frame = route.kind === "exec"
215
+ ? { type: "done", id: route.clientId, status: "error", message: reason }
216
+ : { type: route.responseType, id: route.clientId, error: reason };
217
+ this.deliver(route, frame);
218
+ this.dropRoute(wireId);
219
+ }
220
+ }
221
+
222
+ // ── the queue ───────────────────────────────────────────────────────────
223
+
224
+ enqueue(cmd, wireId) {
225
+ this.queue.push({ cmd, wireId });
226
+ this.dispatch();
227
+ }
228
+
229
+ dispatch() {
230
+ if (this.active !== null || !this.queue.length) return;
231
+ const item = this.queue.shift();
232
+ this.active = item.wireId;
233
+ this.activeSince = Date.now(); // the deadline clock starts at the WRITE
234
+ this.activeType = scalarChr(item.cmd.type) ? item.cmd.type : "";
235
+ let failed = null;
236
+ try { this.engine.send(item.cmd); } catch (e) { failed = e.message; }
237
+ this.acked = false;
238
+ this.ackDue = !failed && this.expectsAck ? Date.now() + COMMAND_ACK_MS : null;
239
+ if (failed) {
240
+ const route = this.routes.get(item.wireId);
241
+ if (route) {
242
+ this.deliver(route, route.kind === "exec"
243
+ ? { type: "done", id: route.clientId, status: "error", message: failed }
244
+ : { type: item.cmd.type, id: route.clientId, error: failed });
245
+ }
246
+ this.dropRoute(item.wireId);
247
+ this.releaseSlot();
248
+ this.dispatch();
249
+ }
250
+ }
251
+
252
+ releaseSlot() {
253
+ if (this.active && typeof this.engine?.clearStop === "function") this.engine.clearStop(this.active);
254
+ this.active = null;
255
+ this.activeSince = null;
256
+ this.activeType = null;
257
+ this.acked = false;
258
+ this.ackDue = null;
259
+ }
260
+
261
+ // ── what a page may ask ─────────────────────────────────────────────────
262
+
263
+ exec(cmd, rec) {
264
+ if (!scalarChr(cmd.id) || typeof cmd.source !== "string") return;
265
+ this.audit("exec", { id: cmd.id, bytes: Buffer.byteLength(cmd.source) });
266
+ const routed = this.route(cmd, rec, "exec");
267
+ routed.route.srcname = scalarChr(cmd.srcname) ? cmd.srcname : "";
268
+ this.running.add(routed.wireId);
269
+ this.enqueue(routed.cmd, routed.wireId);
270
+ }
271
+
272
+ request(cmd, rec) {
273
+ if (!scalarChr(cmd.id)) return;
274
+ this.audit(cmd.type, { id: cmd.id });
275
+ const routed = this.route(cmd, rec, "request");
276
+ this.enqueue(routed.cmd, routed.wireId);
277
+ }
278
+
279
+ /** A command the HOST asks on its own behalf; the reply goes to `onReply`. */
280
+ internal(cmd, onReply) {
281
+ this.routeSeq += 1;
282
+ const wireId = `wire-${this.routeSeq}`;
283
+ this.routes.set(wireId, { rec: null, clientId: wireId, kind: "request", responseType: cmd.type,
284
+ onReply, buffer: [], bufferedBytes: 0, srcname: "", started: Date.now() });
285
+ this.enqueue({ ...cmd, id: wireId }, wireId);
286
+ }
287
+
288
+ interrupt(cmd, rec) {
289
+ this.audit("interrupt", { id: cmd.id || "" });
290
+ if (scalarChr(cmd.id)) {
291
+ const hit = [...this.routes].find(([, r]) => r.rec === rec && r.clientId === cmd.id && r.kind === "exec");
292
+ if (!hit) return;
293
+ const [wireId] = hit;
294
+ if (wireId === this.active) {
295
+ this.startLadder(wireId);
296
+ return;
297
+ }
298
+ // Queued: released where it stands, without signalling the engine.
299
+ this.queue = this.queue.filter((item) => item.wireId !== wireId);
300
+ rec.ws.send(enc({ type: "done", id: cmd.id, status: "interrupted", message: "Execution interrupted" }));
301
+ this.dropRoute(wireId);
302
+ return;
303
+ }
304
+ // An older bundle's id-less Stop: the active run, with its flag written first —
305
+ // the worker treats a signal that carries no flag as one meant for a run
306
+ // that has ended, and resumes.
307
+ if (this.active && typeof this.engine.requestStop === "function") this.engine.requestStop(this.active);
308
+ this.engine.interrupt();
309
+ }
310
+
311
+ forceStop(rec) {
312
+ this.audit("force-stop", { class: rec.class || "unknown" });
313
+ this.forceStopAsked = true;
314
+ if (this.alive) this.engine.kill();
315
+ }
316
+
317
+ async restart() {
318
+ this.audit("restart", {});
319
+ this.failRoutes(`${this.label} was restarted — this request was abandoned.`);
320
+ this.expectedExit = true;
321
+ if (this.engine) await this.engine.stop(1000);
322
+ this.resetSession();
323
+ this.hello = null;
324
+ this.recovering = false;
325
+ this.restartAttempts = 0;
326
+ this.gaveUp = false;
327
+ this.notice = null;
328
+ await this.start();
329
+ }
330
+
331
+ resetSession() {
332
+ this.running.clear();
333
+ this.routes.clear();
334
+ this.queue = [];
335
+ this.releaseSlot();
336
+ if (this.terminal) clearTimeout(this.terminal.timer);
337
+ this.terminal = null;
338
+ this.debugPaused = false;
339
+ this.inputWaiting = false;
340
+ this.clearLadder();
341
+ }
342
+
343
+ runstate(cmd, rec) {
344
+ if (!scalarChr(cmd.id)) return;
345
+ const runId = scalarChr(cmd.run) ? cmd.run : "";
346
+ let alive = Boolean(runId) && [...this.routes.values()].some((r) => r.kind === "exec" && r.clientId === runId && r.rec === rec);
347
+ if (!alive && this.terminal) {
348
+ const held = this.terminal.route;
349
+ alive = Boolean(held) && held.clientId === runId && held.rec === rec;
350
+ }
351
+ rec.ws.send(enc({ type: "runstate", id: cmd.id, run: runId, running: alive }));
352
+ }
353
+
354
+ runs(cmd, rec) {
355
+ if (!scalarChr(cmd.id)) return;
356
+ const rows = [...this.routes.values()].filter((r) => r.kind === "exec").map((r) => ({
357
+ run: r.clientId || "", srcname: r.srcname || "", mine: r.rec === rec,
358
+ orphan: this.routeIsOrphan(r), finished: r.parked != null,
359
+ seconds: Math.round((Date.now() - r.started) / 100) / 10,
360
+ }));
361
+ rec.ws.send(enc({ type: "runs", id: cmd.id, runs: rows }));
362
+ }
363
+
364
+ /** Does this socket have a run by this page-side id here? The pool's router
365
+ * asks every plane this before it forwards a control op — a page never has
366
+ * to name an engine on Stop, input or a debugger step
367
+ * (docs/worker-protocol.md §6.3). */
368
+ ownsRun(rec, clientId) {
369
+ if (!scalarChr(clientId)) return false;
370
+ for (const route of this.routes.values()) {
371
+ if (route.kind === "exec" && route.rec === rec && route.clientId === clientId) return true;
372
+ }
373
+ if (this.terminal && this.terminal.route && this.terminal.route.kind === "exec"
374
+ && this.terminal.route.rec === rec && this.terminal.route.clientId === clientId) return true;
375
+ return false;
376
+ }
377
+
378
+ /** Is there an orphaned run here with this srcname? Asked before `adopt`,
379
+ * because `adopt` answers a miss with a refusal frame and only ONE plane
380
+ * may do that. */
381
+ hasAdoptable(srcname) {
382
+ if (!scalarChr(srcname)) return false;
383
+ return [...this.routes.values()].some((r) => r.kind === "exec"
384
+ && (r.srcname || "") === srcname && this.routeIsOrphan(r));
385
+ }
386
+
387
+ /** The exec rows this plane is carrying, for a merged `runs` answer. */
388
+ runRows(rec) {
389
+ return [...this.routes.values()].filter((r) => r.kind === "exec").map((r) => ({
390
+ run: r.clientId || "", srcname: r.srcname || "", engine: this.name, mine: r.rec === rec,
391
+ orphan: this.routeIsOrphan(r), finished: r.parked != null,
392
+ seconds: Math.round((Date.now() - r.started) / 100) / 10,
393
+ }));
394
+ }
395
+
396
+ adopt(cmd, rec) {
397
+ if (!scalarChr(cmd.id) || !scalarChr(cmd.srcname)) return;
398
+ const refuse = (msg) => rec.ws.send(enc({ type: "done", id: cmd.id, status: "lost", message: msg }));
399
+ const hit = [...this.routes].find(([, r]) => r.kind === "exec" && (r.srcname || "") === cmd.srcname && this.routeIsOrphan(r));
400
+ if (!hit) return refuse("that run is no longer in this session.");
401
+ const [wireId, route] = hit;
402
+ route.rec = rec;
403
+ route.clientId = cmd.id;
404
+ this.audit("adopt", { srcname: cmd.srcname });
405
+ const buffered = route.buffer;
406
+ route.buffer = []; route.bufferedBytes = 0;
407
+ for (const frame of buffered) rec.ws.send(relayFrame({ ...frame, id: cmd.id }, wireId, cmd.id));
408
+ if (route.bufferTruncated) {
409
+ route.bufferTruncated = false;
410
+ rec.ws.send(enc({ type: "message", id: cmd.id, kind: "warning",
411
+ text: `Output printed while this notebook was closed exceeded ${Math.floor(ORPHAN_BUFFER_BYTES / 1024)} KB; the rest was dropped.` }));
412
+ }
413
+ if (route.parked) {
414
+ const parked = route.parked;
415
+ route.parked = null;
416
+ rec.ws.send(relayFrame({ ...parked, id: cmd.id }, wireId, cmd.id));
417
+ this.dropRoute(wireId);
418
+ }
419
+ return undefined;
420
+ }
421
+
422
+ /** A raw console line answering a readline(). Same gate as debug_cmd. */
423
+ inputReply(cmd, rec) {
424
+ const err = (msg) => { if (scalarChr(cmd.id)) rec.ws.send(enc({ type: "input_reply", id: cmd.id, error: msg })); };
425
+ if (!this.canInput) return err("this worker cannot read input");
426
+ if (!this.inputWaiting) return err(`${this.label} is not waiting for input`);
427
+ const route = this.active ? this.routes.get(this.active) : null;
428
+ if (!route || route.rec !== rec) return err("another page owns the waiting run");
429
+ const value = scalarChr(cmd.value) ? cmd.value : "";
430
+ if (/[\n\r]/.test(value) || value.length > 4000) return err("an answer must be a single line under 4000 characters");
431
+ this.audit("input_reply", { bytes: Buffer.byteLength(value) });
432
+ let sent = false;
433
+ try { this.engine.console(value); sent = true; } catch { sent = false; }
434
+ if (!sent) return err(`Beatrina could not deliver the answer to ${this.label}. The question is still open; try again.`);
435
+ this.inputWaiting = false;
436
+ return undefined;
437
+ }
438
+
439
+ debugCmd(cmd, rec) {
440
+ const err = (msg) => { if (scalarChr(cmd.id)) rec.ws.send(enc({ type: "debug_cmd", id: cmd.id, error: msg })); };
441
+ if (!this.canDebug) return err(`the ${this.label} engine has no debugger`);
442
+ if (!this.debugPaused) return err(`${this.label} is not paused in the debugger`);
443
+ const route = this.active ? this.routes.get(this.active) : null;
444
+ if (!route || route.rec !== rec) return err("another page owns the paused run");
445
+ const action = scalarChr(cmd.action) ? cmd.action : "";
446
+ // An engine whose debugger is not a console prompt takes the COMMAND, not
447
+ // a line spelled in R (engines/python/debugger.py). The gate above is the
448
+ // same for every engine; R has no such method and takes the lines below,
449
+ // unchanged.
450
+ if (typeof this.engine.debugCommand === "function") {
451
+ const expr = action === "eval" && scalarChr(cmd.expr) ? cmd.expr : null;
452
+ if (!DEBUG_ACTIONS.includes(action) || (action === "eval" && (expr == null || /[\n\r]/.test(expr) || expr.length > 4000))) {
453
+ return err("bad debug command");
454
+ }
455
+ this.audit("debug_cmd", { action });
456
+ try { this.engine.debugCommand(action, expr); } catch { /* the exit event reports it */ }
457
+ return undefined;
458
+ }
459
+ const line = { continue: "c", over: "n", into: "s", out: "f", where: ".carmar_debug_where()",
460
+ abort: 'invokeRestart("carmar_abort_cell")', eval: scalarChr(cmd.expr) ? cmd.expr : null }[action] ?? null;
461
+ if (line == null || /[\n\r]/.test(line) || line.length > 4000) return err("bad debug command");
462
+ this.audit("debug_cmd", { action });
463
+ try { this.engine.console(line); } catch { /* the exit event reports it */ }
464
+ if (["over", "into", "out"].includes(action)) { try { this.engine.console(".carmar_debug_where()"); } catch { /* same */ } }
465
+ return undefined;
466
+ }
467
+
468
+ /** A socket closed: spend no engine time on commands nobody will receive. */
469
+ dropSocket(rec) {
470
+ const abandoned = this.queue.filter((item) => { const r = this.routes.get(item.wireId); return r && r.rec === rec; });
471
+ if (abandoned.length) {
472
+ this.queue = this.queue.filter((item) => !abandoned.includes(item));
473
+ abandoned.forEach((item) => this.dropRoute(item.wireId));
474
+ }
475
+ }
476
+
477
+ // ── engine events ───────────────────────────────────────────────────────
478
+
479
+ onEvent(e) {
480
+ if (this.terminal) {
481
+ clearTimeout(this.terminal.timer);
482
+ this.terminal.timer = setTimeout(() => this.settleTerminal(), TERMINAL_QUIET_MS);
483
+ }
484
+ if (e.type === "debug") this.debugPaused = true;
485
+ if (e.type === "done") this.debugPaused = false;
486
+ if ((e.type === "ready" || e.type === "done") && scalarChr(e.cwd)) this.workerWd = e.cwd;
487
+ if (e.type === "wd" && scalarChr(e.path)) this.workerWd = e.path;
488
+ if (e.type === "input_request") this.inputWaiting = true;
489
+ if (e.type === "input_done" || e.type === "done") this.inputWaiting = false;
490
+
491
+ // The worker's receipt for the active command: the plane's own bookkeeping,
492
+ // never a page's frame — a request route would otherwise take it as its reply.
493
+ if (e.type === "ack") {
494
+ if (scalarChr(e.id) && e.id === this.active) this.acked = true;
495
+ return;
496
+ }
497
+ const hadId = scalarChr(e.id);
498
+ const wireId = hadId ? e.id : (["stdout", "stderr", "debug"].includes(e.type) ? this.active : null);
499
+ const route = wireId != null ? this.routes.get(wireId) : null;
500
+ if (wireId != null && wireId === this.active) this.acked = true; // any word from the run is receipt enough
501
+ if (route) {
502
+ const isTerminal = (route.kind === "request" && hadId) || e.type === "done"
503
+ || (route.kind === "exec" && hadId && e.error != null);
504
+ if (isTerminal) {
505
+ if (this.terminal) clearTimeout(this.terminal.timer);
506
+ this.terminal = { wireId, route, frame: e, timer: setTimeout(() => this.settleTerminal(), TERMINAL_QUIET_MS) };
507
+ } else {
508
+ // The page's id, ALWAYS — plain stdout/stderr carries no protocol id
509
+ // at all (it belongs to the run that owns the slot), and an id-bearing
510
+ // frame carries the WIRE id, which no page has ever heard of. relayFrame
511
+ // still prefers the engine's own bytes with only that id swapped; this
512
+ // object is what it falls back to, and it used to fall back to the wire
513
+ // id. The fallback is not hypothetical: an engine whose JSON writer
514
+ // spaces its colons takes it on every frame (measured with the Python
515
+ // engine, 2026-09-14 — the page dropped a whole chunk's output).
516
+ const addressed = { ...e, id: route.clientId };
517
+ if (route.kind === "exec") this.recordOutput(route, addressed);
518
+ if (!this.routeIsOrphan(route)) route.rec.ws.send(relayFrame(addressed, wireId, route.clientId));
519
+ }
520
+ return;
521
+ }
522
+ // Session-wide lifecycle frames have no owner and reach every socket.
523
+ if (e.type === "ready") {
524
+ this.readyFrame = e;
525
+ this.expectsAck = Array.isArray(e.features) && e.features.includes("command_ack_v1");
526
+ const { __raw, ...plain } = e;
527
+ const decorated = this.decorateReady(plain);
528
+ this.recovering = false;
529
+ this.restartAttempts = 0;
530
+ this.restartAfter = 0;
531
+ this.gaveUp = false;
532
+ this.notice = null;
533
+ this.audit("worker-ready", { pid: e.pid ?? null });
534
+ const payload = enc(decorated);
535
+ this.hello = payload;
536
+ this.emit("broadcast", payload);
537
+ this.emit("ready", decorated);
538
+ return;
539
+ }
540
+ this.emit("broadcast", relayFrame(e));
541
+ }
542
+
543
+ recordOutput(route, frame) {
544
+ const size = Buffer.byteLength(frame.__raw ?? frame.text ?? "");
545
+ if (route.bufferedBytes + size > ORPHAN_BUFFER_BYTES) { route.bufferTruncated = true; return; }
546
+ route.buffer.push(frame);
547
+ route.bufferedBytes += size;
548
+ }
549
+
550
+ settleTerminal() {
551
+ const terminal = this.terminal;
552
+ if (!terminal) return;
553
+ this.terminal = null;
554
+ const { wireId, route, frame } = terminal;
555
+ if (typeof route.onReply === "function") {
556
+ try { route.onReply(frame); } catch { /* a continuation must not break the plane */ }
557
+ this.dropRoute(wireId);
558
+ } else if (this.routeIsOrphan(route) && route.kind === "exec") {
559
+ route.parked = frame;
560
+ route.parkedAt = Date.now();
561
+ this.audit("result-parked", { srcname: route.srcname || "" });
562
+ } else {
563
+ route.rec.ws.send(relayFrame({ ...frame, id: route.clientId }, wireId, route.clientId));
564
+ this.dropRoute(wireId);
565
+ }
566
+ if (this.active === wireId) this.releaseSlot();
567
+ this.dispatch();
568
+ }
569
+
570
+ onExit() {
571
+ if (this.expectedExit) { this.expectedExit = false; return; }
572
+ this.recover();
573
+ }
574
+
575
+ recover() {
576
+ if (this.gaveUp) return;
577
+ if (!this.recovering) {
578
+ this.recovering = true;
579
+ this.restartAttempts = 0;
580
+ this.restartAfter = 0;
581
+ this.hello = null;
582
+ this.failRoutes(this.forceStopAsked
583
+ ? `${this.label} was force-stopped. Its variables and loaded packages are gone; Beatrina is starting a fresh ${this.label} session.`
584
+ : `The ${this.label} worker stopped unexpectedly. Its session state was lost; Beatrina is starting a fresh ${this.label} session.`);
585
+ this.resetSession();
586
+ const payload = enc({ type: "worker-died", engine: this.name, recovering: true, forced: this.forceStopAsked });
587
+ this.emit("broadcast", payload);
588
+ this.audit("worker-died", { recovering: true, forced: this.forceStopAsked });
589
+ this.forceStopAsked = false;
590
+ }
591
+ if (Date.now() < this.restartAfter) return;
592
+ if (this.restartAttempts >= this.respawnMax) {
593
+ this.gaveUp = true;
594
+ this.recovering = false;
595
+ const reason = `${this.label} would not start after ${this.restartAttempts} attempts. Beatrina is still running — the notebook, `
596
+ + `your text and Save all work — but nothing can be run until ${this.label} is fixed. A broken startup file or a moved `
597
+ + `installation is the usual cause; the kernel log beside this session has ${this.label}'s own message. Restart to try again.`;
598
+ this.audit("worker-gave-up", { attempt: this.restartAttempts });
599
+ this.notice = enc({ type: "worker-died", engine: this.name, recovering: false, terminal: true, attempts: this.restartAttempts, message: reason });
600
+ this.emit("broadcast", this.notice);
601
+ return;
602
+ }
603
+ this.restartAttempts += 1;
604
+ const attempt = this.restartAttempts;
605
+ this.restartAfter = Date.now() + RESPAWN_DELAYS_S[Math.min(attempt, 6) - 1] * 1000;
606
+ let started = null;
607
+ try { started = this.start(); } catch (e) { this.audit("worker-respawn-failed", { attempt, detail: e.message }); }
608
+ if (!started) return;
609
+ this.audit("worker-respawn", { attempt });
610
+ this.emit("broadcast", enc({ type: "worker-restarting", engine: this.name, attempt }));
611
+ }
612
+
613
+ // ── the per-turn sweeps ─────────────────────────────────────────────────
614
+
615
+ tick() {
616
+ this.ackCheck();
617
+ this.deadlineCheck();
618
+ if (this.engine && !this.engine.alive && this.recovering && !this.gaveUp) this.recover();
619
+ }
620
+
621
+ /**
622
+ * The ladder runs on its OWN timers, not the 100 ms sweep: the first re-send
623
+ * must land 60 ms after the group signal, because when that signal ends a
624
+ * child under system() libc has thrown away the SIGINT R got while waiting
625
+ * and R walks on to the next line — measured here, `system("sleep 30")`
626
+ * finished `ok` in 65 ms when the re-send waited for the sweep.
627
+ */
628
+ startLadder(wireId) {
629
+ this.clearLadder();
630
+ // The guard travels with the signal: a signal still in flight when this
631
+ // run ends must not reach the chunk the queue starts next.
632
+ const stillThisRun = () => wireId === this.active;
633
+ // The flag first: it is what stops R at the next expression when no signal can land (system()).
634
+ if (typeof this.engine.requestStop === "function") this.engine.requestStop(wireId);
635
+ this.engine.interrupt(stillThisRun);
636
+ let count = 1;
637
+ const again = () => {
638
+ if (wireId !== this.active || count >= INTERRUPT_LADDER_MAX) { this.ladder = null; return; }
639
+ count += 1;
640
+ this.audit("interrupt-again", { count });
641
+ this.engine.interrupt(stillThisRun);
642
+ this.ladder = { wireId, count, timer: setTimeout(again, INTERRUPT_LADDER_STEP_MS) };
643
+ };
644
+ this.ladder = { wireId, count, timer: setTimeout(again, INTERRUPT_LADDER_FIRST_MS) };
645
+ }
646
+
647
+ clearLadder() {
648
+ if (this.ladder && this.ladder.timer) clearTimeout(this.ladder.timer);
649
+ this.ladder = null;
650
+ }
651
+
652
+ /**
653
+ * A command the worker never received. The plane writes a command only to
654
+ * an idle worker, which acknowledges it within milliseconds; silence past
655
+ * COMMAND_ACK_MS means the line never reached the worker's loop — measured
656
+ * 2026-09-16: a Stop signal landing while R's console reader is half-way
657
+ * through a line loses that line, with R alive and its loop intact, and
658
+ * the exec behind it would otherwise wait forever. Failed with a sentence,
659
+ * the slot released, the queue moved on; the engine is untouched.
660
+ */
661
+ ackCheck() {
662
+ const wireId = this.active;
663
+ if (wireId == null || this.ackDue == null || this.acked) return;
664
+ if (this.inputWaiting || this.debugPaused || this.terminal) return;
665
+ if (Date.now() < this.ackDue) return;
666
+ const type = this.activeType || "";
667
+ const route = this.routes.get(wireId);
668
+ const reason = `${this.label} never received this command: Beatrina wrote it, but ${this.label} did not acknowledge it within `
669
+ + `${Math.round(COMMAND_ACK_MS / 1000)} seconds. A Stop signal that arrives while a command is being read can tear the line. `
670
+ + "Nothing ran and the session's variables are intact; run it again.";
671
+ this.audit("command-lost", { detail: type, id: wireId });
672
+ if (route) {
673
+ this.deliver(route, route.kind === "exec"
674
+ ? { type: "done", id: route.clientId, status: "error", message: reason }
675
+ : { type: route.responseType || type, id: route.clientId, error: reason });
676
+ }
677
+ this.dropRoute(wireId);
678
+ this.releaseSlot();
679
+ this.dispatch();
680
+ }
681
+
682
+ deadlineCheck() {
683
+ const wireId = this.active;
684
+ if (wireId == null || this.activeSince == null) return;
685
+ const type = this.activeType || "";
686
+ if (!type || !(type in WORKER_DEADLINES)) return;
687
+ // R is at a prompt: the slot is held by a question, not by a silence.
688
+ if (this.inputWaiting || this.debugPaused) return;
689
+ if (this.terminal) return;
690
+ const limit = WORKER_DEADLINES[type] * this.deadlineScale * 1000;
691
+ const waited = Date.now() - this.activeSince;
692
+ if (waited < limit) return;
693
+ const route = this.routes.get(wireId);
694
+ const reason = `${this.label} did not answer \`${type}\` within ${Math.round(limit / 1000)} seconds. The session and its `
695
+ + "variables are intact; only this request was abandoned. If it keeps happening, this Beatrina may be newer than the "
696
+ + `${this.label} session it is talking to — restart it.`;
697
+ this.audit("worker-deadline", { detail: type, id: wireId, bytes: Math.round(waited / 1000) });
698
+ if (route) {
699
+ this.deliver(route, route.kind === "exec"
700
+ ? { type: "done", id: route.clientId, status: "error", message: reason }
701
+ : { type: route.responseType || type, id: route.clientId, error: reason });
702
+ }
703
+ this.dropRoute(wireId);
704
+ this.releaseSlot();
705
+ this.dispatch();
706
+ }
707
+
708
+ async stop() {
709
+ clearInterval(this.sweeper);
710
+ this.expectedExit = true;
711
+ if (this.engine) await this.engine.stop();
712
+ }
713
+ }