better-dsh 0.3.0 → 0.3.1-a
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/README.md +49 -177
- package/cordis.patch.yml +5 -36
- package/docs/20_specs/kernel-provisioning/spec.md +2 -0
- package/docs/adr/0015-independent-bundles-agent-remote.md +40 -0
- package/docs/remote-tools.md +56 -0
- package/docs/specs/kernel-provisioning/spec.md +12 -40
- package/docs/specs/omp-python-repl-port/runtime-guide.md +35 -0
- package/docs/specs/omp-python-repl-port/spec.md +68 -0
- package/docs/specs/remote-tool-enhancement/spec.md +203 -0
- package/dsh-docs/baseline.md +13 -0
- package/dsh-docs/subsystems/invariants.md +3 -0
- package/dsh-docs/subsystems/invariants.zh.md +3 -0
- package/eval-description.md +7 -3
- package/lib/LaunchOptions-0Mr6meJc.js +4 -0
- package/lib/{LaunchOptions-kGXcZOe0.js → LaunchOptions-iE9M-STS.js} +1 -1
- package/lib/{bidi-BalpKaJc.js → bidi-CaQeBZHY.js} +1 -1
- package/lib/client/index.js +30 -6
- package/lib/failover/index.js +1 -1
- package/lib/fs-aware/sandbox-plugin.js +3 -3
- package/lib/{index-browser-CysrjMyM.js → index-browser-CJzxT9Vi.js} +4 -4
- package/lib/index.d.ts +16 -50
- package/lib/index.js +482 -1720
- package/lib/kernel-env-x_WkG6Fr.js +76 -0
- package/lib/kernel-env.d.ts +5 -69
- package/lib/kernel-env.js +2 -2
- package/lib/{lsp-server-registry-BXvUTBY7.js → lsp-server-registry-BcdkHROD.js} +1 -1
- package/lib/{lsp-server-registry-CH9WNFFt.js → lsp-server-registry-D4VLs1Sm.js} +1 -1
- package/lib/{main-BQeFrBM1.js → main-taSMz1KX.js} +1 -1
- package/lib/{main-CupOhMp5.js → main-uqGqYYnA.js} +7 -5
- package/lib/mobile/plugin.d.ts +1 -1
- package/lib/omp-python/DILL-LICENSE +35 -0
- package/lib/omp-python/OMP-LICENSE +23 -0
- package/lib/omp-python/better_snapshot.py +391 -0
- package/lib/omp-python/better_support.py +425 -0
- package/lib/omp-python/dill-provenance.json +55 -0
- package/lib/omp-python/provenance.json +13 -0
- package/lib/omp-python/runner.py +2499 -0
- package/lib/omp-python/vendor/dill/__diff.py +234 -0
- package/lib/omp-python/vendor/dill/__info__.py +291 -0
- package/lib/omp-python/vendor/dill/__init__.py +119 -0
- package/lib/omp-python/vendor/dill/_dill.py +2265 -0
- package/lib/omp-python/vendor/dill/_objects.py +545 -0
- package/lib/omp-python/vendor/dill/_shims.py +193 -0
- package/lib/omp-python/vendor/dill/detect.py +287 -0
- package/lib/omp-python/vendor/dill/logger.py +285 -0
- package/lib/omp-python/vendor/dill/objtypes.py +24 -0
- package/lib/omp-python/vendor/dill/pointers.py +122 -0
- package/lib/omp-python/vendor/dill/session.py +612 -0
- package/lib/omp-python/vendor/dill/settings.py +25 -0
- package/lib/omp-python/vendor/dill/source.py +1025 -0
- package/lib/omp-python/vendor/dill/temp.py +252 -0
- package/lib/{puppeteer-core-CSk2PtSm.js → puppeteer-core-C-4NyOpY.js} +8 -8
- package/lib/remote/plugin.d.ts +459 -5
- package/lib/remote/plugin.js +2389 -210
- package/lib/url-schemes/index.d.ts +11 -11
- package/lib/url-schemes/index.js +11 -11
- package/lib/web-password.d.ts +8 -8
- package/lib/web-password.js +5 -1
- package/lib/{web-trust-Dej_RI2B.d.ts → web-trust-C_Y6o8Z2.d.ts} +11 -11
- package/lib/web-trust.d.ts +1 -2
- package/lib/welcome-notice.d.ts +5 -7
- package/lib/welcome-notice.js +9 -48
- package/lib/{wrap-T4I0nfPM.js → wrap-0VA0uFx8.js} +1 -1
- package/package.json +7 -24
- package/scripts/kernel-check.mjs +5 -0
- package/lib/LaunchOptions-B874eBu2.js +0 -4
- package/lib/bundle-delivery.d.ts +0 -20
- package/lib/bundle-delivery.js +0 -209
- package/lib/hmr.d.ts +0 -32
- package/lib/hmr.js +0 -49
- package/lib/kernel-bridge.py +0 -172
- package/lib/kernel-env-D8UOWeji.js +0 -195
- package/lib/webserver.d.ts +0 -10
- package/lib/webserver.js +0 -19
- package/scripts/kernel-provision.mjs +0 -35
- package/scripts/register-bundle.mjs +0 -80
package/lib/index.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { t as Schema } from "./lib-C8_jXvwA.js";
|
|
2
2
|
import { a as RESERVED_ERROR_MEMBERS, i as RESERVED_BINDING_GLOBALS, n as DUNDER_MEMBER, o as ReplRuntime, r as PORTABLE_RESERVED_WORDS, t as isFlatBindableName } from "./py-sdk-D7klhpWb.js";
|
|
3
|
-
import {
|
|
3
|
+
import { n as resolveKernelEnv } from "./kernel-env-x_WkG6Fr.js";
|
|
4
4
|
import { t as captureAllTools } from "./native-capture-BEByDXYM.js";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { fileURLToPath } from "node:url";
|
|
10
6
|
import { Context } from "@deepseek-ai/cordis";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
7
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { createServer } from "node:http";
|
|
14
12
|
import { createInterface } from "node:readline";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
15
14
|
import { TOOL_RUNTIME_SCHEDULER, defineTool } from "@deepseek-ai/dsh-tools";
|
|
16
15
|
import * as dshLlm from "@deepseek-ai/dsh-llm";
|
|
17
16
|
import { BlockAssembler, HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
@@ -175,870 +174,32 @@ function snapshotJsonValue(value) {
|
|
|
175
174
|
return walkJsonValue(value, true);
|
|
176
175
|
}
|
|
177
176
|
|
|
178
|
-
//#endregion
|
|
179
|
-
//#region src/kernel-transport.ts
|
|
180
|
-
/** The bridge script shipped beside the built entry (`lib/kernel-bridge.py`). */
|
|
181
|
-
const BRIDGE_SCRIPT = fileURLToPath(new URL("./kernel-bridge.py", import.meta.url));
|
|
182
|
-
/**
|
|
183
|
-
* One bridge subprocess speaking JSON lines. Lifecycle is owned by the caller
|
|
184
|
-
* (the kernel bridge registers it as an `ctx.effect`); this type installs no
|
|
185
|
-
* process-level signal handlers.
|
|
186
|
-
*/
|
|
187
|
-
var KernelTransport = class {
|
|
188
|
-
child;
|
|
189
|
-
reader;
|
|
190
|
-
startup = false;
|
|
191
|
-
closed = false;
|
|
192
|
-
readySettled = false;
|
|
193
|
-
readyResolve;
|
|
194
|
-
readyReject;
|
|
195
|
-
bridgeStderr = "";
|
|
196
|
-
handlers = /* @__PURE__ */ new Set();
|
|
197
|
-
waiters = /* @__PURE__ */ new Set();
|
|
198
|
-
constructor(options) {
|
|
199
|
-
this.options = options;
|
|
200
|
-
}
|
|
201
|
-
/** Spawn the bridge and resolve once it reports the channels are up. */
|
|
202
|
-
async start() {
|
|
203
|
-
if (this.reader !== void 0) return;
|
|
204
|
-
if (this.closed) throw new Error("kernel transport is closed");
|
|
205
|
-
const child = spawn(this.options.python, [BRIDGE_SCRIPT, this.options.connectionFile], {
|
|
206
|
-
...this.options.cwd === void 0 ? {} : { cwd: this.options.cwd },
|
|
207
|
-
env: {
|
|
208
|
-
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
209
|
-
LANG: "C.UTF-8",
|
|
210
|
-
HOME: process.env.HOME ?? tmpdir(),
|
|
211
|
-
PYTHONUNBUFFERED: "1"
|
|
212
|
-
},
|
|
213
|
-
stdio: [
|
|
214
|
-
"pipe",
|
|
215
|
-
"pipe",
|
|
216
|
-
"pipe"
|
|
217
|
-
]
|
|
218
|
-
});
|
|
219
|
-
this.child = child;
|
|
220
|
-
this.startup = true;
|
|
221
|
-
const ready = new Promise((resolve$1, reject) => {
|
|
222
|
-
this.readyResolve = resolve$1;
|
|
223
|
-
this.readyReject = reject;
|
|
224
|
-
});
|
|
225
|
-
const timer = setTimeout(() => {
|
|
226
|
-
this.failReady(/* @__PURE__ */ new Error(`kernel bridge did not report ready within ${this.options.readyTimeoutMs}ms; stderr tail:\n${this.stderrTail()}`));
|
|
227
|
-
}, this.options.readyTimeoutMs);
|
|
228
|
-
timer.unref?.();
|
|
229
|
-
child.stderr?.on("data", (buffer) => {
|
|
230
|
-
this.bridgeStderr += buffer.toString();
|
|
231
|
-
});
|
|
232
|
-
child.stdin?.on("error", (error) => {
|
|
233
|
-
this.bridgeStderr += `stdin error: ${error.code ?? error.message}\n`;
|
|
234
|
-
this.closed = true;
|
|
235
|
-
this.failWaiters();
|
|
236
|
-
});
|
|
237
|
-
child.stdout?.on("error", (error) => {
|
|
238
|
-
this.bridgeStderr += `stdout error: ${error.code ?? error.message}\n`;
|
|
239
|
-
});
|
|
240
|
-
child.on("error", (error) => {
|
|
241
|
-
this.bridgeStderr += `spawn error: ${error.message}\n`;
|
|
242
|
-
this.failReady(/* @__PURE__ */ new Error(`kernel bridge failed to spawn: ${error.message}`));
|
|
243
|
-
this.failWaiters();
|
|
244
|
-
this.failExit();
|
|
245
|
-
});
|
|
246
|
-
child.on("exit", (code, signal) => {
|
|
247
|
-
const detail = `kernel bridge exited (code=${String(code)} signal=${String(signal)})`;
|
|
248
|
-
this.bridgeStderr += `${detail}\n`;
|
|
249
|
-
this.closed = true;
|
|
250
|
-
this.failReady(/* @__PURE__ */ new Error(`${detail}; stderr tail:\n${this.stderrTail()}`));
|
|
251
|
-
this.failWaiters();
|
|
252
|
-
this.failExit();
|
|
253
|
-
});
|
|
254
|
-
if (child.stdout) {
|
|
255
|
-
this.reader = createInterface({ input: child.stdout });
|
|
256
|
-
this.reader.on("line", (line) => {
|
|
257
|
-
this.accept(line);
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
try {
|
|
261
|
-
await ready;
|
|
262
|
-
} finally {
|
|
263
|
-
clearTimeout(timer);
|
|
264
|
-
this.startup = false;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
/** Whether the bridge process is still live. */
|
|
268
|
-
get alive() {
|
|
269
|
-
return this.child !== void 0 && this.child.exitCode === null && !this.closed;
|
|
270
|
-
}
|
|
271
|
-
/** The bridge's stderr tail, for startup and crash diagnostics. */
|
|
272
|
-
stderrTail() {
|
|
273
|
-
return this.bridgeStderr.slice(-1024) || "(empty)";
|
|
274
|
-
}
|
|
275
|
-
/** Register a message handler; the returned function removes it. */
|
|
276
|
-
onMessage(handler) {
|
|
277
|
-
this.handlers.add(handler);
|
|
278
|
-
return () => {
|
|
279
|
-
this.handlers.delete(handler);
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* Register a bridge-exit handler; the returned function removes it. A bridge
|
|
284
|
-
* that dies mid-cell must settle the active execution at once rather than at
|
|
285
|
-
* the cell budget.
|
|
286
|
-
*/
|
|
287
|
-
onExit(handler) {
|
|
288
|
-
this.exitHandlers.add(handler);
|
|
289
|
-
return () => {
|
|
290
|
-
this.exitHandlers.delete(handler);
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
exitHandlers = /* @__PURE__ */ new Set();
|
|
294
|
-
/** Notify exit handlers once per bridge process end. */
|
|
295
|
-
failExit() {
|
|
296
|
-
const handlers = [...this.exitHandlers];
|
|
297
|
-
this.exitHandlers.clear();
|
|
298
|
-
for (const handler of handlers) handler();
|
|
299
|
-
}
|
|
300
|
-
/** Send one message on the shell or control channel. */
|
|
301
|
-
send(channel, message) {
|
|
302
|
-
this.writeLine({
|
|
303
|
-
op: "send",
|
|
304
|
-
channel,
|
|
305
|
-
header: message.header,
|
|
306
|
-
content: message.content
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
/** Ask the kernel to interrupt the running cell (control channel). */
|
|
310
|
-
interrupt() {
|
|
311
|
-
this.writeLine({ op: "interrupt" });
|
|
312
|
-
}
|
|
313
|
-
/** Write one protocol line, refusing instead of throwing when the pipe is gone. */
|
|
314
|
-
writeLine(payload) {
|
|
315
|
-
const stdin = this.child?.stdin;
|
|
316
|
-
if (stdin === null || stdin === void 0 || this.closed || !stdin.writable) throw new Error("kernel bridge is not connected");
|
|
317
|
-
stdin.write(`${JSON.stringify(payload)}\n`);
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Wait for the next message on `channel` matching `match`.
|
|
321
|
-
* @returns The message, or null when the budget expires or the bridge dies.
|
|
322
|
-
*/
|
|
323
|
-
waitFor(channel, match, timeoutMs) {
|
|
324
|
-
if (this.closed) return Promise.resolve(null);
|
|
325
|
-
return new Promise((resolve$1) => {
|
|
326
|
-
const waiter = {
|
|
327
|
-
channel,
|
|
328
|
-
match,
|
|
329
|
-
settle: (message) => {
|
|
330
|
-
clearTimeout(waiter.timer);
|
|
331
|
-
this.waiters.delete(waiter);
|
|
332
|
-
resolve$1(message);
|
|
333
|
-
},
|
|
334
|
-
timer: setTimeout(() => {
|
|
335
|
-
this.waiters.delete(waiter);
|
|
336
|
-
resolve$1(null);
|
|
337
|
-
}, timeoutMs)
|
|
338
|
-
};
|
|
339
|
-
waiter.timer.unref?.();
|
|
340
|
-
this.waiters.add(waiter);
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
/** Stop the bridge (graceful shutdown note, then kill). */
|
|
344
|
-
async dispose() {
|
|
345
|
-
const child = this.child;
|
|
346
|
-
this.closed = true;
|
|
347
|
-
this.failWaiters();
|
|
348
|
-
this.reader?.close();
|
|
349
|
-
this.reader = void 0;
|
|
350
|
-
if (child === void 0) return;
|
|
351
|
-
try {
|
|
352
|
-
if (child.stdin?.writable === true) child.stdin.write(`${JSON.stringify({ op: "shutdown" })}\n`);
|
|
353
|
-
} catch {}
|
|
354
|
-
try {
|
|
355
|
-
child.stdin?.end();
|
|
356
|
-
} catch {}
|
|
357
|
-
if (child.exitCode !== null) return;
|
|
358
|
-
await new Promise((resolve$1) => {
|
|
359
|
-
const timer = setTimeout(() => {
|
|
360
|
-
try {
|
|
361
|
-
child.kill("SIGKILL");
|
|
362
|
-
} catch {}
|
|
363
|
-
resolve$1();
|
|
364
|
-
}, 500);
|
|
365
|
-
timer.unref?.();
|
|
366
|
-
child.once("exit", () => {
|
|
367
|
-
clearTimeout(timer);
|
|
368
|
-
resolve$1();
|
|
369
|
-
});
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
failReady(error) {
|
|
373
|
-
if (this.readySettled) return;
|
|
374
|
-
this.readySettled = true;
|
|
375
|
-
this.readyReject?.(error);
|
|
376
|
-
}
|
|
377
|
-
failWaiters() {
|
|
378
|
-
for (const waiter of [...this.waiters]) waiter.settle(null);
|
|
379
|
-
}
|
|
380
|
-
/** Route one decoded line; unknown events are ignored. */
|
|
381
|
-
accept(line) {
|
|
382
|
-
let event;
|
|
383
|
-
try {
|
|
384
|
-
event = JSON.parse(line);
|
|
385
|
-
} catch {
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (event.ev === "ready") {
|
|
389
|
-
if (!this.readySettled) {
|
|
390
|
-
this.readySettled = true;
|
|
391
|
-
this.readyResolve?.();
|
|
392
|
-
}
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
if (event.ev === "msg") {
|
|
396
|
-
const channel = event.channel;
|
|
397
|
-
const raw = event.msg;
|
|
398
|
-
if (typeof channel !== "string" || raw === void 0 || typeof raw.header !== "object" || raw.header === null) return;
|
|
399
|
-
const message = {
|
|
400
|
-
header: raw.header,
|
|
401
|
-
parent_header: raw.parent_header ?? {},
|
|
402
|
-
content: raw.content ?? {}
|
|
403
|
-
};
|
|
404
|
-
for (const waiter of [...this.waiters]) if (waiter.channel === channel && waiter.match(message)) waiter.settle(message);
|
|
405
|
-
for (const handler of this.handlers) handler(channel, message);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
if (event.ev === "fatal") this.bridgeStderr += `fatal: ${String(event.message ?? "unknown")}\n`;
|
|
409
|
-
}
|
|
410
|
-
};
|
|
411
|
-
|
|
412
177
|
//#endregion
|
|
413
178
|
//#region src/bootstrap.ts
|
|
414
|
-
/**
|
|
415
|
-
|
|
416
|
-
* startup. Seeds the user namespace with the host-request comm bridge
|
|
417
|
-
* (`dashr.host` target, the PA `host_request()` pattern), the binding-proxy
|
|
418
|
-
* factories, the typed rejection classes, and the lossless-JSON completion
|
|
419
|
-
* encoder. Names are `_dashr`/`__dashr` prefixed (any case) so they are
|
|
420
|
-
* excluded from namespace snapshots and never leak into user state.
|
|
421
|
-
* @module dashr/bootstrap
|
|
422
|
-
*/
|
|
423
|
-
/** Comm target the kernel-side shim opens for typed host requests. */
|
|
424
|
-
const HOST_COMM_TARGET = "dashr.host";
|
|
425
|
-
/**
|
|
426
|
-
* The bootstrap source. Kept dependency-free apart from `ast`, `asyncio`,
|
|
427
|
-
* `json`, and `comm` (present by construction — it IS the kernel).
|
|
428
|
-
*/
|
|
429
|
-
const KERNEL_BOOTSTRAP = `
|
|
430
|
-
import ast as _dashr_ast
|
|
431
|
-
import json as _dashr_json
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def _dashr_install_control_comm_handlers():
|
|
435
|
-
# Host replies arrive as comm_msg on the CONTROL channel while the shell
|
|
436
|
-
# channel is busy executing our cell; teach the kernel to route them.
|
|
437
|
-
try:
|
|
438
|
-
from IPython import get_ipython
|
|
439
|
-
except Exception:
|
|
440
|
-
return
|
|
441
|
-
shell = get_ipython()
|
|
442
|
-
kernel = getattr(shell, 'kernel', None)
|
|
443
|
-
comm_manager = getattr(kernel, 'comm_manager', None)
|
|
444
|
-
control_handlers = getattr(kernel, 'control_handlers', None)
|
|
445
|
-
if comm_manager is None or not isinstance(control_handlers, dict):
|
|
446
|
-
return
|
|
447
|
-
control_handlers.setdefault('comm_msg', comm_manager.comm_msg)
|
|
448
|
-
control_handlers.setdefault('comm_close', comm_manager.comm_close)
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _dashr_install_interrupt():
|
|
452
|
-
# ipykernel's async-cell SIGINT handling only schedules a callback on the
|
|
453
|
-
# kernel event loop, and a non-yielding cell ('while True: pass') blocks
|
|
454
|
-
# that very loop, so the control-channel interrupt can never break it.
|
|
455
|
-
# SIGALRM is untouched by ipykernel: raising KeyboardInterrupt from its
|
|
456
|
-
# handler breaks any cell, and the host sends it (after a confirm window,
|
|
457
|
-
# see the bridge) on timeout/abort. Best effort — where SIGALRM is
|
|
458
|
-
# unavailable the control-channel interrupt is all there is.
|
|
459
|
-
#
|
|
460
|
-
# The busy guard is load-bearing (M3-A, blueprint §5 "SIGALRM-at-idle 双重
|
|
461
|
-
# 窗口"): a KeyboardInterrupt raised OUTSIDE cell execution — while the
|
|
462
|
-
# kernel boots, idles between cells, or unwinds a finished one — escapes
|
|
463
|
-
# the shell's containment and terminates the process cleanly, which used
|
|
464
|
-
# to kill the kernel deterministically (10/10 same-tick aborts, 40/40
|
|
465
|
-
# during cold boot; dev/m2a-report.md §5.1). The handler therefore only
|
|
466
|
-
# raises while a dashr cell is actually executing, tracked by the
|
|
467
|
-
# module-level _dashr_state dict below and set/cleared by the run/snapshot
|
|
468
|
-
# cell scaffolds around their whole body. A stray SIGALRM outside that
|
|
469
|
-
# window is swallowed — the host's force-settle already resolves the run,
|
|
470
|
-
# and the kernel lives. The dict (not a bare flag) is captured by the
|
|
471
|
-
# handler closure, so user code rebinding the NAME cannot disarm the
|
|
472
|
-
# guard; mutating the dict itself stays possible and accepted — this shim
|
|
473
|
-
# is a capability seam, not a security boundary.
|
|
474
|
-
try:
|
|
475
|
-
import signal as _dashr_signal
|
|
476
|
-
|
|
477
|
-
def _dashr_on_sigalrm(_signum, _frame):
|
|
478
|
-
if not _dashr_state['executing']:
|
|
479
|
-
return
|
|
480
|
-
raise KeyboardInterrupt
|
|
481
|
-
|
|
482
|
-
_dashr_signal.signal(_dashr_signal.SIGALRM, _dashr_on_sigalrm)
|
|
483
|
-
except (ImportError, AttributeError, OSError, ValueError):
|
|
484
|
-
pass
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
# The dashr busy-guard flag (see _dashr_install_interrupt): True for the whole
|
|
488
|
-
# duration of a run or snapshot cell — the only window in which a SIGALRM may
|
|
489
|
-
# raise KeyboardInterrupt. Lives at module (user-namespace) level so the cell
|
|
490
|
-
# scaffolds can flip it; excluded from snapshots by the _dashr prefix rule.
|
|
491
|
-
_dashr_state = {'executing': False}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
_dashr_install_interrupt()
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
class _DashrRejected(Exception):
|
|
498
|
-
def __init__(self, message):
|
|
499
|
-
Exception.__init__(self, message)
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
def _dashr_host_request(payload):
|
|
503
|
-
from comm import create_comm
|
|
504
|
-
import asyncio
|
|
505
|
-
_dashr_install_control_comm_handlers()
|
|
506
|
-
loop = asyncio.get_running_loop()
|
|
507
|
-
future = loop.create_future()
|
|
508
|
-
# primary=True publishes comm_open so the host learns this comm_id; the
|
|
509
|
-
# request itself travels as the first comm_msg.
|
|
510
|
-
comm = create_comm(target_name=${JSON.stringify(HOST_COMM_TARGET)})
|
|
511
|
-
|
|
512
|
-
def _resolve(action):
|
|
513
|
-
def _apply():
|
|
514
|
-
if future.done():
|
|
515
|
-
return
|
|
516
|
-
action()
|
|
517
|
-
comm.close()
|
|
518
|
-
# Replies arrive on the kernel's control channel, which may run off
|
|
519
|
-
# the event-loop thread; only call_soon_threadsafe may touch the
|
|
520
|
-
# future from there.
|
|
521
|
-
loop.call_soon_threadsafe(_apply)
|
|
522
|
-
|
|
523
|
-
def _on_msg(msg):
|
|
524
|
-
content = msg.get('content', {})
|
|
525
|
-
reply = content.get('data', {}) if isinstance(content, dict) else {}
|
|
526
|
-
if not isinstance(reply, dict) or future.done():
|
|
527
|
-
return
|
|
528
|
-
status = reply.get('status')
|
|
529
|
-
if status == 'ok':
|
|
530
|
-
_resolve(lambda: future.set_result(reply.get('result')))
|
|
531
|
-
elif status == 'error':
|
|
532
|
-
_resolve(lambda: future.set_exception(_DashrRejected(reply.get('error') or 'host request failed')))
|
|
533
|
-
|
|
534
|
-
comm.on_msg(_on_msg)
|
|
535
|
-
comm.send(dict(payload))
|
|
536
|
-
return future
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def _dashr_make_error_class(name, member_property):
|
|
540
|
-
def _init(self, message, member=None):
|
|
541
|
-
Exception.__init__(self, message)
|
|
542
|
-
if member is not None:
|
|
543
|
-
setattr(self, member_property, member)
|
|
544
|
-
return type(name, (Exception,), {'__init__': _init})
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
def _dashr_make_proxy(global_name, function_name, error_class_name, member_property):
|
|
548
|
-
async def _dashr_proxy(*args, **kwargs):
|
|
549
|
-
if kwargs:
|
|
550
|
-
raise TypeError('dashr binding calls do not accept keyword arguments')
|
|
551
|
-
if len(args) == 1:
|
|
552
|
-
call_args = args[0]
|
|
553
|
-
elif len(args) == 0:
|
|
554
|
-
call_args = None
|
|
555
|
-
else:
|
|
556
|
-
call_args = list(args)
|
|
557
|
-
payload = {
|
|
558
|
-
'type': 'binding.call',
|
|
559
|
-
'global': global_name,
|
|
560
|
-
'name': function_name,
|
|
561
|
-
'args': call_args,
|
|
562
|
-
}
|
|
563
|
-
try:
|
|
564
|
-
return await _dashr_host_request(payload)
|
|
565
|
-
except _DashrRejected as rejected:
|
|
566
|
-
cls = globals().get(error_class_name) if error_class_name else None
|
|
567
|
-
if cls is None:
|
|
568
|
-
raise
|
|
569
|
-
raise cls(str(rejected), function_name) from None
|
|
570
|
-
return _dashr_proxy
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
def _dashr_make_holder(global_name, error_class_name, member_property, members):
|
|
574
|
-
# Attribute misses travel to the host instead of raising AttributeError
|
|
575
|
-
# here: the host owns the namespace, so IT rejects an unknown member as an
|
|
576
|
-
# unknown binding rather than the kernel inventing a local error class.
|
|
577
|
-
class _DashrBindingHolder:
|
|
578
|
-
def __dir__(self):
|
|
579
|
-
# F9 (v0.2.0-a): introspection tells the truth — dir(tool) lists
|
|
580
|
-
# exactly the bound names. A custom __dir__ fully replaces the
|
|
581
|
-
# default object.__dir__, so no dunder noise leaks in.
|
|
582
|
-
return list(members)
|
|
583
|
-
|
|
584
|
-
def __getattr__(self, function_name):
|
|
585
|
-
return _dashr_make_proxy(
|
|
586
|
-
global_name,
|
|
587
|
-
function_name,
|
|
588
|
-
error_class_name,
|
|
589
|
-
member_property,
|
|
590
|
-
)
|
|
591
|
-
|
|
592
|
-
return _DashrBindingHolder()
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
def _dashr_make_callable(global_name, function_name, error_class_name, member_property):
|
|
596
|
-
# A BARE callable global (the flat per-tool bindings AND the bridge
|
|
597
|
-
# tools: 'callable: true' on the namespace) instead of an object
|
|
598
|
-
# holder. The one-object form is the single convention: every callable
|
|
599
|
-
# takes exactly one positional arguments object (e.g.
|
|
600
|
-
# agent_message({'receiver': 'child', ...})), and keyword / multi-positional forms are
|
|
601
|
-
# rejected HERE, at the Python call boundary, so the introspectable
|
|
602
|
-
# signature tells the truth and a TypeError names the binding global
|
|
603
|
-
# (read(...) got an unexpected keyword argument ...). The wire envelope
|
|
604
|
-
# keeps the historical {'args': [...], 'kwargs': {}} shape so the host's
|
|
605
|
-
# parseReplCall / flatToolArgs / flatBridgeToolArgs validation layers stay
|
|
606
|
-
# authoritative and unchanged. The single functions-entry name is
|
|
607
|
-
# transport-only: the program never sees it, and a typed rejection
|
|
608
|
-
# carries the GLOBAL name as its member (for a flat tool binding the
|
|
609
|
-
# global IS the tool name the program knows). __name__/__qualname__ are
|
|
610
|
-
# pinned to the global name so introspection shows the binding's name,
|
|
611
|
-
# not the factory-local _dashr_callable.
|
|
612
|
-
async def _dashr_callable(args=None, /):
|
|
613
|
-
payload = {
|
|
614
|
-
'type': 'binding.call',
|
|
615
|
-
'global': global_name,
|
|
616
|
-
'name': function_name,
|
|
617
|
-
'args': {'args': [] if args is None else [args], 'kwargs': {}},
|
|
618
|
-
}
|
|
619
|
-
try:
|
|
620
|
-
return await _dashr_host_request(payload)
|
|
621
|
-
except _DashrRejected as rejected:
|
|
622
|
-
cls = globals().get(error_class_name) if error_class_name else None
|
|
623
|
-
if cls is None:
|
|
624
|
-
raise
|
|
625
|
-
raise cls(str(rejected), global_name) from None
|
|
626
|
-
|
|
627
|
-
_dashr_callable.__name__ = global_name
|
|
628
|
-
_dashr_callable.__qualname__ = global_name
|
|
629
|
-
return _dashr_callable
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
class _DashrNoValue:
|
|
633
|
-
# Marks 'no completion value': a cell whose last statement is not an
|
|
634
|
-
# expression, or whose last expression evaluates to None — the REPL
|
|
635
|
-
# displayhook suppresses None (IPython behavior), so no value crosses.
|
|
636
|
-
__slots__ = ()
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
_DASHR_NO_VALUE = _DashrNoValue()
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
class _DashrTopLevelReturnChecker(_dashr_ast.NodeVisitor):
|
|
643
|
-
# A cell runs at module scope, like any native IPython cell: a top-level
|
|
644
|
-
# 'return'/'yield' is a SyntaxError decided by the kernel itself, exactly
|
|
645
|
-
# as in a real REPL. Nested scopes (functions, lambdas, classes) keep
|
|
646
|
-
# their own return semantics.
|
|
647
|
-
def visit_FunctionDef(self, node):
|
|
648
|
-
return
|
|
649
|
-
|
|
650
|
-
def visit_AsyncFunctionDef(self, node):
|
|
651
|
-
return
|
|
652
|
-
|
|
653
|
-
def visit_ClassDef(self, node):
|
|
654
|
-
return
|
|
655
|
-
|
|
656
|
-
def visit_Lambda(self, node):
|
|
657
|
-
return
|
|
658
|
-
|
|
659
|
-
def visit_Return(self, node):
|
|
660
|
-
raise SyntaxError("'return' outside function")
|
|
661
|
-
|
|
662
|
-
def visit_Yield(self, node):
|
|
663
|
-
raise SyntaxError("'yield' outside function")
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
async def _dashr_run_program(source):
|
|
667
|
-
# Run one cell with the user namespace as BOTH globals and locals —
|
|
668
|
-
# module-scope REPL semantics: state persists across cells, top-level
|
|
669
|
-
# 'await' is legal (PyCF_ALLOW_TOP_LEVEL_AWAIT, the same mechanism IPython
|
|
670
|
-
# uses), a top-level 'return'/'yield' is a SyntaxError decided here by the
|
|
671
|
-
# kernel — exactly as in a native IPython cell — and the last expression's
|
|
672
|
-
# value is the cell's result (the REPL displayhook handoff).
|
|
673
|
-
import inspect as _dashr_inspect
|
|
674
|
-
user_ns = globals()
|
|
675
|
-
if not source.strip():
|
|
676
|
-
source = 'pass'
|
|
677
|
-
parsed = _dashr_ast.parse(source, '<dashr program>', 'exec')
|
|
678
|
-
_DashrTopLevelReturnChecker().visit(parsed)
|
|
679
|
-
body = parsed.body
|
|
680
|
-
if body and isinstance(body[-1], _dashr_ast.Expr):
|
|
681
|
-
# Capture the last expression the way a REPL's displayhook shows it:
|
|
682
|
-
# its value feeds the completion envelope instead of being discarded.
|
|
683
|
-
# The name is distinct from the scaffold's __dashr_completion__ (the
|
|
684
|
-
# envelope holder) so a statement-ending cell can never pop a stale
|
|
685
|
-
# envelope left by an earlier run.
|
|
686
|
-
last = body[-1]
|
|
687
|
-
body[-1] = _dashr_ast.Assign(
|
|
688
|
-
targets=[_dashr_ast.Name(id='__dashr_cell_value__', ctx=_dashr_ast.Store())],
|
|
689
|
-
value=last.value,
|
|
690
|
-
)
|
|
691
|
-
_dashr_ast.fix_missing_locations(body[-1])
|
|
692
|
-
code = compile(
|
|
693
|
-
parsed,
|
|
694
|
-
'<dashr program>',
|
|
695
|
-
'exec',
|
|
696
|
-
flags=_dashr_ast.PyCF_ALLOW_TOP_LEVEL_AWAIT,
|
|
697
|
-
)
|
|
698
|
-
# IPython's own run_code pattern: with PyCF_ALLOW_TOP_LEVEL_AWAIT, plain
|
|
699
|
-
# exec() DISCARDS the module coroutine silently; eval() under await
|
|
700
|
-
# returns it and runs the code (IPython InteractiveShell.run_code does
|
|
701
|
-
# exactly 'await eval(code_obj, user_global_ns, user_ns)').
|
|
702
|
-
result = eval(code, user_ns, user_ns)
|
|
703
|
-
if _dashr_inspect.iscoroutine(result):
|
|
704
|
-
await result
|
|
705
|
-
value = user_ns.pop('__dashr_cell_value__', _DASHR_NO_VALUE)
|
|
706
|
-
if value is None:
|
|
707
|
-
return _DASHR_NO_VALUE
|
|
708
|
-
return value
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
def _dashr_install_bindings(spec_json):
|
|
712
|
-
spec = _dashr_json.loads(spec_json)
|
|
713
|
-
user_ns = globals()
|
|
714
|
-
injected = user_ns.get('__dashr_injected__', {})
|
|
715
|
-
for old_name, old_spec in list(injected.items()):
|
|
716
|
-
if old_name not in spec:
|
|
717
|
-
user_ns.pop(old_name, None)
|
|
718
|
-
if old_spec.get('errorClass'):
|
|
719
|
-
user_ns.pop(old_spec['errorClass']['name'], None)
|
|
720
|
-
fresh = {}
|
|
721
|
-
# The flat per-tool shape declares the SAME error class on many
|
|
722
|
-
# namespaces: materialize each DISTINCT name once, so every proxy's
|
|
723
|
-
# raise-time globals() lookup finds ONE class and a single
|
|
724
|
-
# 'except ToolCallError' catches failures from every binding.
|
|
725
|
-
error_classes = {}
|
|
726
|
-
for global_name, namespace in spec.items():
|
|
727
|
-
error_class = namespace.get('errorClass')
|
|
728
|
-
if namespace.get('callable'):
|
|
729
|
-
# Exactly one function entry was validated by the host; its key is
|
|
730
|
-
# the transport name, the bare global itself is the callable.
|
|
731
|
-
function_name = next(iter(namespace['functions']))
|
|
732
|
-
user_ns[global_name] = _dashr_make_callable(
|
|
733
|
-
global_name,
|
|
734
|
-
function_name,
|
|
735
|
-
error_class['name'] if error_class else None,
|
|
736
|
-
error_class['memberNameProperty'] if error_class else None,
|
|
737
|
-
)
|
|
738
|
-
else:
|
|
739
|
-
user_ns[global_name] = _dashr_make_holder(
|
|
740
|
-
global_name,
|
|
741
|
-
error_class['name'] if error_class else None,
|
|
742
|
-
error_class['memberNameProperty'] if error_class else None,
|
|
743
|
-
sorted(namespace['functions']),
|
|
744
|
-
)
|
|
745
|
-
if error_class and error_class['name'] not in error_classes:
|
|
746
|
-
cls = _dashr_make_error_class(
|
|
747
|
-
error_class['name'],
|
|
748
|
-
error_class['memberNameProperty'],
|
|
749
|
-
)
|
|
750
|
-
error_classes[error_class['name']] = cls
|
|
751
|
-
user_ns[error_class['name']] = cls
|
|
752
|
-
fresh[global_name] = namespace
|
|
753
|
-
user_ns['__dashr_injected__'] = fresh
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
def _dashr_encode(value):
|
|
757
|
-
if value is _DASHR_NO_VALUE:
|
|
758
|
-
return {'ok': True}
|
|
759
|
-
try:
|
|
760
|
-
return {'ok': True, 'json': _dashr_json.dumps(value, allow_nan=False)}
|
|
761
|
-
except (TypeError, ValueError, OverflowError):
|
|
762
|
-
# Not lossless JSON: pass its repr TEXT through instead of failing
|
|
763
|
-
# the cell. The upstream renders the tool result as text either way,
|
|
764
|
-
# so a non-JSON value costs the model nothing; the host keeps a
|
|
765
|
-
# plain string it forwards untouched. The envelope itself never
|
|
766
|
-
# fails, so the completion never gate-rejects a return value.
|
|
767
|
-
return {'ok': True, 'json': _dashr_json.dumps(repr(value))}
|
|
768
|
-
`;
|
|
179
|
+
/** Module-qualified initialization survives OMP %reset without leaking imports. */
|
|
180
|
+
const KERNEL_BOOTSTRAP = `__import__('better_support').initialize(globals())`;
|
|
769
181
|
|
|
770
182
|
//#endregion
|
|
771
183
|
//#region src/python.ts
|
|
772
|
-
/**
|
|
773
|
-
* Build the single cell for one run.
|
|
774
|
-
* @param program - the model's cell program, executed at module scope.
|
|
775
|
-
* @param specJson - the JSON-encoded {@link NamespaceSpecs} map.
|
|
776
|
-
* @param sentinel - per-run nonce prefix; the envelope line starts with it.
|
|
777
|
-
* @returns the complete cell source.
|
|
778
|
-
*/
|
|
779
184
|
function buildRunCell(program, specJson, sentinel) {
|
|
780
|
-
return
|
|
781
|
-
`_dashr_state['executing'] = True`,
|
|
782
|
-
`try:`,
|
|
783
|
-
` _dashr_install_bindings(${JSON.stringify(specJson)})`,
|
|
784
|
-
` __dashr_completion__ = _dashr_encode(await _dashr_run_program(${JSON.stringify(program)}))`,
|
|
785
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps(__dashr_completion__))`,
|
|
786
|
-
`finally:`,
|
|
787
|
-
` _dashr_state['executing'] = False`
|
|
788
|
-
].join("\n");
|
|
789
|
-
}
|
|
790
|
-
/**
|
|
791
|
-
* Build the internal cell that dumps the user namespace to a dill snapshot and
|
|
792
|
-
* writes a JSON manifest next to it. Only USER state is captured: IPython's
|
|
793
|
-
* own session plumbing (`exit`, `quit`, `get_ipython`, … tracked in the
|
|
794
|
-
* shell's `user_ns_hidden` and still bound to the object IPython installed)
|
|
795
|
-
* reaches unpicklable kernel machinery — e.g. the history manager's
|
|
796
|
-
* sqlite3.Connection — and is never restored as program state anyway. Shim
|
|
797
|
-
* names (any `_dashr`/`__dashr` spelling) are excluded by prefix.
|
|
798
|
-
*
|
|
799
|
-
* Size-cap policy (M3-B, blueprint §8.3): dill cannot report a serialized
|
|
800
|
-
* size before dumping, and a GB-sized `DataFrame` dumped EVERY turn is the IO
|
|
801
|
-
* storm the cap exists to stop. The cell therefore gates in two tiers:
|
|
802
|
-
* first a bounded-depth namespace walk estimates the footprint (counting
|
|
803
|
-
* container lengths and reading `nbytes` / `memory_usage(deep=True)` for
|
|
804
|
-
* numpy/pandas objects — the stated pathological case — so the dump is
|
|
805
|
-
* skipped BEFORE any dill IO), then the actual dump goes to a sibling
|
|
806
|
-
* `.part` file whose true byte size is measured before it replaces the
|
|
807
|
-
* previous good snapshot. Skipped snapshots leave the previous
|
|
808
|
-
* `state.dill` + manifest untouched, so a revive restores the last good turn.
|
|
809
|
-
* @param payloadPath - absolute path for the dill payload.
|
|
810
|
-
* @param manifestPath - absolute path for the JSON manifest.
|
|
811
|
-
* @param spec - turn counter, skills list, and the size cap.
|
|
812
|
-
* @param sentinel - per-cell nonce prefix for the outcome envelope.
|
|
813
|
-
* @returns the complete cell source.
|
|
814
|
-
*/
|
|
815
|
-
function buildSnapshotCell(payloadPath, manifestPath, spec, sentinel) {
|
|
816
|
-
return [
|
|
817
|
-
`_dashr_state['executing'] = True`,
|
|
818
|
-
`try:`,
|
|
819
|
-
` import dill as _dashr_dill, json as _dashr_json, platform as _dashr_platform`,
|
|
820
|
-
` import os as _dashr_os, sys as _dashr_sys, datetime as _dashr_dt`,
|
|
821
|
-
` _dashr_shell = globals().get('get_ipython', lambda: None)()`,
|
|
822
|
-
` _dashr_hidden = getattr(_dashr_shell, 'user_ns_hidden', None) or {}`,
|
|
823
|
-
` _dashr_user_ns = {`,
|
|
824
|
-
` name: value`,
|
|
825
|
-
` for name, value in globals().items()`,
|
|
826
|
-
` if not name.lower().startswith(('_dashr', '__dashr'))`,
|
|
827
|
-
` and (name not in _dashr_hidden or _dashr_hidden[name] is not value)`,
|
|
828
|
-
` }`,
|
|
829
|
-
` _dashr_cap = ${JSON.stringify(spec.sizeCapBytes)}`,
|
|
830
|
-
` def _dashr_estimate(value, depth):`,
|
|
831
|
-
` if value is None or isinstance(value, (bool, int, float, complex)):`,
|
|
832
|
-
` return 32`,
|
|
833
|
-
` if isinstance(value, (str, bytes, bytearray)):`,
|
|
834
|
-
` return len(value) + 64`,
|
|
835
|
-
` if isinstance(value, (list, tuple, set, frozenset)):`,
|
|
836
|
-
` if depth <= 0:`,
|
|
837
|
-
` return 4096`,
|
|
838
|
-
` return 64 + sum(_dashr_estimate(item, depth - 1) for item in value)`,
|
|
839
|
-
` if isinstance(value, dict):`,
|
|
840
|
-
` if depth <= 0:`,
|
|
841
|
-
` return 4096`,
|
|
842
|
-
` total = 64`,
|
|
843
|
-
` for key, item in value.items():`,
|
|
844
|
-
` total += _dashr_estimate(key, depth - 1)`,
|
|
845
|
-
` total += _dashr_estimate(item, depth - 1)`,
|
|
846
|
-
` if total > _dashr_cap:`,
|
|
847
|
-
` return total`,
|
|
848
|
-
` return total`,
|
|
849
|
-
` _dashr_nbytes = getattr(value, 'nbytes', None)`,
|
|
850
|
-
` if isinstance(_dashr_nbytes, int) and _dashr_nbytes > 0:`,
|
|
851
|
-
` return _dashr_nbytes + 128`,
|
|
852
|
-
` _dashr_mem = getattr(value, 'memory_usage', None)`,
|
|
853
|
-
` if callable(_dashr_mem):`,
|
|
854
|
-
` try:`,
|
|
855
|
-
` _dashr_usage = _dashr_mem(deep=True)`,
|
|
856
|
-
` _dashr_sum = getattr(_dashr_usage, 'sum', None)`,
|
|
857
|
-
` if callable(_dashr_sum):`,
|
|
858
|
-
` # pandas returns a numpy scalar (np.int64), which is`,
|
|
859
|
-
` # not a Python int subclass; coerce, don't isinstance.`,
|
|
860
|
-
` _dashr_n = int(_dashr_sum())`,
|
|
861
|
-
` if _dashr_n > 0:`,
|
|
862
|
-
` return _dashr_n + 128`,
|
|
863
|
-
` except Exception:`,
|
|
864
|
-
` pass`,
|
|
865
|
-
` return 4096`,
|
|
866
|
-
` _dashr_estimate_bytes = sum(_dashr_estimate(value, 4) for value in _dashr_user_ns.values())`,
|
|
867
|
-
` if _dashr_estimate_bytes > _dashr_cap:`,
|
|
868
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({`,
|
|
869
|
-
` 'ok': False, 'skipped': True, 'reason': 'estimate',`,
|
|
870
|
-
` 'estimateBytes': _dashr_estimate_bytes, 'capBytes': _dashr_cap,`,
|
|
871
|
-
` }))`,
|
|
872
|
-
` else:`,
|
|
873
|
-
` _dashr_part = ${JSON.stringify(payloadPath)} + '.part'`,
|
|
874
|
-
` try:`,
|
|
875
|
-
` with open(_dashr_part, 'wb') as _dashr_f:`,
|
|
876
|
-
` _dashr_dill.dump(_dashr_user_ns, _dashr_f)`,
|
|
877
|
-
` _dashr_size = _dashr_os.path.getsize(_dashr_part)`,
|
|
878
|
-
` if _dashr_size > _dashr_cap:`,
|
|
879
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({`,
|
|
880
|
-
` 'ok': False, 'skipped': True, 'reason': 'actual',`,
|
|
881
|
-
` 'sizeBytes': _dashr_size, 'capBytes': _dashr_cap,`,
|
|
882
|
-
` }))`,
|
|
883
|
-
` else:`,
|
|
884
|
-
` _dashr_os.replace(_dashr_part, ${JSON.stringify(payloadPath)})`,
|
|
885
|
-
` _dashr_manifest = {`,
|
|
886
|
-
` 'snapshotFormat': 1,`,
|
|
887
|
-
` 'turn': ${JSON.stringify(spec.turn)},`,
|
|
888
|
-
` 'pythonVersion': _dashr_platform.python_version(),`,
|
|
889
|
-
` 'venvPath': _dashr_sys.executable,`,
|
|
890
|
-
` 'skills': ${JSON.stringify(spec.skills)},`,
|
|
891
|
-
` 'names': sorted(_dashr_user_ns),`,
|
|
892
|
-
` 'sizeBytes': _dashr_size,`,
|
|
893
|
-
` 'skipped': False,`,
|
|
894
|
-
` 'createdAt': _dashr_dt.datetime.now(_dashr_dt.timezone.utc).isoformat(),`,
|
|
895
|
-
` }`,
|
|
896
|
-
` with open(${JSON.stringify(manifestPath)}, 'w', encoding='utf-8') as _dashr_f:`,
|
|
897
|
-
` _dashr_json.dump(_dashr_manifest, _dashr_f)`,
|
|
898
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({`,
|
|
899
|
-
` 'ok': True, 'sizeBytes': _dashr_size, 'names': len(_dashr_user_ns),`,
|
|
900
|
-
` }))`,
|
|
901
|
-
` finally:`,
|
|
902
|
-
` if _dashr_os.path.exists(_dashr_part):`,
|
|
903
|
-
` try:`,
|
|
904
|
-
` _dashr_os.unlink(_dashr_part)`,
|
|
905
|
-
` except OSError:`,
|
|
906
|
-
` pass`,
|
|
907
|
-
`finally:`,
|
|
908
|
-
` _dashr_state['executing'] = False`
|
|
909
|
-
].join("\n");
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* Build the internal cell that restores a dill snapshot into the user
|
|
913
|
-
* namespace. The kernel validates the manifest ITSELF (it is the only party
|
|
914
|
-
* that knows its own python version and `sys.executable`): a python-version
|
|
915
|
-
* mismatch, a different interpreter (`venvPath`), a non-empty skills list, or
|
|
916
|
-
* an unreadable/corrupt payload degrades to an empty namespace WITHOUT
|
|
917
|
-
* touching globals, and reports the reason through the envelope so the host
|
|
918
|
-
* can tell the model "snapshot not replayable, started from empty". Shim and
|
|
919
|
-
* IPython-hidden names are re-filtered on the way in — the same exclusion the
|
|
920
|
-
* snapshot applied on the way out, applied again defensively.
|
|
921
|
-
* @param payloadPath - absolute path of the dill payload.
|
|
922
|
-
* @param manifestPath - absolute path of the JSON manifest.
|
|
923
|
-
* @param sentinel - per-cell nonce prefix for the outcome envelope.
|
|
924
|
-
* @returns the complete cell source.
|
|
925
|
-
*/
|
|
926
|
-
function buildRestoreCell(payloadPath, manifestPath, sentinel) {
|
|
927
|
-
return [
|
|
928
|
-
`_dashr_state['executing'] = True`,
|
|
929
|
-
`try:`,
|
|
930
|
-
` import dill as _dashr_dill, json as _dashr_json, platform as _dashr_platform`,
|
|
931
|
-
` import os as _dashr_os, sys as _dashr_sys`,
|
|
932
|
-
` _dashr_manifest = None`,
|
|
933
|
-
` _dashr_reason = 'snapshot manifest could not be read'`,
|
|
934
|
-
` try:`,
|
|
935
|
-
` with open(${JSON.stringify(manifestPath)}, 'r', encoding='utf-8') as _dashr_f:`,
|
|
936
|
-
` _dashr_manifest = _dashr_json.load(_dashr_f)`,
|
|
937
|
-
` except Exception as _dashr_err:`,
|
|
938
|
-
` _dashr_reason = 'snapshot manifest could not be read: ' + repr(_dashr_err)`,
|
|
939
|
-
` if _dashr_manifest is None:`,
|
|
940
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': _dashr_reason}))`,
|
|
941
|
-
` elif _dashr_manifest.get('snapshotFormat') != 1:`,
|
|
942
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'snapshot format is not replayable'}))`,
|
|
943
|
-
` elif _dashr_manifest.get('skipped'):`,
|
|
944
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'snapshot was skipped (namespace exceeded the size cap)'}))`,
|
|
945
|
-
` elif _dashr_manifest.get('pythonVersion') != _dashr_platform.python_version():`,
|
|
946
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'python version mismatch: snapshot ' + str(_dashr_manifest.get('pythonVersion')) + ' vs kernel ' + _dashr_platform.python_version()}))`,
|
|
947
|
-
` elif _dashr_os.path.realpath(_dashr_manifest.get('venvPath') or '') != _dashr_os.path.realpath(_dashr_sys.executable):`,
|
|
948
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'kernel interpreter differs from the snapshot venv'}))`,
|
|
949
|
-
` elif _dashr_manifest.get('skills'):`,
|
|
950
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'snapshot skills are not replayable in this provider'}))`,
|
|
951
|
-
` else:`,
|
|
952
|
-
` try:`,
|
|
953
|
-
` with open(${JSON.stringify(payloadPath)}, 'rb') as _dashr_f:`,
|
|
954
|
-
` _dashr_restored = _dashr_dill.load(_dashr_f)`,
|
|
955
|
-
` except Exception as _dashr_err:`,
|
|
956
|
-
` _dashr_restored = None`,
|
|
957
|
-
` _dashr_reason = 'snapshot payload could not be loaded: ' + repr(_dashr_err)`,
|
|
958
|
-
` if not isinstance(_dashr_restored, dict):`,
|
|
959
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': _dashr_reason if _dashr_restored is None else 'snapshot payload is not a namespace dict'}))`,
|
|
960
|
-
` else:`,
|
|
961
|
-
` _dashr_shell = globals().get('get_ipython', lambda: None)()`,
|
|
962
|
-
` _dashr_hidden = getattr(_dashr_shell, 'user_ns_hidden', None) or {}`,
|
|
963
|
-
` _dashr_user_ns = globals()`,
|
|
964
|
-
` _dashr_restored_count = 0`,
|
|
965
|
-
` for _dashr_name, _dashr_value in _dashr_restored.items():`,
|
|
966
|
-
` if not isinstance(_dashr_name, str):`,
|
|
967
|
-
` continue`,
|
|
968
|
-
` if _dashr_name.lower().startswith(('_dashr', '__dashr')):`,
|
|
969
|
-
` continue`,
|
|
970
|
-
` if _dashr_name in _dashr_hidden and _dashr_hidden[_dashr_name] is _dashr_value:`,
|
|
971
|
-
` continue`,
|
|
972
|
-
` _dashr_user_ns[_dashr_name] = _dashr_value`,
|
|
973
|
-
` _dashr_restored_count += 1`,
|
|
974
|
-
` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'restored': _dashr_restored_count}))`,
|
|
975
|
-
`finally:`,
|
|
976
|
-
` _dashr_state['executing'] = False`
|
|
977
|
-
].join("\n");
|
|
185
|
+
return `await __import__('better_support').run(globals(), ${JSON.stringify(program)}, ${JSON.stringify(specJson)}, ${JSON.stringify(sentinel)})`;
|
|
978
186
|
}
|
|
979
|
-
/**
|
|
980
|
-
* Build the internal cell that reads ONE user-namespace variable by name, or
|
|
981
|
-
* — when `name` is `null` — lists the namespace's user-variable names. The
|
|
982
|
-
* same exclusion the snapshot applies on the way out (any `_dashr`/`__dashr`
|
|
983
|
-
* spelling plus IPython's `user_ns_hidden`) defines what "user variable"
|
|
984
|
-
* means here, so `ctx://` sees exactly the namespace a revive would restore.
|
|
985
|
-
*
|
|
986
|
-
* Serialization boundary (design.md D4): a value that survives
|
|
987
|
-
* `json.dumps(…, allow_nan=False)` crosses as `kind: 'json'` text; anything
|
|
988
|
-
* else falls back to `repr` text (`kind: 'repr'`) so a non-JSON variable
|
|
989
|
-
* (the reason the snapshot uses dill at all) is still readable as text. The
|
|
990
|
-
* host keeps the `kind` so the presentation can annotate a repr as "this is
|
|
991
|
-
* repr text, not a JSON value".
|
|
992
|
-
* @param name - the exact variable name, or `null` to list namespace names.
|
|
993
|
-
* @param sentinel - per-cell nonce prefix for the outcome envelope.
|
|
994
|
-
* @returns the complete cell source.
|
|
995
|
-
*/
|
|
996
187
|
function buildQueryVarCell(name$1, sentinel) {
|
|
997
|
-
|
|
998
|
-
`_dashr_state['executing'] = True`,
|
|
999
|
-
`try:`,
|
|
1000
|
-
` import json as _dashr_json`,
|
|
1001
|
-
` _dashr_shell = globals().get('get_ipython', lambda: None)()`,
|
|
1002
|
-
` _dashr_hidden = getattr(_dashr_shell, 'user_ns_hidden', None) or {}`,
|
|
1003
|
-
` _dashr_user_ns = {`,
|
|
1004
|
-
` _name: _value`,
|
|
1005
|
-
` for _name, _value in globals().items()`,
|
|
1006
|
-
` if not _name.lower().startswith(('_dashr', '__dashr'))`,
|
|
1007
|
-
` and (_name not in _dashr_hidden or _dashr_hidden[_name] is not _value)`,
|
|
1008
|
-
` }`
|
|
1009
|
-
];
|
|
1010
|
-
if (name$1 === null) lines.push(` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'names', 'names': sorted(_dashr_user_ns)}))`);
|
|
1011
|
-
else lines.push(` _dashr_name = ${JSON.stringify(name$1)}`, ` if _dashr_name not in _dashr_user_ns:`, ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'missing'}))`, ` else:`, ` _dashr_value = _dashr_user_ns[_dashr_name]`, ` try:`, ` _dashr_text = _dashr_json.dumps(_dashr_value, allow_nan=False)`, ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'json', 'text': _dashr_text}))`, ` except (TypeError, ValueError, OverflowError):`, ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'repr', 'text': repr(_dashr_value)}))`);
|
|
1012
|
-
lines.push(`finally:`, ` _dashr_state['executing'] = False`);
|
|
1013
|
-
return lines.join("\n");
|
|
188
|
+
return `__import__('better_support').query(globals(), ${JSON.stringify(JSON.stringify(name$1))}, ${JSON.stringify(sentinel)})`;
|
|
1014
189
|
}
|
|
1015
|
-
/**
|
|
1016
|
-
* Build the internal cell that assigns one JSON value into the user
|
|
1017
|
-
* namespace under `name`. The value is carried as pre-serialized JSON text
|
|
1018
|
-
* (the host validated it as lossless JSON and the name as a usable
|
|
1019
|
-
* identifier), so the cell only decodes and binds — no arbitrary object can
|
|
1020
|
-
* cross the wire. Bracketed by the same SIGALRM busy guard as a run cell.
|
|
1021
|
-
* @param name - the validated identifier to assign under.
|
|
1022
|
-
* @param valueJson - the lossless-JSON text to decode and bind.
|
|
1023
|
-
* @param sentinel - per-cell nonce prefix for the outcome envelope.
|
|
1024
|
-
* @returns the complete cell source.
|
|
1025
|
-
*/
|
|
1026
190
|
function buildSetVarCell(name$1, valueJson, sentinel) {
|
|
1027
|
-
return
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
`finally:`,
|
|
1035
|
-
` _dashr_state['executing'] = False`
|
|
1036
|
-
].join("\n");
|
|
191
|
+
return `__import__('better_support').set_var(globals(), ${JSON.stringify(name$1)}, ${JSON.stringify(valueJson)}, ${JSON.stringify(sentinel)})`;
|
|
192
|
+
}
|
|
193
|
+
function buildSnapshotCell(payloadPath, manifestPath, spec, sentinel) {
|
|
194
|
+
return `__import__('better_snapshot').save(globals(), ${JSON.stringify(payloadPath)}, ${JSON.stringify(manifestPath)}, ${JSON.stringify(JSON.stringify(spec))}, ${JSON.stringify(sentinel)})`;
|
|
195
|
+
}
|
|
196
|
+
function buildRestoreCell(payloadPath, manifestPath, sentinel, bindings = []) {
|
|
197
|
+
return `__import__('better_snapshot').restore(globals(), ${JSON.stringify(payloadPath)}, ${JSON.stringify(manifestPath)}, ${JSON.stringify(sentinel)}, ${JSON.stringify(JSON.stringify(bindings))})`;
|
|
1037
198
|
}
|
|
1038
199
|
|
|
1039
200
|
//#endregion
|
|
1040
201
|
//#region src/output.ts
|
|
1041
|
-
/** ANSI color escapes, which
|
|
202
|
+
/** ANSI color escapes, which Python may embed in tracebacks. */
|
|
1042
203
|
const ANSI_ESCAPE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
1043
204
|
/** Smallest cap that can represent an empty logs array plus a quoted message. */
|
|
1044
205
|
const MIN_OUTPUT_BYTES = 4;
|
|
@@ -1067,7 +228,7 @@ function extractStream(streamText, sentinel) {
|
|
|
1067
228
|
envelope: envelope === "" ? void 0 : envelope
|
|
1068
229
|
};
|
|
1069
230
|
}
|
|
1070
|
-
/** Strip ANSI escapes from
|
|
231
|
+
/** Strip ANSI escapes from a Python traceback for a model-facing message. */
|
|
1071
232
|
function plainTraceback(ename, evalue, traceback) {
|
|
1072
233
|
const body = traceback.map((line) => line.replace(ANSI_ESCAPE, "")).join("\n");
|
|
1073
234
|
return body.length > 0 ? `${ename}: ${evalue}\n${body}` : `${ename}: ${evalue}`;
|
|
@@ -1122,775 +283,437 @@ function finalizeOutput(logs, value, error, maxBytes) {
|
|
|
1122
283
|
|
|
1123
284
|
//#endregion
|
|
1124
285
|
//#region src/kernel.ts
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const IOPUB_SUBSCRIBE_DELAY_MS = 50;
|
|
1128
|
-
const CONNECT_POLL_MS = 25;
|
|
1129
|
-
const SHUTDOWN_DRAIN_MS = 200;
|
|
1130
|
-
function isRecord(value) {
|
|
1131
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
286
|
+
function record(value) {
|
|
287
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1132
288
|
}
|
|
1133
|
-
|
|
1134
|
-
return {
|
|
1135
|
-
header: {
|
|
1136
|
-
msg_id: randomUUID(),
|
|
1137
|
-
session,
|
|
1138
|
-
username,
|
|
1139
|
-
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1140
|
-
msg_type: msgType,
|
|
1141
|
-
version: PROTOCOL_VERSION
|
|
1142
|
-
},
|
|
1143
|
-
parent_header: {},
|
|
1144
|
-
content
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
function makeConnection() {
|
|
1148
|
-
const info = {
|
|
1149
|
-
ip: "127.0.0.1",
|
|
1150
|
-
transport: "tcp",
|
|
1151
|
-
shell_port: 0,
|
|
1152
|
-
iopub_port: 0,
|
|
1153
|
-
stdin_port: 0,
|
|
1154
|
-
control_port: 0,
|
|
1155
|
-
hb_port: 0,
|
|
1156
|
-
signature_scheme: "hmac-sha256",
|
|
1157
|
-
key: randomBytes(16).toString("hex"),
|
|
1158
|
-
kernel_name: "python3"
|
|
1159
|
-
};
|
|
1160
|
-
const tempDir = mkdtempSync(join(tmpdir(), "dashr-kernel-"));
|
|
1161
|
-
const path$1 = join(tempDir, "connection.json");
|
|
1162
|
-
writeFileSync(path$1, JSON.stringify(info, null, 2), { mode: 384 });
|
|
1163
|
-
return {
|
|
1164
|
-
path: path$1,
|
|
1165
|
-
tempDir
|
|
1166
|
-
};
|
|
1167
|
-
}
|
|
1168
|
-
function readResolvedConnection(path$1) {
|
|
1169
|
-
try {
|
|
1170
|
-
const parsed = JSON.parse(readFileSync(path$1, "utf8"));
|
|
1171
|
-
if (!isRecord(parsed) || parsed.ip !== "127.0.0.1" || parsed.transport !== "tcp" || parsed.signature_scheme !== "hmac-sha256" || typeof parsed.key !== "string") return null;
|
|
1172
|
-
for (const port of [
|
|
1173
|
-
"shell_port",
|
|
1174
|
-
"iopub_port",
|
|
1175
|
-
"stdin_port",
|
|
1176
|
-
"control_port",
|
|
1177
|
-
"hb_port"
|
|
1178
|
-
]) {
|
|
1179
|
-
const value = parsed[port];
|
|
1180
|
-
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return null;
|
|
1181
|
-
}
|
|
1182
|
-
return parsed;
|
|
1183
|
-
} catch {
|
|
1184
|
-
return null;
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
/**
|
|
1188
|
-
* One persistent IPython kernel subprocess with its ZeroMQ channels. Cell
|
|
1189
|
-
* execution is serialized on an internal queue (the Jupyter shell channel is
|
|
1190
|
-
* request/reply).
|
|
1191
|
-
*/
|
|
289
|
+
/** Historical class name retained for the runtime's private integration seam. */
|
|
1192
290
|
var IpyKernelBridge = class {
|
|
1193
|
-
session = randomUUID();
|
|
1194
291
|
state = "idle";
|
|
292
|
+
child;
|
|
1195
293
|
startPromise;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
servedComms = /* @__PURE__ */ new Set();
|
|
294
|
+
disposePromise;
|
|
295
|
+
queue = Promise.resolve();
|
|
296
|
+
active;
|
|
297
|
+
stderr = "";
|
|
298
|
+
deathFailure;
|
|
299
|
+
callbackServer;
|
|
300
|
+
closePromise;
|
|
301
|
+
ownedReaders = [];
|
|
1205
302
|
constructor(options) {
|
|
1206
303
|
this.options = options;
|
|
1207
304
|
}
|
|
1208
|
-
/** The kernel subprocess pid, for lifecycle assertions; absent before spawn. */
|
|
1209
305
|
get pid() {
|
|
1210
|
-
return this.
|
|
306
|
+
return this.child?.pid;
|
|
1211
307
|
}
|
|
1212
308
|
get isRunning() {
|
|
1213
309
|
return this.state === "running";
|
|
1214
310
|
}
|
|
1215
|
-
/**
|
|
1216
|
-
* Whether the subprocess is gone (crashed or disposed). A dead bridge is
|
|
1217
|
-
* never resurrected — the owning provider replaces it with a fresh one
|
|
1218
|
-
* (respawn, M3-A), because kernel state is unrecoverable in-process.
|
|
1219
|
-
*/
|
|
1220
311
|
get isDead() {
|
|
1221
312
|
return this.state === "shutdown";
|
|
1222
313
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
* @throws when the subprocess cannot spawn, ports never resolve, the
|
|
1226
|
-
* kernel_info probe times out, or the bootstrap cell fails.
|
|
1227
|
-
*/
|
|
1228
|
-
async start() {
|
|
1229
|
-
if (!this.startPromise) this.startPromise = this.doStart().catch((error) => {
|
|
1230
|
-
this.startPromise = void 0;
|
|
1231
|
-
throw error;
|
|
1232
|
-
});
|
|
314
|
+
start() {
|
|
315
|
+
this.startPromise ??= this.doStart();
|
|
1233
316
|
return this.startPromise;
|
|
1234
317
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
318
|
+
async windowsCallbacks() {
|
|
319
|
+
const token = randomBytes(32).toString("hex");
|
|
320
|
+
const server = createServer((req, response) => {
|
|
321
|
+
if (req.method !== "POST" || req.url !== "/callback" || req.headers.authorization !== `Bearer ${token}`) {
|
|
322
|
+
response.writeHead(403).end();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
let body = "";
|
|
326
|
+
req.setEncoding("utf8");
|
|
327
|
+
req.on("data", (data) => {
|
|
328
|
+
body += data;
|
|
329
|
+
});
|
|
330
|
+
req.on("end", () => {
|
|
331
|
+
let parsed;
|
|
332
|
+
try {
|
|
333
|
+
parsed = JSON.parse(body);
|
|
334
|
+
} catch {
|
|
335
|
+
response.writeHead(400).end();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this.callback(parsed).then((reply) => {
|
|
339
|
+
if (!response.destroyed) response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(reply));
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
this.callbackServer = server;
|
|
344
|
+
await new Promise((resolve$1, reject) => {
|
|
345
|
+
server.once("error", reject);
|
|
346
|
+
server.listen(0, "127.0.0.1", () => {
|
|
347
|
+
server.off("error", reject);
|
|
348
|
+
resolve$1();
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
const address = server.address();
|
|
352
|
+
if (!address || typeof address === "string") throw new Error("callback endpoint unavailable");
|
|
353
|
+
return {
|
|
354
|
+
BETTER_CALLBACK_URL: `http://127.0.0.1:${address.port}/callback`,
|
|
355
|
+
BETTER_CALLBACK_TOKEN: token
|
|
356
|
+
};
|
|
1237
357
|
}
|
|
1238
358
|
async doStart() {
|
|
1239
|
-
if (this.state !== "idle")
|
|
359
|
+
if (this.state !== "idle") throw new Error("Python bridge cannot restart after shutdown");
|
|
1240
360
|
this.state = "starting";
|
|
1241
|
-
const { path: path$1, tempDir } = makeConnection();
|
|
1242
|
-
this.tempDir = tempDir;
|
|
1243
|
-
const kernel = spawn(this.options.python, [
|
|
1244
|
-
"-m",
|
|
1245
|
-
"ipykernel_launcher",
|
|
1246
|
-
"-f",
|
|
1247
|
-
path$1
|
|
1248
|
-
], {
|
|
1249
|
-
cwd: this.options.cwd,
|
|
1250
|
-
env: {
|
|
1251
|
-
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
1252
|
-
LANG: "C.UTF-8",
|
|
1253
|
-
HOME: process.env.HOME ?? tmpdir()
|
|
1254
|
-
},
|
|
1255
|
-
stdio: [
|
|
1256
|
-
"ignore",
|
|
1257
|
-
"pipe",
|
|
1258
|
-
"pipe"
|
|
1259
|
-
]
|
|
1260
|
-
});
|
|
1261
|
-
this.kernel = kernel;
|
|
1262
|
-
kernel.stderr?.on("data", (buf) => {
|
|
1263
|
-
this.kernelStderr += buf.toString();
|
|
1264
|
-
});
|
|
1265
|
-
kernel.on("error", (error) => {
|
|
1266
|
-
this.kernelStderr += `spawn error: ${error.message}\n`;
|
|
1267
|
-
this.onKernelDeath();
|
|
1268
|
-
});
|
|
1269
|
-
kernel.on("exit", (code, signal) => {
|
|
1270
|
-
if (this.state !== "shutdown") this.kernelStderr += `unexpected exit code=${code} signal=${signal}\n`;
|
|
1271
|
-
this.onKernelDeath();
|
|
1272
|
-
});
|
|
1273
|
-
let conn;
|
|
1274
361
|
try {
|
|
1275
|
-
|
|
1276
|
-
this.
|
|
1277
|
-
|
|
1278
|
-
this.
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
362
|
+
const resources = fileURLToPath(new URL("./omp-python/", import.meta.url));
|
|
363
|
+
const callbackEnv = process.platform === "win32" ? await this.windowsCallbacks() : {};
|
|
364
|
+
if (this.isDead) throw new Error("Python startup cancelled by disposal");
|
|
365
|
+
const child = spawn(this.options.python, ["-u", fileURLToPath(new URL("./omp-python/runner.py", import.meta.url))], {
|
|
366
|
+
...this.options.cwd ? { cwd: this.options.cwd } : {},
|
|
367
|
+
detached: process.platform !== "win32",
|
|
368
|
+
stdio: process.platform === "win32" ? [
|
|
369
|
+
"pipe",
|
|
370
|
+
"pipe",
|
|
371
|
+
"pipe"
|
|
372
|
+
] : [
|
|
373
|
+
"pipe",
|
|
374
|
+
"pipe",
|
|
375
|
+
"pipe",
|
|
376
|
+
"pipe",
|
|
377
|
+
"pipe"
|
|
378
|
+
],
|
|
379
|
+
env: {
|
|
380
|
+
...process.env,
|
|
381
|
+
...this.options.env,
|
|
382
|
+
PYTHONUNBUFFERED: "1",
|
|
383
|
+
PYTHONIOENCODING: "utf-8",
|
|
384
|
+
PYTHONDONTWRITEBYTECODE: "1",
|
|
385
|
+
PYTHONPATH: [resources, process.env.PYTHONPATH].filter(Boolean).join(process.platform === "win32" ? ";" : ":"),
|
|
386
|
+
...callbackEnv
|
|
387
|
+
}
|
|
1296
388
|
});
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
this.
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
389
|
+
this.child = child;
|
|
390
|
+
this.closePromise = new Promise((resolve$1) => child.once("close", () => resolve$1()));
|
|
391
|
+
child.on("error", (error) => this.died(`Python process error: ${error.message}`));
|
|
392
|
+
child.on("exit", (code, signal) => this.died(`Python process exited (${signal ?? code}); stderr: ${this.stderr.slice(-2048)}`));
|
|
393
|
+
child.stdin?.on("error", (error) => this.died(`Python input pipe failed: ${error.message}`));
|
|
394
|
+
child.stderr?.setEncoding("utf8").on("data", (data) => {
|
|
395
|
+
this.stderr = (this.stderr + data).slice(-8192);
|
|
396
|
+
});
|
|
397
|
+
const frames = createInterface({ input: child.stdout });
|
|
398
|
+
this.ownedReaders.push(frames);
|
|
399
|
+
frames.on("line", (line) => {
|
|
400
|
+
try {
|
|
401
|
+
this.frame(JSON.parse(line));
|
|
402
|
+
} catch {
|
|
403
|
+
this.died("Python emitted invalid NDJSON protocol");
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
if (process.platform !== "win32") {
|
|
407
|
+
const requests = createInterface({ input: child.stdio[3] });
|
|
408
|
+
this.ownedReaders.push(requests);
|
|
409
|
+
const replies = child.stdio[4];
|
|
410
|
+
replies.on("error", () => {});
|
|
411
|
+
requests.on("line", (line) => {
|
|
412
|
+
let data;
|
|
413
|
+
try {
|
|
414
|
+
data = JSON.parse(line);
|
|
415
|
+
} catch {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.callback(data).then((reply) => {
|
|
419
|
+
if (!replies.destroyed && !replies.writableEnded) replies.write(JSON.stringify(reply) + "\n");
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const result = await this.perform(KERNEL_BOOTSTRAP + `\n__import__('better_snapshot').configure(${JSON.stringify(this.options.snapshotDirectory ?? "")}, ${this.options.snapshotSizeCapBytes ?? 268435456}, ${this.options.snapshotTimeoutMs})`, {
|
|
424
|
+
timeoutMs: this.options.startupTimeoutMs,
|
|
425
|
+
interruptGraceMs: 1e3,
|
|
426
|
+
interruptConfirmMs: this.options.interruptConfirmMs
|
|
427
|
+
});
|
|
428
|
+
if (result.outcome !== "completed" || result.status !== "ok") throw new Error(`OMP Python initialization failed: ${JSON.stringify(result)}`);
|
|
429
|
+
if (this.isDead) throw new Error("Python exited during initialization");
|
|
430
|
+
this.state = "running";
|
|
1308
431
|
} catch (error) {
|
|
1309
|
-
this.
|
|
1310
|
-
this.state = "idle";
|
|
432
|
+
await this.dispose();
|
|
1311
433
|
throw error;
|
|
1312
434
|
}
|
|
1313
|
-
const bootstrap = await this.executeCell(KERNEL_BOOTSTRAP, {
|
|
1314
|
-
timeoutMs: this.options.startupTimeoutMs,
|
|
1315
|
-
interruptGraceMs: 1e3,
|
|
1316
|
-
interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
|
|
1317
|
-
});
|
|
1318
|
-
if (bootstrap.outcome !== "completed" || bootstrap.status !== "ok") {
|
|
1319
|
-
const detail = bootstrap.outcome === "completed" && bootstrap.cellError ? `${bootstrap.cellError.ename}: ${bootstrap.cellError.evalue}` : "bootstrap cell failed";
|
|
1320
|
-
this.cleanupResources();
|
|
1321
|
-
this.state = "idle";
|
|
1322
|
-
throw new Error(`dashr kernel bootstrap failed: ${detail}`);
|
|
1323
|
-
}
|
|
1324
|
-
this.state = "running";
|
|
1325
|
-
}
|
|
1326
|
-
async waitForConnection(path$1) {
|
|
1327
|
-
const deadline = Date.now() + this.options.startupTimeoutMs;
|
|
1328
|
-
while (Date.now() < deadline) {
|
|
1329
|
-
if (this.state === "shutdown") throw new Error(`kernel exited before resolving ports. stderr:\n${this.stderrTail()}`);
|
|
1330
|
-
const info = readResolvedConnection(path$1);
|
|
1331
|
-
if (info) return info;
|
|
1332
|
-
await setTimeout$1(CONNECT_POLL_MS);
|
|
1333
|
-
}
|
|
1334
|
-
throw new Error(`kernel did not resolve connection ports within ${this.options.startupTimeoutMs}ms. stderr tail:\n${this.stderrTail()}`);
|
|
1335
435
|
}
|
|
1336
|
-
async
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
if (
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
436
|
+
async callback(data) {
|
|
437
|
+
const identity = record(data) ? {
|
|
438
|
+
id: data.id,
|
|
439
|
+
run: data.run
|
|
440
|
+
} : {};
|
|
441
|
+
const active = this.active;
|
|
442
|
+
if (!record(data) || !active || active.id !== data.run || active.pendingForce || !active.opts.hostRequestHandler) return {
|
|
443
|
+
...identity,
|
|
444
|
+
ok: false,
|
|
445
|
+
message: "host callback rejected: its cell is no longer active"
|
|
446
|
+
};
|
|
447
|
+
const revoked = {
|
|
448
|
+
...identity,
|
|
449
|
+
ok: false,
|
|
450
|
+
message: "host callback cancelled: its cell ended"
|
|
451
|
+
};
|
|
452
|
+
return new Promise((resolve$1) => {
|
|
453
|
+
let settled = false;
|
|
454
|
+
const finish = (reply) => {
|
|
455
|
+
if (settled) return;
|
|
456
|
+
settled = true;
|
|
457
|
+
active.rejectCallbacks.delete(reject);
|
|
458
|
+
resolve$1(reply);
|
|
459
|
+
};
|
|
460
|
+
const reject = () => finish(revoked);
|
|
461
|
+
active.rejectCallbacks.add(reject);
|
|
462
|
+
active.opts.hostRequestHandler(data).then((reply) => {
|
|
463
|
+
finish(this.active === active && !active.pendingForce ? {
|
|
464
|
+
...identity,
|
|
465
|
+
...reply
|
|
466
|
+
} : revoked);
|
|
467
|
+
}, (error) => finish({
|
|
468
|
+
...identity,
|
|
469
|
+
ok: false,
|
|
470
|
+
message: String(error)
|
|
471
|
+
}));
|
|
472
|
+
});
|
|
1346
473
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
474
|
+
frame(frame) {
|
|
475
|
+
if (!record(frame) || frame.id !== this.active?.id) return;
|
|
476
|
+
const active = this.active;
|
|
477
|
+
if (frame.type === "started") {
|
|
478
|
+
active.started = true;
|
|
479
|
+
if (active.pendingForce) this.signal("SIGINT");
|
|
480
|
+
} else if (frame.type === "stdout" || frame.type === "stderr") {
|
|
481
|
+
if (typeof frame.data === "string") active.streamText += frame.data;
|
|
482
|
+
} else if (frame.type === "display" || frame.type === "result") {
|
|
483
|
+
if (record(frame.bundle)) {
|
|
484
|
+
const text = frame.bundle["text/plain"] ?? frame.bundle["text/markdown"] ?? frame.bundle["text/html"];
|
|
485
|
+
if (typeof text === "string") active.streamText += `${text}\n`;
|
|
486
|
+
else if (frame.bundle["application/json"] !== void 0) active.streamText += `${JSON.stringify(frame.bundle["application/json"])}\n`;
|
|
487
|
+
else active.streamText += `[Python display: ${Object.keys(frame.bundle).join(", ")}]\n`;
|
|
488
|
+
}
|
|
489
|
+
} else if (frame.type === "error") active.error = {
|
|
490
|
+
ename: String(frame.ename),
|
|
491
|
+
evalue: String(frame.evalue),
|
|
492
|
+
traceback: Array.isArray(frame.traceback) ? frame.traceback.map(String) : []
|
|
493
|
+
};
|
|
494
|
+
else if (frame.type === "done") this.settle(active.pendingForce ? {
|
|
495
|
+
outcome: "forced",
|
|
496
|
+
failure: active.pendingForce,
|
|
497
|
+
streamText: active.streamText
|
|
498
|
+
} : {
|
|
499
|
+
outcome: "completed",
|
|
500
|
+
status: frame.status === "ok" ? "ok" : "error",
|
|
501
|
+
streamText: active.streamText,
|
|
502
|
+
...active.error ? { cellError: active.error } : {}
|
|
1359
503
|
});
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
504
|
+
}
|
|
505
|
+
executeCell(code, opts) {
|
|
506
|
+
const task = this.queue.then(() => {
|
|
507
|
+
if (!this.isRunning) return {
|
|
1363
508
|
outcome: "forced",
|
|
1364
509
|
failure: {
|
|
1365
510
|
kind: "worker-exit",
|
|
1366
|
-
message: "
|
|
511
|
+
message: "Python is not running"
|
|
1367
512
|
},
|
|
1368
513
|
streamText: ""
|
|
1369
514
|
};
|
|
1370
|
-
return
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
|
|
515
|
+
return this.perform(code, opts);
|
|
516
|
+
});
|
|
517
|
+
this.queue = task.catch(() => void 0);
|
|
518
|
+
return task;
|
|
1374
519
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
if (!transport) return Promise.resolve({
|
|
520
|
+
perform(code, opts) {
|
|
521
|
+
if (opts.signal?.aborted) return Promise.resolve({
|
|
1378
522
|
outcome: "forced",
|
|
1379
523
|
failure: {
|
|
1380
|
-
kind: "
|
|
1381
|
-
message:
|
|
524
|
+
kind: "abort",
|
|
525
|
+
message: String(opts.signal.reason)
|
|
1382
526
|
},
|
|
1383
527
|
streamText: ""
|
|
1384
528
|
});
|
|
1385
|
-
const msg = buildMessage("execute_request", {
|
|
1386
|
-
code,
|
|
1387
|
-
silent: false,
|
|
1388
|
-
store_history: false,
|
|
1389
|
-
user_expressions: {},
|
|
1390
|
-
allow_stdin: false,
|
|
1391
|
-
stop_on_error: true
|
|
1392
|
-
}, this.session, this.options.username);
|
|
1393
529
|
return new Promise((resolve$1) => {
|
|
1394
|
-
const
|
|
1395
|
-
|
|
530
|
+
const active = {
|
|
531
|
+
id: randomUUID(),
|
|
1396
532
|
opts,
|
|
1397
533
|
streamText: "",
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
534
|
+
started: false,
|
|
535
|
+
resolve: resolve$1,
|
|
536
|
+
rejectCallbacks: /* @__PURE__ */ new Set()
|
|
1401
537
|
};
|
|
1402
|
-
this.
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
if (this.activeExec === execution) this.activeExec = void 0;
|
|
1411
|
-
resolve$1(outcome);
|
|
1412
|
-
};
|
|
1413
|
-
const interruptAndForce = (failure) => {
|
|
1414
|
-
if (this.activeExec !== execution) return;
|
|
1415
|
-
if (execution.pendingForce === void 0) execution.pendingForce = failure;
|
|
1416
|
-
this.sendInterrupt(execution);
|
|
1417
|
-
execution.forceTimer = setTimeout(() => {
|
|
1418
|
-
settle({
|
|
1419
|
-
outcome: "forced",
|
|
1420
|
-
failure,
|
|
1421
|
-
streamText: execution.streamText
|
|
1422
|
-
});
|
|
1423
|
-
}, opts.interruptGraceMs);
|
|
1424
|
-
execution.forceTimer.unref?.();
|
|
1425
|
-
};
|
|
1426
|
-
const onAbort = () => {
|
|
1427
|
-
interruptAndForce({
|
|
538
|
+
this.active = active;
|
|
539
|
+
active.timeout = setTimeout(() => this.interrupt(active, {
|
|
540
|
+
kind: "timeout",
|
|
541
|
+
message: `Python cell exceeded ${opts.timeoutMs}ms`
|
|
542
|
+
}), opts.timeoutMs);
|
|
543
|
+
if (opts.signal) {
|
|
544
|
+
const signal = opts.signal;
|
|
545
|
+
const abort = () => this.interrupt(active, {
|
|
1428
546
|
kind: "abort",
|
|
1429
|
-
message: String(
|
|
1430
|
-
});
|
|
1431
|
-
};
|
|
1432
|
-
execution.timeoutTimer = setTimeout(() => {
|
|
1433
|
-
interruptAndForce({
|
|
1434
|
-
kind: "timeout",
|
|
1435
|
-
message: `cell exceeded ${opts.timeoutMs}ms wall budget`
|
|
1436
|
-
});
|
|
1437
|
-
}, opts.timeoutMs);
|
|
1438
|
-
execution.timeoutTimer.unref?.();
|
|
1439
|
-
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
1440
|
-
execution.detachSignal = () => opts.signal?.removeEventListener("abort", onAbort);
|
|
1441
|
-
if (opts.signal?.aborted) onAbort();
|
|
1442
|
-
try {
|
|
1443
|
-
transport.send("shell", msg);
|
|
1444
|
-
} catch {
|
|
1445
|
-
settle({
|
|
1446
|
-
outcome: "forced",
|
|
1447
|
-
failure: {
|
|
1448
|
-
kind: "worker-exit",
|
|
1449
|
-
message: "shell channel send failed"
|
|
1450
|
-
},
|
|
1451
|
-
streamText: execution.streamText
|
|
547
|
+
message: String(signal.reason)
|
|
1452
548
|
});
|
|
549
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
550
|
+
active.detach = () => signal.removeEventListener("abort", abort);
|
|
1453
551
|
}
|
|
552
|
+
this.child?.stdin?.write(JSON.stringify({
|
|
553
|
+
id: active.id,
|
|
554
|
+
code,
|
|
555
|
+
...this.options.cwd ? { cwd: this.options.cwd } : {}
|
|
556
|
+
}) + "\n");
|
|
1454
557
|
});
|
|
1455
558
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
this.
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
outcome: "forced",
|
|
1481
|
-
failure,
|
|
1482
|
-
streamText: execution.streamText
|
|
1483
|
-
});
|
|
1484
|
-
}
|
|
1485
|
-
handleExecutionMessage(incoming) {
|
|
1486
|
-
const execution = this.activeExec;
|
|
1487
|
-
if (!execution || execution.settled) return;
|
|
1488
|
-
if (incoming.parent_header.msg_id !== execution.requestMsgId) return;
|
|
1489
|
-
const msgType = incoming.header.msg_type;
|
|
1490
|
-
if (msgType === "stream") {
|
|
1491
|
-
const content = incoming.content;
|
|
1492
|
-
if (typeof content.text === "string") execution.streamText += content.text;
|
|
1493
|
-
} else if (msgType === "error") {
|
|
1494
|
-
const content = incoming.content;
|
|
1495
|
-
execution.cellError = {
|
|
1496
|
-
ename: typeof content.ename === "string" ? content.ename : "Error",
|
|
1497
|
-
evalue: typeof content.evalue === "string" ? content.evalue : "",
|
|
1498
|
-
traceback: Array.isArray(content.traceback) ? content.traceback : []
|
|
1499
|
-
};
|
|
1500
|
-
execution.status = "error";
|
|
1501
|
-
} else if (msgType === "status") {
|
|
1502
|
-
if (incoming.content.execution_state === "idle") this.settleCompleted(execution);
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
settleCompleted(execution) {
|
|
1506
|
-
if (execution.settled) return;
|
|
1507
|
-
execution.settled = true;
|
|
1508
|
-
if (execution.forceTimer) clearTimeout(execution.forceTimer);
|
|
1509
|
-
if (execution.timeoutTimer) clearTimeout(execution.timeoutTimer);
|
|
1510
|
-
if (execution.alarmTimer) clearTimeout(execution.alarmTimer);
|
|
1511
|
-
execution.detachSignal?.();
|
|
1512
|
-
if (this.activeExec === execution) this.activeExec = void 0;
|
|
1513
|
-
if (execution.pendingForce !== void 0) {
|
|
1514
|
-
execution.resolve({
|
|
1515
|
-
outcome: "forced",
|
|
1516
|
-
failure: execution.pendingForce,
|
|
1517
|
-
streamText: execution.streamText
|
|
1518
|
-
});
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
execution.resolve({
|
|
1522
|
-
outcome: "completed",
|
|
1523
|
-
status: execution.status,
|
|
1524
|
-
streamText: execution.streamText,
|
|
1525
|
-
...execution.cellError ? { cellError: execution.cellError } : {}
|
|
1526
|
-
});
|
|
1527
|
-
}
|
|
1528
|
-
handleCommMessage(incoming) {
|
|
1529
|
-
const content = incoming.content;
|
|
1530
|
-
const commId = content.comm_id;
|
|
1531
|
-
if (typeof commId !== "string") return;
|
|
1532
|
-
const msgType = incoming.header.msg_type;
|
|
1533
|
-
if (msgType === "comm_close") {
|
|
1534
|
-
this.commTargets.delete(commId);
|
|
1535
|
-
this.servedComms.delete(commId);
|
|
1536
|
-
return;
|
|
1537
|
-
}
|
|
1538
|
-
if (msgType === "comm_open") {
|
|
1539
|
-
const targetName = content.target_name;
|
|
1540
|
-
if (typeof targetName === "string") this.commTargets.set(commId, targetName);
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
if (msgType === "comm_msg" && this.commTargets.get(commId) === HOST_COMM_TARGET) this.serveHostRequest(commId, content.data);
|
|
1544
|
-
}
|
|
1545
|
-
serveHostRequest(commId, data) {
|
|
1546
|
-
if (this.servedComms.has(commId)) return;
|
|
1547
|
-
this.servedComms.add(commId);
|
|
1548
|
-
const handler = this.activeExec?.opts.hostRequestHandler;
|
|
1549
|
-
if (!handler) {
|
|
1550
|
-
this.replyComm(commId, {
|
|
1551
|
-
status: "error",
|
|
1552
|
-
error: "no host request handler is active"
|
|
1553
|
-
}).catch(() => void 0);
|
|
1554
|
-
return;
|
|
1555
|
-
}
|
|
1556
|
-
if (!isRecord(data)) {
|
|
1557
|
-
this.replyComm(commId, {
|
|
1558
|
-
status: "error",
|
|
1559
|
-
error: "host request payload must be an object"
|
|
1560
|
-
}).catch(() => void 0);
|
|
1561
|
-
return;
|
|
1562
|
-
}
|
|
1563
|
-
(async () => {
|
|
1564
|
-
try {
|
|
1565
|
-
const outcome = await handler(data);
|
|
1566
|
-
if (outcome.ok) await this.replyComm(commId, {
|
|
1567
|
-
status: "ok",
|
|
1568
|
-
result: outcome.result
|
|
1569
|
-
});
|
|
1570
|
-
else await this.replyComm(commId, {
|
|
1571
|
-
status: "error",
|
|
1572
|
-
error: outcome.message
|
|
1573
|
-
});
|
|
1574
|
-
} catch (error) {
|
|
1575
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1576
|
-
await this.replyComm(commId, {
|
|
1577
|
-
status: "error",
|
|
1578
|
-
error: message
|
|
1579
|
-
}).catch(() => void 0);
|
|
1580
|
-
}
|
|
1581
|
-
})().catch(() => void 0);
|
|
1582
|
-
}
|
|
1583
|
-
async replyComm(commId, data) {
|
|
1584
|
-
const transport = this.transport;
|
|
1585
|
-
if (!transport) throw new Error("kernel channels are not connected");
|
|
1586
|
-
transport.send("control", buildMessage("comm_msg", {
|
|
1587
|
-
comm_id: commId,
|
|
1588
|
-
data
|
|
1589
|
-
}, this.session, this.options.username));
|
|
1590
|
-
}
|
|
1591
|
-
/**
|
|
1592
|
-
* Phase one NOW, phase two on a timer: send the control-channel interrupt,
|
|
1593
|
-
* then — after the confirm window, and only if the execution has still not
|
|
1594
|
-
* settled — escalate to SIGALRM. The zmq interrupt only lands when the cell
|
|
1595
|
-
* yields to the kernel event loop; a busy sync cell ('while True: pass')
|
|
1596
|
-
* never does, which is why the escalation exists at all. Sending both
|
|
1597
|
-
* channels in the same tick (the M1 design) made SIGALRM the FIRST signal
|
|
1598
|
-
* an idle-or-booting kernel saw on timeout/abort: its handler raised
|
|
1599
|
-
* KeyboardInterrupt at the top level and the process exited cleanly — a
|
|
1600
|
-
* deterministic kill (10/10 same-tick aborts, 8/10 at +1-2ms, 40/40 during
|
|
1601
|
-
* cold boot; dev/m2a-report.md §5.1). Two-phase escalation plus the
|
|
1602
|
-
* kernel-side busy guard closes that window while keeping the hard-abort
|
|
1603
|
-
* contract: a busy loop still breaks, one confirm window later.
|
|
1604
|
-
*/
|
|
1605
|
-
async sendInterrupt(execution) {
|
|
1606
|
-
const transport = this.transport;
|
|
1607
|
-
if (!transport) return;
|
|
559
|
+
interrupt(active, failure) {
|
|
560
|
+
if (this.active !== active || active.pendingForce) return;
|
|
561
|
+
active.pendingForce = failure;
|
|
562
|
+
for (const revoke of [...active.rejectCallbacks]) revoke();
|
|
563
|
+
if (active.started) this.signal("SIGINT");
|
|
564
|
+
active.confirm = setTimeout(() => {
|
|
565
|
+
if (this.active === active && active.started && active.pendingForce) this.signal("SIGINT");
|
|
566
|
+
}, active.opts.interruptConfirmMs);
|
|
567
|
+
active.force = setTimeout(() => {
|
|
568
|
+
this.dispose();
|
|
569
|
+
}, active.opts.interruptGraceMs);
|
|
570
|
+
}
|
|
571
|
+
settle(outcome) {
|
|
572
|
+
const active = this.active;
|
|
573
|
+
if (!active) return;
|
|
574
|
+
this.active = void 0;
|
|
575
|
+
clearTimeout(active.timeout);
|
|
576
|
+
clearTimeout(active.force);
|
|
577
|
+
clearTimeout(active.confirm);
|
|
578
|
+
active.detach?.();
|
|
579
|
+
for (const revoke of [...active.rejectCallbacks]) revoke();
|
|
580
|
+
active.resolve(outcome);
|
|
581
|
+
}
|
|
582
|
+
signal(signal) {
|
|
1608
583
|
try {
|
|
1609
|
-
|
|
584
|
+
this.child?.kill(signal);
|
|
1610
585
|
} catch {}
|
|
1611
|
-
execution.alarmTimer = setTimeout(() => {
|
|
1612
|
-
if (execution.settled) return;
|
|
1613
|
-
if (this.kernel?.exitCode === null) try {
|
|
1614
|
-
this.kernel.kill("SIGALRM");
|
|
1615
|
-
} catch {}
|
|
1616
|
-
}, execution.opts.interruptConfirmMs);
|
|
1617
|
-
execution.alarmTimer.unref?.();
|
|
1618
586
|
}
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
* envelope (skipped-for-size vs written); a failed/skipped dump NEVER
|
|
1623
|
-
* replaces a previous good snapshot on disk.
|
|
1624
|
-
* @param payloadPath - dill payload destination.
|
|
1625
|
-
* @param manifestPath - JSON manifest destination (turn, names, python
|
|
1626
|
-
* version, venv path, skills, size).
|
|
1627
|
-
* @param spec - turn counter, skills list, and the size cap.
|
|
1628
|
-
*/
|
|
1629
|
-
async snapshotState(payloadPath, manifestPath, spec) {
|
|
1630
|
-
if (!this.isRunning) return {
|
|
1631
|
-
kind: "failed",
|
|
1632
|
-
reason: "kernel is not running"
|
|
1633
|
-
};
|
|
1634
|
-
const sentinel = `__dashr_snapshot_${randomBytes(9).toString("hex")}__`;
|
|
1635
|
-
const outcome = await this.executeCell(buildSnapshotCell(payloadPath, manifestPath, spec, sentinel), {
|
|
1636
|
-
timeoutMs: this.options.snapshotTimeoutMs,
|
|
1637
|
-
interruptGraceMs: 1e3,
|
|
1638
|
-
interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
|
|
1639
|
-
});
|
|
1640
|
-
if (outcome.outcome !== "completed" || outcome.status !== "ok") return {
|
|
1641
|
-
kind: "failed",
|
|
1642
|
-
reason: "snapshot cell failed"
|
|
1643
|
-
};
|
|
1644
|
-
return this.parseSnapshotEnvelope(outcome.streamText, sentinel);
|
|
1645
|
-
}
|
|
1646
|
-
/** Parse one snapshot cell's nonce envelope into its outcome. */
|
|
1647
|
-
parseSnapshotEnvelope(streamText, sentinel) {
|
|
1648
|
-
const capture = extractStream(streamText, sentinel);
|
|
1649
|
-
if (capture.envelope === void 0) return {
|
|
1650
|
-
kind: "failed",
|
|
1651
|
-
reason: "snapshot cell produced no envelope"
|
|
1652
|
-
};
|
|
1653
|
-
let envelope;
|
|
587
|
+
killGroup() {
|
|
588
|
+
const pid = this.child?.pid;
|
|
589
|
+
if (pid === void 0) return;
|
|
1654
590
|
try {
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
kind: "failed",
|
|
1659
|
-
reason: "snapshot envelope was not valid JSON"
|
|
1660
|
-
};
|
|
1661
|
-
}
|
|
1662
|
-
if (typeof envelope !== "object" || envelope === null) return {
|
|
1663
|
-
kind: "failed",
|
|
1664
|
-
reason: "snapshot envelope was not an object"
|
|
1665
|
-
};
|
|
1666
|
-
const record = envelope;
|
|
1667
|
-
if (record.ok === true) return {
|
|
1668
|
-
kind: "ok",
|
|
1669
|
-
sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : 0,
|
|
1670
|
-
names: typeof record.names === "number" ? record.names : 0
|
|
1671
|
-
};
|
|
1672
|
-
if (record.skipped === true) return {
|
|
1673
|
-
kind: "skipped",
|
|
1674
|
-
reason: typeof record.reason === "string" ? record.reason : "size cap",
|
|
1675
|
-
...typeof record.estimateBytes === "number" ? { estimateBytes: record.estimateBytes } : {},
|
|
1676
|
-
...typeof record.sizeBytes === "number" ? { sizeBytes: record.sizeBytes } : {},
|
|
1677
|
-
capBytes: typeof record.capBytes === "number" ? record.capBytes : 0
|
|
1678
|
-
};
|
|
1679
|
-
return {
|
|
1680
|
-
kind: "failed",
|
|
1681
|
-
reason: typeof record.reason === "string" ? record.reason : "snapshot failed"
|
|
1682
|
-
};
|
|
591
|
+
if (process.platform !== "win32") process.kill(-pid, "SIGKILL");
|
|
592
|
+
else this.child?.kill("SIGKILL");
|
|
593
|
+
} catch {}
|
|
1683
594
|
}
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
* @param payloadPath - dill payload source.
|
|
1690
|
-
* @param manifestPath - JSON manifest source.
|
|
1691
|
-
*/
|
|
1692
|
-
async restoreState(payloadPath, manifestPath) {
|
|
1693
|
-
if (!this.isRunning) return {
|
|
1694
|
-
kind: "degraded",
|
|
1695
|
-
reason: "kernel is not running"
|
|
595
|
+
died(message) {
|
|
596
|
+
this.state = "shutdown";
|
|
597
|
+
this.deathFailure ??= {
|
|
598
|
+
kind: "worker-exit",
|
|
599
|
+
message
|
|
1696
600
|
};
|
|
1697
|
-
|
|
1698
|
-
|
|
601
|
+
this.killGroup();
|
|
602
|
+
this.dispose();
|
|
603
|
+
}
|
|
604
|
+
async internal(code, sentinel) {
|
|
605
|
+
const result = await this.executeCell(code, {
|
|
1699
606
|
timeoutMs: this.options.snapshotTimeoutMs,
|
|
1700
607
|
interruptGraceMs: 1e3,
|
|
1701
|
-
interruptConfirmMs:
|
|
608
|
+
interruptConfirmMs: this.options.interruptConfirmMs
|
|
1702
609
|
});
|
|
1703
|
-
if (
|
|
1704
|
-
kind: "degraded",
|
|
1705
|
-
reason: "restore cell failed"
|
|
1706
|
-
};
|
|
1707
|
-
const capture = extractStream(outcome.streamText, sentinel);
|
|
1708
|
-
if (capture.envelope === void 0) return {
|
|
1709
|
-
kind: "degraded",
|
|
1710
|
-
reason: "restore cell produced no envelope"
|
|
1711
|
-
};
|
|
1712
|
-
let envelope;
|
|
610
|
+
if (result.outcome !== "completed" || result.status !== "ok") return void 0;
|
|
1713
611
|
try {
|
|
1714
|
-
envelope = JSON.parse(
|
|
612
|
+
const envelope = JSON.parse(extractStream(result.streamText, sentinel).envelope ?? "");
|
|
613
|
+
return record(envelope) ? envelope : void 0;
|
|
1715
614
|
} catch {
|
|
1716
|
-
return
|
|
1717
|
-
kind: "degraded",
|
|
1718
|
-
reason: "restore envelope was not valid JSON"
|
|
1719
|
-
};
|
|
615
|
+
return;
|
|
1720
616
|
}
|
|
1721
|
-
if (typeof envelope !== "object" || envelope === null) return {
|
|
1722
|
-
kind: "degraded",
|
|
1723
|
-
reason: "restore envelope was not an object"
|
|
1724
|
-
};
|
|
1725
|
-
const record = envelope;
|
|
1726
|
-
if (record.ok === true) return {
|
|
1727
|
-
kind: "restored",
|
|
1728
|
-
count: typeof record.restored === "number" ? record.restored : 0
|
|
1729
|
-
};
|
|
1730
|
-
return {
|
|
1731
|
-
kind: "degraded",
|
|
1732
|
-
reason: typeof record.reason === "string" ? record.reason : "snapshot not replayable"
|
|
1733
|
-
};
|
|
1734
617
|
}
|
|
1735
|
-
/**
|
|
1736
|
-
* Read one user-namespace variable by name (or list the namespace's user
|
|
1737
|
-
* names when `name` is `null`) via an internal cell. JSON-serializable
|
|
1738
|
-
* values cross as `kind: 'json'` text; anything else falls back to `repr`
|
|
1739
|
-
* text (`kind: 'repr'`) so a non-JSON variable is still readable. A missing
|
|
1740
|
-
* name reports `kind: 'missing'` rather than failing the cell.
|
|
1741
|
-
* @param name - the exact variable name, or `null` to list namespace names.
|
|
1742
|
-
*/
|
|
1743
618
|
async queryVar(name$1) {
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
};
|
|
1748
|
-
const sentinel = `__dashr_query_${randomBytes(9).toString("hex")}__`;
|
|
1749
|
-
const outcome = await this.executeCell(buildQueryVarCell(name$1, sentinel), {
|
|
1750
|
-
timeoutMs: this.options.snapshotTimeoutMs,
|
|
1751
|
-
interruptGraceMs: 1e3,
|
|
1752
|
-
interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
|
|
1753
|
-
});
|
|
1754
|
-
if (outcome.outcome !== "completed" || outcome.status !== "ok") return {
|
|
1755
|
-
kind: "failed",
|
|
1756
|
-
reason: "query cell failed"
|
|
1757
|
-
};
|
|
1758
|
-
const capture = extractStream(outcome.streamText, sentinel);
|
|
1759
|
-
if (capture.envelope === void 0) return {
|
|
1760
|
-
kind: "failed",
|
|
1761
|
-
reason: "query cell produced no envelope"
|
|
1762
|
-
};
|
|
1763
|
-
let envelope;
|
|
1764
|
-
try {
|
|
1765
|
-
envelope = JSON.parse(capture.envelope);
|
|
1766
|
-
} catch {
|
|
1767
|
-
return {
|
|
1768
|
-
kind: "failed",
|
|
1769
|
-
reason: "query envelope was not valid JSON"
|
|
1770
|
-
};
|
|
1771
|
-
}
|
|
1772
|
-
if (!isRecord(envelope)) return {
|
|
1773
|
-
kind: "failed",
|
|
1774
|
-
reason: "query envelope was not an object"
|
|
1775
|
-
};
|
|
1776
|
-
if (envelope.ok !== true) return {
|
|
1777
|
-
kind: "failed",
|
|
1778
|
-
reason: typeof envelope.reason === "string" ? envelope.reason : "query failed"
|
|
1779
|
-
};
|
|
1780
|
-
const kind = envelope.kind;
|
|
1781
|
-
if (kind === "names") return {
|
|
619
|
+
const sentinel = `__better_query_${randomBytes(12).toString("hex")}__`;
|
|
620
|
+
const reply = await this.internal(buildQueryVarCell(name$1, sentinel), sentinel);
|
|
621
|
+
if (reply?.kind === "names" && Array.isArray(reply.names)) return {
|
|
1782
622
|
kind: "names",
|
|
1783
|
-
names:
|
|
623
|
+
names: reply.names.filter((name$2) => typeof name$2 === "string")
|
|
1784
624
|
};
|
|
1785
|
-
if (kind === "missing") return { kind: "missing" };
|
|
1786
|
-
if (kind === "json" || kind === "repr")
|
|
1787
|
-
kind,
|
|
1788
|
-
text:
|
|
1789
|
-
} : {
|
|
1790
|
-
kind: "failed",
|
|
1791
|
-
reason: "query envelope carried no text"
|
|
625
|
+
if (reply?.kind === "missing") return { kind: "missing" };
|
|
626
|
+
if ((reply?.kind === "json" || reply?.kind === "repr") && typeof reply.text === "string") return {
|
|
627
|
+
kind: reply.kind,
|
|
628
|
+
text: reply.text
|
|
1792
629
|
};
|
|
1793
630
|
return {
|
|
1794
631
|
kind: "failed",
|
|
1795
|
-
reason:
|
|
632
|
+
reason: "Python variable query failed"
|
|
1796
633
|
};
|
|
1797
634
|
}
|
|
1798
|
-
/**
|
|
1799
|
-
* Assign one pre-serialized JSON value into the user namespace under
|
|
1800
|
-
* `name` via an internal cell. The host already validated the name and the
|
|
1801
|
-
* value; the cell only decodes and binds. Uses the same busy guard and
|
|
1802
|
-
* internal-cell budget as the snapshot cells.
|
|
1803
|
-
* @param name - the validated identifier to assign under.
|
|
1804
|
-
* @param valueJson - the lossless-JSON text to decode and bind.
|
|
1805
|
-
*/
|
|
1806
635
|
async setVar(name$1, valueJson) {
|
|
1807
|
-
|
|
636
|
+
const sentinel = `__better_set_${randomBytes(12).toString("hex")}__`;
|
|
637
|
+
return (await this.internal(buildSetVarCell(name$1, valueJson, sentinel), sentinel))?.ok === true ? { kind: "ok" } : {
|
|
1808
638
|
kind: "failed",
|
|
1809
|
-
reason: "
|
|
639
|
+
reason: "Python variable assignment failed"
|
|
1810
640
|
};
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
641
|
+
}
|
|
642
|
+
async snapshotState(payloadPath, manifestPath, spec) {
|
|
643
|
+
const sentinel = `__better_save_${randomBytes(12).toString("hex")}__`;
|
|
644
|
+
const reply = await this.internal(buildSnapshotCell(payloadPath, manifestPath, spec, sentinel), sentinel);
|
|
645
|
+
if (reply?.kind === "ok" && Array.isArray(reply.names) && typeof reply.sizeBytes === "number" && typeof reply.turn === "number" && record(reply.omitted)) return {
|
|
646
|
+
kind: "ok",
|
|
647
|
+
sizeBytes: reply.sizeBytes,
|
|
648
|
+
names: reply.names,
|
|
649
|
+
omitted: reply.omitted,
|
|
650
|
+
turn: reply.turn,
|
|
651
|
+
...typeof reply.warning === "string" ? { warning: reply.warning } : {}
|
|
1820
652
|
};
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
653
|
+
if (reply?.kind === "skipped") return {
|
|
654
|
+
kind: "skipped",
|
|
655
|
+
reason: String(reply.reason),
|
|
656
|
+
capBytes: spec.sizeCapBytes
|
|
1825
657
|
};
|
|
1826
|
-
|
|
1827
|
-
try {
|
|
1828
|
-
envelope = JSON.parse(capture.envelope);
|
|
1829
|
-
} catch {
|
|
1830
|
-
return {
|
|
1831
|
-
kind: "failed",
|
|
1832
|
-
reason: "set envelope was not valid JSON"
|
|
1833
|
-
};
|
|
1834
|
-
}
|
|
1835
|
-
if (!isRecord(envelope) || envelope.ok !== true) return {
|
|
658
|
+
return {
|
|
1836
659
|
kind: "failed",
|
|
1837
|
-
reason:
|
|
660
|
+
reason: typeof reply?.reason === "string" ? reply.reason : "checkpoint helper timed out, failed, or exited; commit status could not be confirmed"
|
|
1838
661
|
};
|
|
1839
|
-
return { kind: "ok" };
|
|
1840
662
|
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
this.
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
663
|
+
async restoreState(payloadPath, manifestPath, bindings = []) {
|
|
664
|
+
const sentinel = `__better_restore_${randomBytes(12).toString("hex")}__`;
|
|
665
|
+
const reply = await this.internal(buildRestoreCell(payloadPath, manifestPath, sentinel, bindings), sentinel);
|
|
666
|
+
if (reply?.kind === "restored" && typeof reply.count === "number" && typeof reply.turn === "number" && record(reply.omitted)) return {
|
|
667
|
+
kind: "restored",
|
|
668
|
+
count: reply.count,
|
|
669
|
+
turn: reply.turn,
|
|
670
|
+
omitted: reply.omitted
|
|
671
|
+
};
|
|
672
|
+
return {
|
|
673
|
+
kind: "degraded",
|
|
674
|
+
reason: typeof reply?.reason === "string" ? reply.reason : "checkpoint restore helper failed or timed out"
|
|
675
|
+
};
|
|
1848
676
|
}
|
|
1849
|
-
|
|
1850
|
-
this.
|
|
1851
|
-
this.
|
|
1852
|
-
this.connection = void 0;
|
|
1853
|
-
try {
|
|
1854
|
-
this.kernel?.kill("SIGKILL");
|
|
1855
|
-
} catch {}
|
|
1856
|
-
this.kernel = void 0;
|
|
1857
|
-
if (this.tempDir) try {
|
|
1858
|
-
rmSync(this.tempDir, {
|
|
1859
|
-
recursive: true,
|
|
1860
|
-
force: true
|
|
1861
|
-
});
|
|
1862
|
-
} catch {}
|
|
1863
|
-
this.tempDir = void 0;
|
|
677
|
+
dispose() {
|
|
678
|
+
this.disposePromise ??= this.doDispose();
|
|
679
|
+
return this.disposePromise;
|
|
1864
680
|
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
681
|
+
async doDispose() {
|
|
682
|
+
this.state = "shutdown";
|
|
683
|
+
const child = this.child;
|
|
684
|
+
if (!child) {
|
|
685
|
+
this.callbackServer?.closeAllConnections();
|
|
686
|
+
this.callbackServer?.close();
|
|
1869
687
|
return;
|
|
1870
688
|
}
|
|
1871
|
-
this.
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
689
|
+
if (!this.active && child.exitCode === null && child.signalCode === null) {
|
|
690
|
+
child.stdin?.end(JSON.stringify({ type: "exit" }) + "\n");
|
|
691
|
+
await this.waitClose(Math.min(200, this.options.disposeTimeoutMs));
|
|
692
|
+
}
|
|
693
|
+
this.killGroup();
|
|
694
|
+
await this.waitClose(this.options.disposeTimeoutMs);
|
|
695
|
+
if (this.active) this.settle({
|
|
696
|
+
outcome: "forced",
|
|
697
|
+
failure: this.active.pendingForce ?? this.deathFailure ?? {
|
|
698
|
+
kind: "worker-exit",
|
|
699
|
+
message: "Python disposed during cell execution"
|
|
700
|
+
},
|
|
701
|
+
streamText: this.active.streamText
|
|
702
|
+
});
|
|
703
|
+
for (const reader of this.ownedReaders) reader.close();
|
|
704
|
+
for (const stream of child.stdio) stream?.destroy();
|
|
705
|
+
this.callbackServer?.closeAllConnections();
|
|
706
|
+
this.callbackServer?.close();
|
|
707
|
+
this.child = void 0;
|
|
708
|
+
}
|
|
709
|
+
async waitClose(ms) {
|
|
710
|
+
if (!this.closePromise) return;
|
|
711
|
+
await new Promise((resolve$1) => {
|
|
712
|
+
const timer = setTimeout(resolve$1, ms);
|
|
713
|
+
this.closePromise.then(() => {
|
|
1890
714
|
clearTimeout(timer);
|
|
1891
715
|
resolve$1();
|
|
1892
|
-
};
|
|
1893
|
-
kernel.once("exit", onExit);
|
|
716
|
+
});
|
|
1894
717
|
});
|
|
1895
718
|
}
|
|
1896
719
|
};
|
|
@@ -1898,15 +721,8 @@ var IpyKernelBridge = class {
|
|
|
1898
721
|
//#endregion
|
|
1899
722
|
//#region src/runtime.ts
|
|
1900
723
|
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1901
|
-
/**
|
|
1902
|
-
|
|
1903
|
-
* bootstrap helpers plus the per-run `__dashr_completion__` and
|
|
1904
|
-
* `__dashr_injected__`), refused as binding globals on top of the seam's
|
|
1905
|
-
* shared set.
|
|
1906
|
-
*/
|
|
1907
|
-
const KERNEL_OWNED_NAME = /^_+dashr/i;
|
|
1908
|
-
/** Manifest format version; a snapshot written under a different version is not replayable. */
|
|
1909
|
-
const SNAPSHOT_FORMAT = 1;
|
|
724
|
+
/** Runner internals and legacy reserved shim prefix are never injectable variables. */
|
|
725
|
+
const KERNEL_OWNED_NAME = /^(?:_+dashr|__omp_)/i;
|
|
1910
726
|
function messageOf(error) {
|
|
1911
727
|
return error instanceof Error ? error.message : String(error);
|
|
1912
728
|
}
|
|
@@ -1931,7 +747,7 @@ function keyDirectoryName(principal) {
|
|
|
1931
747
|
*/
|
|
1932
748
|
var DashrRuntime = class extends ReplRuntime {
|
|
1933
749
|
static Config = Schema.object({
|
|
1934
|
-
python: Schema.string()
|
|
750
|
+
python: Schema.string(),
|
|
1935
751
|
startupTimeoutMs: Schema.number().default(3e4),
|
|
1936
752
|
runTimeoutMs: Schema.number().default(12e4),
|
|
1937
753
|
interruptGraceMs: Schema.number().default(2e3),
|
|
@@ -1941,9 +757,6 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
1941
757
|
maxOutputBytes: Schema.number().default(67108864),
|
|
1942
758
|
snapshotDir: Schema.string(),
|
|
1943
759
|
snapshotSizeCapBytes: Schema.number().default(268435456),
|
|
1944
|
-
kernelEnvDir: Schema.string(),
|
|
1945
|
-
kernelPythonVersion: Schema.string(),
|
|
1946
|
-
kernelAutoInstall: Schema.boolean().default(true),
|
|
1947
760
|
username: Schema.string().default("dashr")
|
|
1948
761
|
});
|
|
1949
762
|
language = "python";
|
|
@@ -1953,11 +766,14 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
1953
766
|
/** One entry per session principal that has run code (lazy — never pre-seeded). */
|
|
1954
767
|
kernels = /* @__PURE__ */ new Map();
|
|
1955
768
|
disposed = false;
|
|
1956
|
-
|
|
1957
|
-
kernelEnvPromise;
|
|
769
|
+
transactions = /* @__PURE__ */ new Map();
|
|
1958
770
|
constructor(ctx, config) {
|
|
1959
771
|
super(ctx);
|
|
1960
|
-
|
|
772
|
+
const profile = ctx.get("profileContext");
|
|
773
|
+
this.config = {
|
|
774
|
+
...config,
|
|
775
|
+
snapshotDir: config.snapshotDir ?? (profile ? join(profile.home, "better-dsh", "python-repl", createHash("sha256").update(profile.dir).digest("hex")) : void 0)
|
|
776
|
+
};
|
|
1961
777
|
for (const key of [
|
|
1962
778
|
"startupTimeoutMs",
|
|
1963
779
|
"runTimeoutMs",
|
|
@@ -1997,9 +813,21 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
1997
813
|
* @param request - the program, its bindings, and the abort signal.
|
|
1998
814
|
* @returns the run's outcome per the seam contract.
|
|
1999
815
|
*/
|
|
2000
|
-
|
|
816
|
+
transaction(key, operation) {
|
|
817
|
+
const result = (this.transactions.get(key) ?? Promise.resolve()).catch(() => void 0).then(operation);
|
|
818
|
+
this.transactions.set(key, result);
|
|
819
|
+
result.finally(() => {
|
|
820
|
+
if (this.transactions.get(key) === result) this.transactions.delete(key);
|
|
821
|
+
}).catch(() => void 0);
|
|
822
|
+
return result;
|
|
823
|
+
}
|
|
824
|
+
run(request) {
|
|
825
|
+
return this.transaction(request.principal || AGENTLESS_KEY, () => this.runTransaction(request));
|
|
826
|
+
}
|
|
827
|
+
async runTransaction(request) {
|
|
2001
828
|
if (this.disposed) throw new Error("dashr-repl: run() after disposal");
|
|
2002
829
|
const bindings = this.validateBindings(request);
|
|
830
|
+
const bindingNames = request.bindings.flatMap((binding) => [binding.global, ...binding.errorClass ? [binding.errorClass.name] : []]);
|
|
2003
831
|
if (request.signal?.aborted) return {
|
|
2004
832
|
logs: [],
|
|
2005
833
|
error: {
|
|
@@ -2012,7 +840,7 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2012
840
|
const dead = this.kernels.get(key);
|
|
2013
841
|
if (dead?.bridge.isDead) {
|
|
2014
842
|
this.kernels.delete(key);
|
|
2015
|
-
if (dead.ranCells) return await this.reviveAfterDeath(key, dead);
|
|
843
|
+
if (dead.ranCells) return await this.reviveAfterDeath(key, dead, false, bindingNames);
|
|
2016
844
|
}
|
|
2017
845
|
let entry;
|
|
2018
846
|
try {
|
|
@@ -2025,8 +853,8 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2025
853
|
}
|
|
2026
854
|
if (!entry.restoreAttempted) {
|
|
2027
855
|
entry.restoreAttempted = true;
|
|
2028
|
-
const restore = await this.restoreEntry(entry);
|
|
2029
|
-
if (restore.kind === "restored") entry.pendingNotice = `namespace restored from the turn-${restore.turn} snapshot`;
|
|
856
|
+
const restore = await this.restoreEntry(entry, bindingNames);
|
|
857
|
+
if (restore.kind === "restored") entry.pendingNotice = `namespace restored from the turn-${restore.turn} snapshot${this.omissionNotice(restore.omitted)}`;
|
|
2030
858
|
else if (restore.kind === "degraded") entry.pendingNotice = `snapshot not replayable (${restore.reason}); started from an EMPTY namespace — re-create any state you need`;
|
|
2031
859
|
}
|
|
2032
860
|
const kernel = entry.bridge;
|
|
@@ -2049,17 +877,13 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2049
877
|
entry.turn += 1;
|
|
2050
878
|
} else if (outcome.outcome === "forced" && outcome.failure.kind === "worker-exit" && kernel.isDead && !this.disposed) {
|
|
2051
879
|
if (this.kernels.get(key) === entry) this.kernels.delete(key);
|
|
2052
|
-
|
|
880
|
+
const revived = await this.reviveAfterDeath(key, entry, true, bindingNames);
|
|
881
|
+
return finalizeOutput(extractStream(outcome.streamText, sentinel).logs, revived.value, revived.error, this.config.maxOutputBytes);
|
|
2053
882
|
}
|
|
2054
883
|
let result = this.assembleResult(outcome, sentinel);
|
|
2055
884
|
if (outcome.outcome === "completed") {
|
|
2056
885
|
const snapshot = await this.snapshotTurnEnd(entry);
|
|
2057
|
-
const notices = [];
|
|
2058
|
-
if (snapshot.skipped && !entry.warnedSnapshotSkip) {
|
|
2059
|
-
entry.warnedSnapshotSkip = true;
|
|
2060
|
-
const detail = snapshot.estimateBytes !== void 0 ? `estimated ${snapshot.estimateBytes} bytes` : `${snapshot.sizeBytes ?? "unknown"} bytes serialized`;
|
|
2061
|
-
notices.push(`[dashr] namespace snapshot skipped: ${detail} exceeds snapshotSizeCapBytes (${this.config.snapshotSizeCapBytes}) — later turn-end snapshots are skipped while the namespace stays this large`);
|
|
2062
|
-
}
|
|
886
|
+
const notices = snapshot ? [snapshot] : [];
|
|
2063
887
|
if (entry.pendingNotice) {
|
|
2064
888
|
notices.push(`[dashr] ${entry.pendingNotice}`);
|
|
2065
889
|
entry.pendingNotice = void 0;
|
|
@@ -2079,7 +903,10 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2079
903
|
* @param name - the variable name, or empty to list namespace names.
|
|
2080
904
|
* @param principal - the session key (absent → the shared default).
|
|
2081
905
|
*/
|
|
2082
|
-
|
|
906
|
+
queryVar(name$1, principal) {
|
|
907
|
+
return this.transaction(principal || AGENTLESS_KEY, () => this.queryTransaction(name$1, principal));
|
|
908
|
+
}
|
|
909
|
+
async queryTransaction(name$1, principal) {
|
|
2083
910
|
if (this.disposed) throw new Error("dashr-repl: queryVar() after disposal");
|
|
2084
911
|
const key = principal && principal.length > 0 ? principal : AGENTLESS_KEY;
|
|
2085
912
|
const bridge = this.kernels.get(key)?.bridge;
|
|
@@ -2115,12 +942,15 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2115
942
|
* @param value - the lossless-JSON value to bind.
|
|
2116
943
|
* @param principal - the session key (absent → the shared default).
|
|
2117
944
|
*/
|
|
2118
|
-
|
|
945
|
+
setVar(name$1, value, principal) {
|
|
946
|
+
return this.transaction(principal || AGENTLESS_KEY, () => this.setTransaction(name$1, value, principal));
|
|
947
|
+
}
|
|
948
|
+
async setTransaction(name$1, value, principal) {
|
|
2119
949
|
if (this.disposed) throw new Error("dashr-repl: setVar() after disposal");
|
|
2120
950
|
const trimmed = name$1.trim();
|
|
2121
951
|
if (trimmed.length === 0) throw new Error("dashr-repl: setVar() requires a non-empty variable name");
|
|
2122
952
|
if (!IDENTIFIER.test(trimmed)) throw new Error(`dashr-repl: setVar() name ${JSON.stringify(trimmed)} is not a usable identifier`);
|
|
2123
|
-
if (KERNEL_OWNED_NAME.test(trimmed)) throw new Error(`dashr-repl: setVar() name ${JSON.stringify(trimmed)} is reserved by the kernel`);
|
|
953
|
+
if (KERNEL_OWNED_NAME.test(trimmed) || RESERVED_BINDING_GLOBALS.has(trimmed) || trimmed === "__doc__") throw new Error(`dashr-repl: setVar() name ${JSON.stringify(trimmed)} is reserved by the kernel`);
|
|
2124
954
|
const jsonValue = snapshotJsonValue(value);
|
|
2125
955
|
if (jsonValue === void 0) throw new Error("dashr-repl: setVar() value must be lossless JSON");
|
|
2126
956
|
const key = principal && principal.length > 0 ? principal : AGENTLESS_KEY;
|
|
@@ -2128,6 +958,9 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2128
958
|
if (!bridge || !bridge.isRunning) throw new Error("dashr-repl: setVar() requires a live kernel for this session — run a cell first");
|
|
2129
959
|
const outcome = await bridge.setVar(trimmed, JSON.stringify(jsonValue));
|
|
2130
960
|
if (outcome.kind === "failed") throw new Error(`dashr-repl: setVar(${JSON.stringify(trimmed)}) failed: ${outcome.reason}`);
|
|
961
|
+
const entry = this.kernels.get(key);
|
|
962
|
+
const notice = await this.snapshotTurnEnd(entry);
|
|
963
|
+
if (notice) entry.pendingNotice = [entry.pendingNotice, notice].filter(Boolean).join("; ");
|
|
2131
964
|
}
|
|
2132
965
|
/** Map one cell outcome onto the seam's result shape under the output ledger. */
|
|
2133
966
|
assembleResult(outcome, sentinel) {
|
|
@@ -2193,8 +1026,8 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2193
1026
|
ok: false,
|
|
2194
1027
|
message: "host request must be a binding.call with global and name"
|
|
2195
1028
|
};
|
|
2196
|
-
const record = bindings.get(data.global)?.functions;
|
|
2197
|
-
const fn = record && Object.hasOwn(record, data.name) ? record[data.name] : void 0;
|
|
1029
|
+
const record$1 = bindings.get(data.global)?.functions;
|
|
1030
|
+
const fn = record$1 && Object.hasOwn(record$1, data.name) ? record$1[data.name] : void 0;
|
|
2198
1031
|
if (typeof fn !== "function") return {
|
|
2199
1032
|
ok: false,
|
|
2200
1033
|
message: `unknown binding ${JSON.stringify(`${data.global}.${data.name}`)}`
|
|
@@ -2223,23 +1056,6 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2223
1056
|
result: value
|
|
2224
1057
|
};
|
|
2225
1058
|
}
|
|
2226
|
-
/** Resolve (and provision, when managed) the kernel interpreter once per runtime. */
|
|
2227
|
-
resolveKernelPython() {
|
|
2228
|
-
this.kernelEnvPromise ??= resolveKernelEnv({
|
|
2229
|
-
python: this.config.python,
|
|
2230
|
-
venvDir: this.config.kernelEnvDir,
|
|
2231
|
-
pythonVersion: this.config.kernelPythonVersion,
|
|
2232
|
-
autoInstall: this.config.kernelAutoInstall,
|
|
2233
|
-
log: (level, message) => {
|
|
2234
|
-
if (level === "warn") this.logger.warn(message);
|
|
2235
|
-
else this.logger.info(message);
|
|
2236
|
-
}
|
|
2237
|
-
}).catch((error) => {
|
|
2238
|
-
this.kernelEnvPromise = void 0;
|
|
2239
|
-
throw error;
|
|
2240
|
-
});
|
|
2241
|
-
return this.kernelEnvPromise;
|
|
2242
|
-
}
|
|
2243
1059
|
/**
|
|
2244
1060
|
* Spawn-or-reuse the kernel for one key. The lazy map guarantees one
|
|
2245
1061
|
* entry is created here and nowhere else, so a key that never runs never
|
|
@@ -2251,150 +1067,129 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2251
1067
|
let entry = this.kernels.get(key);
|
|
2252
1068
|
if (!entry) {
|
|
2253
1069
|
const kernelCwd = cwd && existsSync(cwd) ? cwd : void 0;
|
|
1070
|
+
const environment = await resolveKernelEnv({
|
|
1071
|
+
python: this.config.python,
|
|
1072
|
+
cwd: kernelCwd
|
|
1073
|
+
});
|
|
2254
1074
|
entry = {
|
|
2255
1075
|
key,
|
|
2256
1076
|
cwd: kernelCwd,
|
|
2257
1077
|
bridge: new IpyKernelBridge({
|
|
2258
|
-
python:
|
|
1078
|
+
python: environment.python,
|
|
1079
|
+
env: environment.env,
|
|
2259
1080
|
...kernelCwd ? { cwd: kernelCwd } : {},
|
|
2260
1081
|
startupTimeoutMs: this.config.startupTimeoutMs,
|
|
2261
1082
|
disposeTimeoutMs: this.config.disposeTimeoutMs,
|
|
2262
1083
|
interruptConfirmMs: this.config.interruptConfirmMs,
|
|
2263
1084
|
snapshotTimeoutMs: this.config.snapshotTimeoutMs,
|
|
2264
|
-
username: this.config.username
|
|
1085
|
+
username: this.config.username,
|
|
1086
|
+
snapshotDirectory: this.config.snapshotDir ? join(this.config.snapshotDir, keyDirectoryName(key)) : void 0,
|
|
1087
|
+
snapshotSizeCapBytes: this.config.snapshotSizeCapBytes
|
|
2265
1088
|
}),
|
|
2266
1089
|
ranCells: false,
|
|
2267
1090
|
turn: 0
|
|
2268
1091
|
};
|
|
2269
1092
|
this.kernels.set(key, entry);
|
|
2270
1093
|
}
|
|
1094
|
+
if (this.disposed) {
|
|
1095
|
+
await entry.bridge.dispose();
|
|
1096
|
+
throw new Error("runtime disposed during startup");
|
|
1097
|
+
}
|
|
2271
1098
|
await entry.bridge.start();
|
|
2272
1099
|
return entry;
|
|
2273
1100
|
}
|
|
2274
|
-
/**
|
|
2275
|
-
|
|
2276
|
-
* exists and its manifest is plausible. Called once per entry boot. The
|
|
2277
|
-
* kernel-side restore cell performs the authoritative environment checks
|
|
2278
|
-
* (python version, interpreter identity, skills); this method only gates on
|
|
2279
|
-
* the manifest the host can read cheaply, then records the turn so the
|
|
2280
|
-
* first-run notice and the revive chain can name it.
|
|
2281
|
-
*/
|
|
2282
|
-
async restoreEntry(entry) {
|
|
1101
|
+
/** Restore once before user admission; any degraded load retires that child. */
|
|
1102
|
+
async restoreEntry(entry, bindingNames) {
|
|
2283
1103
|
if (!this.config.snapshotDir) return { kind: "none" };
|
|
2284
1104
|
const dir = join(this.config.snapshotDir, keyDirectoryName(entry.key));
|
|
2285
|
-
const payloadPath = join(dir, "state.dill");
|
|
2286
1105
|
const manifestPath = join(dir, "manifest.json");
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
if (manifest.snapshotFormat !== SNAPSHOT_FORMAT) return {
|
|
2290
|
-
kind: "degraded",
|
|
2291
|
-
reason: `snapshot format ${String(manifest.snapshotFormat)} is not replayable`
|
|
2292
|
-
};
|
|
2293
|
-
if (manifest.skipped === true) return { kind: "none" };
|
|
2294
|
-
if (!existsSync(payloadPath)) return {
|
|
2295
|
-
kind: "degraded",
|
|
2296
|
-
reason: "snapshot payload is missing"
|
|
2297
|
-
};
|
|
2298
|
-
const restored = await entry.bridge.restoreState(payloadPath, manifestPath);
|
|
1106
|
+
if (!existsSync(manifestPath)) return { kind: "none" };
|
|
1107
|
+
const restored = await entry.bridge.restoreState(join(dir, "state.dill"), manifestPath, bindingNames);
|
|
2299
1108
|
if (restored.kind === "restored") {
|
|
2300
|
-
entry.turn =
|
|
2301
|
-
entry.lastSnapshotTurn =
|
|
1109
|
+
entry.turn = restored.turn;
|
|
1110
|
+
entry.lastSnapshotTurn = restored.turn;
|
|
2302
1111
|
entry.ranCells = true;
|
|
2303
1112
|
return {
|
|
2304
1113
|
kind: "restored",
|
|
2305
|
-
turn:
|
|
1114
|
+
turn: restored.turn,
|
|
1115
|
+
omitted: restored.omitted
|
|
2306
1116
|
};
|
|
2307
1117
|
}
|
|
1118
|
+
await entry.bridge.dispose();
|
|
1119
|
+
this.kernels.delete(entry.key);
|
|
1120
|
+
entry.bridge = (await this.ensureKernel(entry.key, entry.cwd)).bridge;
|
|
1121
|
+
this.kernels.set(entry.key, entry);
|
|
2308
1122
|
return {
|
|
2309
1123
|
kind: "degraded",
|
|
2310
1124
|
reason: restored.reason
|
|
2311
1125
|
};
|
|
2312
1126
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
try {
|
|
2317
|
-
return JSON.parse(readFileSync(join(this.config.snapshotDir, keyDirectoryName(key), "manifest.json"), "utf8"));
|
|
2318
|
-
} catch {
|
|
2319
|
-
return;
|
|
2320
|
-
}
|
|
1127
|
+
omissionNotice(omitted) {
|
|
1128
|
+
const entries = Object.entries(omitted);
|
|
1129
|
+
return entries.length ? `; partial coverage, omitted ${entries.map(([name$1, reason]) => `${name$1} (${reason})`).join(", ")}` : "";
|
|
2321
1130
|
}
|
|
2322
|
-
/**
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
* return the death-observing run's explicit error naming the turn. When no
|
|
2326
|
-
* replayable snapshot exists the message falls back to M3-A's fresh-empty
|
|
2327
|
-
* contract. The run itself does NOT execute — an error that lies about a
|
|
2328
|
-
* NameError costs the model more than one dropped cell (blueprint §8.3).
|
|
2329
|
-
* @param key - the run principal.
|
|
2330
|
-
* @param dead - the entry that died (its `turn` is the last completed turn).
|
|
2331
|
-
*/
|
|
2332
|
-
async reviveAfterDeath(key, dead) {
|
|
1131
|
+
/** Restore a fresh child and report whether the observing call had started. */
|
|
1132
|
+
async reviveAfterDeath(key, dead, started, bindingNames) {
|
|
1133
|
+
const execution = started ? "This cell may have executed partially, including external side effects; inspect their state before choosing any retry. No user code was replayed." : "This observing call did not execute. No user code was replayed.";
|
|
2333
1134
|
let entry;
|
|
2334
1135
|
try {
|
|
2335
1136
|
entry = await this.ensureKernel(key, dead.cwd);
|
|
2336
1137
|
} catch (error) {
|
|
2337
1138
|
return finalizeOutput([], void 0, {
|
|
2338
1139
|
kind: "worker-exit",
|
|
2339
|
-
message: `kernel died and respawn failed (${messageOf(error)}); the next run will retry a fresh kernel`
|
|
1140
|
+
message: `kernel died and respawn failed (${messageOf(error)}); the next run will retry a fresh kernel. ${execution}`
|
|
2340
1141
|
}, this.config.maxOutputBytes);
|
|
2341
1142
|
}
|
|
2342
1143
|
entry.restoreAttempted = true;
|
|
2343
|
-
const restore = await this.restoreEntry(entry);
|
|
2344
|
-
|
|
2345
|
-
if (restore.kind === "restored") {
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
kind: "worker-exit",
|
|
2350
|
-
message: `the kernel for this session died; its namespace was restored from the turn-${restore.turn} snapshot — the last ${lost} ${rounds} of variable operations must be replayed. This run did not execute.`
|
|
2351
|
-
}, this.config.maxOutputBytes);
|
|
2352
|
-
}
|
|
1144
|
+
const restore = await this.restoreEntry(entry, bindingNames);
|
|
1145
|
+
entry.turn = Math.max(entry.turn, dead.turn);
|
|
1146
|
+
if (restore.kind === "restored") return finalizeOutput([], void 0, {
|
|
1147
|
+
kind: "worker-exit",
|
|
1148
|
+
message: `the kernel for this session died; its namespace was restored from the turn-${restore.turn} snapshot${this.omissionNotice(restore.omitted)}. Changes after that checkpoint may be lost. ${execution}`
|
|
1149
|
+
}, this.config.maxOutputBytes);
|
|
2353
1150
|
return finalizeOutput([], void 0, {
|
|
2354
1151
|
kind: "worker-exit",
|
|
2355
|
-
message: `the kernel for this session died and no
|
|
1152
|
+
message: `the kernel for this session died and no usable snapshot was available${restore.kind === "degraded" ? ` (${restore.reason})` : ""}; the next run uses an EMPTY namespace. ${execution}`
|
|
2356
1153
|
}, this.config.maxOutputBytes);
|
|
2357
1154
|
}
|
|
2358
|
-
/**
|
|
2359
|
-
* Write one key's turn-end snapshot, when a snapshot directory is
|
|
2360
|
-
* configured. A size-cap skip is reported (so the run can warn the model
|
|
2361
|
-
* once); other failures log without disturbing the run.
|
|
2362
|
-
*/
|
|
1155
|
+
/** Save completed mutations and expose partial/failed coverage within output limits. */
|
|
2363
1156
|
async snapshotTurnEnd(entry) {
|
|
2364
|
-
if (!this.config.snapshotDir
|
|
2365
|
-
const kernel = entry.bridge;
|
|
2366
|
-
if (!kernel.isRunning) return { skipped: false };
|
|
1157
|
+
if (!this.config.snapshotDir || !entry.bridge.isRunning || this.disposed) return;
|
|
2367
1158
|
const dir = join(this.config.snapshotDir, keyDirectoryName(entry.key));
|
|
2368
|
-
mkdirSync(dir, { recursive: true });
|
|
2369
1159
|
const spec = {
|
|
2370
1160
|
turn: entry.turn,
|
|
2371
1161
|
skills: [],
|
|
2372
1162
|
sizeCapBytes: this.config.snapshotSizeCapBytes
|
|
2373
1163
|
};
|
|
2374
|
-
|
|
1164
|
+
let outcome;
|
|
1165
|
+
try {
|
|
1166
|
+
mkdirSync(dir, {
|
|
1167
|
+
recursive: true,
|
|
1168
|
+
mode: 448
|
|
1169
|
+
});
|
|
1170
|
+
outcome = await entry.bridge.snapshotState(join(dir, "state.dill"), join(dir, "manifest.json"), spec);
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
outcome = {
|
|
1173
|
+
kind: "failed",
|
|
1174
|
+
reason: messageOf(error)
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
2375
1177
|
if (outcome.kind === "ok") {
|
|
2376
|
-
entry.lastSnapshotTurn =
|
|
2377
|
-
|
|
1178
|
+
entry.lastSnapshotTurn = outcome.turn;
|
|
1179
|
+
const omission = this.omissionNotice(outcome.omitted);
|
|
1180
|
+
const warning = outcome.warning ? `; ${outcome.warning}` : "";
|
|
1181
|
+
return omission || warning ? `[dashr] turn-${outcome.turn} checkpoint saved${omission}${warning}` : void 0;
|
|
2378
1182
|
}
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
...outcome.estimateBytes !== void 0 ? { estimateBytes: outcome.estimateBytes } : {},
|
|
2382
|
-
...outcome.sizeBytes !== void 0 ? { sizeBytes: outcome.sizeBytes } : {}
|
|
2383
|
-
};
|
|
2384
|
-
this.logger.warn(`turn-end snapshot for ${JSON.stringify(entry.key)} failed: ${outcome.reason}`);
|
|
2385
|
-
return { skipped: false };
|
|
1183
|
+
const latest = entry.lastSnapshotTurn === void 0 ? "no successful checkpoint available" : `latest successful checkpoint is turn-${entry.lastSnapshotTurn}`;
|
|
1184
|
+
return `[dashr] namespace snapshot ${outcome.kind === "skipped" ? "skipped" : "failed"}: ${outcome.reason}; ${latest}`;
|
|
2386
1185
|
}
|
|
2387
|
-
/**
|
|
2388
|
-
* Dispose to quiescence: snapshot every live key's namespace when a
|
|
2389
|
-
* snapshot directory is configured (each under its own per-key
|
|
2390
|
-
* subdirectory), then shut the subprocesses down. Registered as the
|
|
2391
|
-
* plugin's `ctx.effect` disposer.
|
|
2392
|
-
*/
|
|
1186
|
+
/** Cancel active work, drain admitted transactions, and reap every owned child. */
|
|
2393
1187
|
async teardown() {
|
|
2394
1188
|
this.disposed = true;
|
|
2395
|
-
|
|
1189
|
+
await Promise.all([...this.kernels.values()].map((entry) => entry.bridge.dispose()));
|
|
1190
|
+
await Promise.allSettled([...this.transactions.values()]);
|
|
1191
|
+
await Promise.all([...this.kernels.values()].map((entry) => entry.bridge.dispose()));
|
|
2396
1192
|
this.kernels.clear();
|
|
2397
|
-
await Promise.all(entries.map((entry) => this.teardownKernel(entry)));
|
|
2398
1193
|
}
|
|
2399
1194
|
/**
|
|
2400
1195
|
* Tear one session's kernel down (session end via the `agent/disposed`
|
|
@@ -2402,15 +1197,17 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2402
1197
|
* which is what makes hearing every agent's disposal safe.
|
|
2403
1198
|
*/
|
|
2404
1199
|
async destroyKey(key) {
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
1200
|
+
await this.kernels.get(key)?.bridge.dispose();
|
|
1201
|
+
await this.transaction(key, async () => {
|
|
1202
|
+
const entry = this.kernels.get(key);
|
|
1203
|
+
this.kernels.delete(key);
|
|
1204
|
+
await entry?.bridge.dispose();
|
|
1205
|
+
});
|
|
2409
1206
|
}
|
|
2410
1207
|
/**
|
|
2411
1208
|
* Reset one session's kernel to a fresh, empty namespace: dispose the live
|
|
2412
1209
|
* subprocess WITHOUT a turn-end snapshot and clear its on-disk snapshot, so
|
|
2413
|
-
* the next ensureKernel spawns empty (restore finds
|
|
1210
|
+
* the next ensureKernel spawns empty (restore finds no checkpoint).
|
|
2414
1211
|
*/
|
|
2415
1212
|
async resetKey(key) {
|
|
2416
1213
|
const entry = this.kernels.get(key);
|
|
@@ -2423,24 +1220,6 @@ var DashrRuntime = class extends ReplRuntime {
|
|
|
2423
1220
|
force: true
|
|
2424
1221
|
});
|
|
2425
1222
|
}
|
|
2426
|
-
/** Snapshot (when configured) then dispose one key's kernel; failures log, never throw into a listener. */
|
|
2427
|
-
async teardownKernel(entry) {
|
|
2428
|
-
const kernel = entry.bridge;
|
|
2429
|
-
if (this.config.snapshotDir && kernel.isRunning) try {
|
|
2430
|
-
const dir = join(this.config.snapshotDir, keyDirectoryName(entry.key));
|
|
2431
|
-
mkdirSync(dir, { recursive: true });
|
|
2432
|
-
const spec = {
|
|
2433
|
-
turn: entry.turn,
|
|
2434
|
-
skills: [],
|
|
2435
|
-
sizeCapBytes: this.config.snapshotSizeCapBytes
|
|
2436
|
-
};
|
|
2437
|
-
const saved = await kernel.snapshotState(join(dir, "state.dill"), join(dir, "manifest.json"), spec);
|
|
2438
|
-
if (saved.kind !== "ok" && saved.kind !== "skipped") this.logger.warn(`kernel namespace snapshot for ${JSON.stringify(entry.key)} failed: ${saved.reason}`);
|
|
2439
|
-
} catch (error) {
|
|
2440
|
-
this.logger.warn(`kernel namespace snapshot for ${JSON.stringify(entry.key)} failed: ${messageOf(error)}`);
|
|
2441
|
-
}
|
|
2442
|
-
await kernel.dispose();
|
|
2443
|
-
}
|
|
2444
1223
|
};
|
|
2445
1224
|
|
|
2446
1225
|
//#endregion
|
|
@@ -3389,7 +2168,7 @@ function createRunCellTool(registry, options) {
|
|
|
3389
2168
|
},
|
|
3390
2169
|
reset: {
|
|
3391
2170
|
type: "boolean",
|
|
3392
|
-
description: "Optional: reset the persistent pad namespace to EMPTY before running — variables, imports, and definitions from earlier cells are discarded and the pad restarts fresh. Omit to keep the namespace."
|
|
2171
|
+
description: "Optional: reset the persistent pad namespace to EMPTY before running and delete its saved checkpoints — variables, imports, and definitions from earlier cells are discarded and the pad restarts fresh. Omit to keep the namespace."
|
|
3393
2172
|
}
|
|
3394
2173
|
},
|
|
3395
2174
|
output: {
|
|
@@ -3701,10 +2480,7 @@ const RUNTIME_CONFIG_KEYS = [
|
|
|
3701
2480
|
"maxOutputBytes",
|
|
3702
2481
|
"snapshotDir",
|
|
3703
2482
|
"snapshotSizeCapBytes",
|
|
3704
|
-
"username"
|
|
3705
|
-
"kernelEnvDir",
|
|
3706
|
-
"kernelPythonVersion",
|
|
3707
|
-
"kernelAutoInstall"
|
|
2483
|
+
"username"
|
|
3708
2484
|
];
|
|
3709
2485
|
/** The runtime slice of the merged config — the keys `DashrRuntime` owns. */
|
|
3710
2486
|
function pickRuntimeConfig(config) {
|
|
@@ -3716,20 +2492,6 @@ function apply(ctx, config) {
|
|
|
3716
2492
|
ctx.plugin(DashrRuntime, pickRuntimeConfig(config));
|
|
3717
2493
|
const logger = ctx.logger("dashr-repl");
|
|
3718
2494
|
const maxParallel = resolveMaxParallelSubCalls(config.maxParallelSubCalls);
|
|
3719
|
-
{
|
|
3720
|
-
const kernelConfig = pickRuntimeConfig(config);
|
|
3721
|
-
resolveKernelEnv({
|
|
3722
|
-
python: kernelConfig.python,
|
|
3723
|
-
venvDir: kernelConfig.kernelEnvDir,
|
|
3724
|
-
pythonVersion: kernelConfig.kernelPythonVersion,
|
|
3725
|
-
autoInstall: kernelConfig.kernelAutoInstall ?? true,
|
|
3726
|
-
log: (level, message) => {
|
|
3727
|
-
logger[level](message);
|
|
3728
|
-
}
|
|
3729
|
-
}).catch((error) => {
|
|
3730
|
-
logger.error(`dashr-repl: spin-up kernel check failed (non-blocking): ${error instanceof Error ? error.message : String(error)}`);
|
|
3731
|
-
});
|
|
3732
|
-
}
|
|
3733
2495
|
ctx.inject(["replRuntime"], (runtimeCtx) => {
|
|
3734
2496
|
const requireRuntime = () => {
|
|
3735
2497
|
const runtime = runtimeCtx.get("replRuntime");
|