pi-repl-py 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.py +495 -0
- package/docs/ARCHITECTURE.md +71 -51
- package/index.ts +56 -44
- package/package.json +2 -1
- package/scripts/setup-venv.mjs +22 -9
- package/src/engine/index.ts +55 -136
- package/src/engine/kernel.ts +272 -496
- package/src/extension/prompt.ts +12 -17
- package/src/extension/session-engine.ts +1 -3
- package/src/engine/session.ts +0 -146
- package/src/engine/zmtp.ts +0 -239
- package/src/extension/tool-meta.ts +0 -8
package/src/engine/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// --- EngineManager: venv resolution, spawn, queue, snapshots, abort grace, teardown; the wire lives in kernel.ts ---
|
|
2
2
|
|
|
3
|
-
import { existsSync,
|
|
3
|
+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
-
import { type HelperLoadResult, KernelClient
|
|
6
|
+
import { type HelperLoadResult, KernelClient } from "./kernel.js";
|
|
7
7
|
|
|
8
8
|
export type { HelperLoadResult } from "./kernel.js";
|
|
9
9
|
|
|
@@ -18,19 +18,15 @@ function resolvePythonPath(_cwd: string | undefined): string {
|
|
|
18
18
|
return process.env.PYTHON ?? "python3";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const DEFAULT_MAX_OUTPUT_CHARS = 46080;
|
|
21
|
+
export const DEFAULT_MAX_OUTPUT_CHARS = 46080;
|
|
22
22
|
/** Per-line cap: one giant line must not own the channel budget while long JSON/reprs/errors still pass whole. */
|
|
23
23
|
export const MAX_OUTPUT_LINE_CHARS = 4096;
|
|
24
24
|
const ABORT_GRACE_MS = 20_000;
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
/** Guard: a periodic refresh stands down for already-heavy namespaces — they re-arm on name churn anyway. */
|
|
28
|
-
export const FORCED_SNAPSHOT_MAX_BYTES = 8 * 1024 * 1024;
|
|
25
|
+
/** Shared deadline: bounds a wedged boot (lifecycle) and a wedged background restore alike. */
|
|
26
|
+
export const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
|
|
29
27
|
/** Snapshot size cap, also per-entry; oversized bindings are reported as skipped names. */
|
|
30
28
|
const DEFAULT_SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024;
|
|
31
|
-
const
|
|
32
|
-
/** Restore-cell deadline: a poisoned pickle would wedge the kernel's single queue forever — kill and mark skipped. */
|
|
33
|
-
const DEFAULT_RESTORE_DEADLINE_MS = 90_000;
|
|
29
|
+
const DEFAULT_SNAPSHOT_PERIOD_MS = 120_000;
|
|
34
30
|
|
|
35
31
|
interface EngineExecuteError {
|
|
36
32
|
name: string;
|
|
@@ -72,7 +68,6 @@ export interface EngineOptions {
|
|
|
72
68
|
env?: Record<string, string>;
|
|
73
69
|
snapshot?: {
|
|
74
70
|
path: string;
|
|
75
|
-
debounceMs?: number;
|
|
76
71
|
/** Total base64 payload cap; also the per-entry cap. Oversized entries are skipped with a reason. Default 128 MiB. */
|
|
77
72
|
maxBytes?: number;
|
|
78
73
|
/** Force a refresh when the last persisted snapshot is older than this, even if no name changed. 0 disables. Default 2 min. */
|
|
@@ -181,16 +176,10 @@ export class EngineManager {
|
|
|
181
176
|
private state: "idle" | "starting" | "running" | "shutdown" = "idle";
|
|
182
177
|
private startPromise?: Promise<void>;
|
|
183
178
|
private executionQueue: Promise<unknown> = Promise.resolve();
|
|
184
|
-
private snapshotTimer?: ReturnType<typeof setTimeout>;
|
|
185
|
-
/** In-flight user cells; the debounced snapshot never cuts in front of one. */
|
|
186
|
-
private inFlightCells = 0;
|
|
187
|
-
/** Last-seen namespace names; the snapshot is gated on this set changing. */
|
|
188
|
-
private lastNamespaceNames?: string[];
|
|
189
179
|
private pythonPath?: string;
|
|
190
180
|
private restoredKernel?: KernelClient;
|
|
191
181
|
/** A wedged revive marks the engine: later kernels boot without restoring. */
|
|
192
182
|
private restoreSkipped: boolean;
|
|
193
|
-
private restoreTimer?: ReturnType<typeof setTimeout>;
|
|
194
183
|
private restoreResolve?: (result: RestoreResult | null) => void;
|
|
195
184
|
private restorePromise?: Promise<RestoreResult | null>;
|
|
196
185
|
private restoreSettledResult?: RestoreResult | null;
|
|
@@ -198,10 +187,6 @@ export class EngineManager {
|
|
|
198
187
|
/** Whether the current boot's report has been handed out (once per boot). */
|
|
199
188
|
private helperReportTaken = true;
|
|
200
189
|
private readonly forkInherited: boolean;
|
|
201
|
-
/** When the last snapshot was persisted; 0 = never. Drives the periodic refresh. */
|
|
202
|
-
private lastPersistedAt = 0;
|
|
203
|
-
/** Payload bytes of the last persisted snapshot; the periodic refresh stands down above FORCED_SNAPSHOT_MAX_BYTES. */
|
|
204
|
-
private lastSnapshotBytes = 0;
|
|
205
190
|
|
|
206
191
|
constructor(options: EngineOptions = {}) {
|
|
207
192
|
this.options = options;
|
|
@@ -253,10 +238,18 @@ export class EngineManager {
|
|
|
253
238
|
this.pythonPath = resolvePythonPath(this.options.cwd);
|
|
254
239
|
const timeoutMs = Number(process.env.PI_REPL_TIMEOUT_MS ?? this.options.env?.PI_REPL_TIMEOUT_MS ?? 0) || 0;
|
|
255
240
|
try {
|
|
241
|
+
const snap = this.options.snapshot;
|
|
256
242
|
this.kernel = await KernelClient.start(this.pythonPath, {
|
|
257
243
|
cwd: this.options.cwd,
|
|
258
244
|
env: this.options.env,
|
|
259
245
|
timeoutMs,
|
|
246
|
+
snapshot: snap
|
|
247
|
+
? {
|
|
248
|
+
path: snap.path,
|
|
249
|
+
max_bytes: snap.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES,
|
|
250
|
+
period_ms: snap.periodMs ?? DEFAULT_SNAPSHOT_PERIOD_MS,
|
|
251
|
+
}
|
|
252
|
+
: undefined,
|
|
260
253
|
});
|
|
261
254
|
// --- a fresh boot's helper verdicts are announced once, on the first cell after it ---
|
|
262
255
|
this.helperReport = this.kernel.helperReport;
|
|
@@ -267,7 +260,6 @@ export class EngineManager {
|
|
|
267
260
|
if (this.kernel !== current) return;
|
|
268
261
|
this.kernel = undefined;
|
|
269
262
|
this.startPromise = undefined;
|
|
270
|
-
this.lastNamespaceNames = undefined;
|
|
271
263
|
this.helperReport = null;
|
|
272
264
|
});
|
|
273
265
|
} catch (error) {
|
|
@@ -282,13 +274,12 @@ export class EngineManager {
|
|
|
282
274
|
throw new Error("Engine has been shut down");
|
|
283
275
|
}
|
|
284
276
|
this.state = "running";
|
|
285
|
-
// --- recovery runs in the first quiet gap — never ahead of a user cell
|
|
286
|
-
this.
|
|
277
|
+
// --- recovery runs in the bridge's first quiet gap — never ahead of a user cell ---
|
|
278
|
+
this.restoreInBackground();
|
|
287
279
|
}
|
|
288
280
|
|
|
289
281
|
/** Abrupt teardown: SIGKILL the kernel; safe from process.on("exit"). */
|
|
290
282
|
killSync(): void {
|
|
291
|
-
this.clearSnapshotTimer();
|
|
292
283
|
this.state = "shutdown";
|
|
293
284
|
liveEngines.delete(this);
|
|
294
285
|
this.kernel?.kill();
|
|
@@ -361,15 +352,12 @@ export class EngineManager {
|
|
|
361
352
|
};
|
|
362
353
|
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
363
354
|
|
|
364
|
-
this.inFlightCells++;
|
|
365
355
|
try {
|
|
366
356
|
const r = await this.kernel!.executeCell(code, {
|
|
367
357
|
signal: opts.signal,
|
|
368
358
|
onStream: opts.onStream,
|
|
369
359
|
maxOutputChars: maxChars,
|
|
370
360
|
});
|
|
371
|
-
// --- names gate runs off the critical path so the next cell enqueues before it ---
|
|
372
|
-
if (r.status === "ok") setImmediate(() => void this.scheduleSnapshotIfChanged());
|
|
373
361
|
const status: ExecuteResult["status"] = opts.signal?.aborted ? "aborted" : r.status;
|
|
374
362
|
// Channel cap (truncateWithMarker), then per-line cap; both append a marker so truncation is explicit.
|
|
375
363
|
const finalize = (text: string, channelTruncated: boolean): string => {
|
|
@@ -395,7 +383,6 @@ export class EngineManager {
|
|
|
395
383
|
} finally {
|
|
396
384
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
397
385
|
if (graceTimer) clearTimeout(graceTimer);
|
|
398
|
-
this.inFlightCells--;
|
|
399
386
|
}
|
|
400
387
|
} finally {
|
|
401
388
|
release();
|
|
@@ -406,29 +393,17 @@ export class EngineManager {
|
|
|
406
393
|
const config = this.options.snapshot;
|
|
407
394
|
if (!config || this.state !== "running" || !this.kernel) return null;
|
|
408
395
|
try {
|
|
409
|
-
const reply = await this.kernel.snapshot(config.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES);
|
|
396
|
+
const reply = await this.kernel.snapshot(config.path, config.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES);
|
|
410
397
|
// --- an incomplete snapshot must not overwrite the last good file ---
|
|
411
398
|
if (reply.complete === false) return null;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
writeFileSync(tmp, JSON.stringify({ version: 3, entries: reply.entries, failed: reply.failed }));
|
|
416
|
-
renameSync(tmp, config.path);
|
|
417
|
-
this.lastPersistedAt = Date.now();
|
|
418
|
-
this.lastSnapshotBytes = reply.entries.reduce((n, e) => n + e.payload.length, 0);
|
|
419
|
-
await this.advanceSnapshotGate();
|
|
420
|
-
return { path: config.path, saved: reply.entries.map((e) => e.name), failed: reply.failed };
|
|
399
|
+
// --- the bridge wrote the file atomically; names and counts only cross the pipe ---
|
|
400
|
+
const saved = reply.saved ?? reply.entries?.map((e) => e.name) ?? [];
|
|
401
|
+
return { path: config.path, saved, failed: reply.failed };
|
|
421
402
|
} catch {
|
|
422
403
|
return null;
|
|
423
404
|
}
|
|
424
405
|
}
|
|
425
406
|
|
|
426
|
-
/** On success, advance the name-diff gate to today's names — a failed write never blocks the retry. */
|
|
427
|
-
private async advanceSnapshotGate(): Promise<void> {
|
|
428
|
-
const names = await this.listNamespaceNames();
|
|
429
|
-
if (names !== null && names.length > 0) this.lastNamespaceNames = [...names].sort();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
407
|
/** Restore outcome (never rejects); the background quiet-gap job — this promise is the lifecycle's only announce hook. */
|
|
433
408
|
restoreResult(): Promise<RestoreResult | null> {
|
|
434
409
|
if (this.restoreSettledResult !== undefined) return Promise.resolve(this.restoreSettledResult);
|
|
@@ -452,36 +427,46 @@ export class EngineManager {
|
|
|
452
427
|
this.restoreResolve = undefined;
|
|
453
428
|
}
|
|
454
429
|
|
|
455
|
-
/** Background revive:
|
|
456
|
-
|
|
430
|
+
/** Background revive: the bridge runs it at its first quiet gap (never ahead of a user cell);
|
|
431
|
+
* mid-session rebuilds force it synchronously instead. */
|
|
432
|
+
private restoreInBackground(): void {
|
|
457
433
|
const config = this.options.snapshot;
|
|
458
|
-
|
|
459
|
-
if (this.restoreSkipped) {
|
|
434
|
+
const kernel = this.kernel;
|
|
435
|
+
if (!config || !kernel || this.restoreSkipped) {
|
|
460
436
|
this.settleRestore(null);
|
|
461
437
|
return;
|
|
462
438
|
}
|
|
463
|
-
if (
|
|
439
|
+
if (kernel === this.restoredKernel) return; // this kernel already revived
|
|
464
440
|
if (!existsSync(config.path)) {
|
|
465
441
|
this.settleRestore(null);
|
|
466
442
|
return;
|
|
467
443
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
444
|
+
this.restoredKernel = kernel;
|
|
445
|
+
// a poisoned pickle would wedge the bridge's single loop forever — the boot deadline
|
|
446
|
+
// kills and marks the revive skipped instead
|
|
447
|
+
const deadlineMs =
|
|
448
|
+
Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? this.options.env?.PI_REPL_BOOT_TIMEOUT_MS ?? 0) ||
|
|
449
|
+
DEFAULT_BOOT_TIMEOUT_MS;
|
|
450
|
+
const reaper = setTimeout(() => {
|
|
451
|
+
this.restoreSkipped = true;
|
|
452
|
+
this.settleRestore(null);
|
|
453
|
+
this.kernel?.kill();
|
|
454
|
+
}, deadlineMs);
|
|
455
|
+
reaper.unref?.();
|
|
456
|
+
kernel
|
|
457
|
+
.restore(config.path, true)
|
|
458
|
+
.then((reply) => {
|
|
459
|
+
clearTimeout(reaper);
|
|
460
|
+
const result: RestoreResult = { path: config.path, restored: reply.restored, failed: reply.failed };
|
|
461
|
+
this.settleRestore(result);
|
|
462
|
+
})
|
|
463
|
+
.catch(() => {
|
|
464
|
+
clearTimeout(reaper);
|
|
465
|
+
this.settleRestore(null);
|
|
466
|
+
});
|
|
482
467
|
}
|
|
483
468
|
|
|
484
|
-
/** Restore-cell
|
|
469
|
+
/** Restore-cell deadline: a poisoned pickle would wedge the bridge's single loop forever — kill, skip, and rebuild honestly. */
|
|
485
470
|
private async restoreWithReap(): Promise<RestoreResult | null> {
|
|
486
471
|
const config = this.options.snapshot;
|
|
487
472
|
if (!config || !this.kernel || this.restoreSkipped) {
|
|
@@ -491,7 +476,7 @@ export class EngineManager {
|
|
|
491
476
|
if (this.kernel === this.restoredKernel) return this.restoreResult();
|
|
492
477
|
const deadlineMs =
|
|
493
478
|
Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? this.options.env?.PI_REPL_BOOT_TIMEOUT_MS ?? 0) ||
|
|
494
|
-
|
|
479
|
+
DEFAULT_BOOT_TIMEOUT_MS;
|
|
495
480
|
const reaper = setTimeout(() => {
|
|
496
481
|
this.restoreSkipped = true;
|
|
497
482
|
this.settleRestore(null);
|
|
@@ -505,10 +490,6 @@ export class EngineManager {
|
|
|
505
490
|
}
|
|
506
491
|
}
|
|
507
492
|
|
|
508
|
-
private async runRestore(): Promise<void> {
|
|
509
|
-
await this.restoreWithReap();
|
|
510
|
-
}
|
|
511
|
-
|
|
512
493
|
/** Restore, idempotent per kernel: a second call shares the in-flight outcome. */
|
|
513
494
|
async restoreState(skip = false): Promise<RestoreResult | null> {
|
|
514
495
|
// --- start unconditionally: direct callers may not have started, and a wedged boot is only visible mid-attempt ---
|
|
@@ -526,33 +507,16 @@ export class EngineManager {
|
|
|
526
507
|
if (kernel === this.restoredKernel) {
|
|
527
508
|
return this.restoreResult();
|
|
528
509
|
}
|
|
529
|
-
// claim the kernel now so
|
|
510
|
+
// claim the kernel now so a background revive cannot start a second restore cell
|
|
530
511
|
this.restoredKernel = kernel;
|
|
531
512
|
if (!existsSync(config.path)) {
|
|
532
513
|
this.settleRestore(null);
|
|
533
514
|
return null;
|
|
534
515
|
}
|
|
535
516
|
try {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
vars?: Record<string, string>;
|
|
540
|
-
failed?: { name: string; reason: string }[];
|
|
541
|
-
};
|
|
542
|
-
// --- v1 files (pre-source-capture) restore via plain pickles; v3 value entries are zlib-compressed ---
|
|
543
|
-
const entries: SnapshotEntry[] =
|
|
544
|
-
payload.version !== undefined && payload.version >= 2
|
|
545
|
-
? (payload.entries ?? [])
|
|
546
|
-
: Object.entries(payload.vars ?? {}).map(([name, b64]) => ({ name, kind: "value", payload: b64 }));
|
|
547
|
-
const reply = await kernel.restore(entries, payload.version === 3);
|
|
548
|
-
// --- merge save-time skips (oversized bindings) into the result so the resume notice names every loss ---
|
|
549
|
-
const failed = [...(payload.failed ?? [])];
|
|
550
|
-
const seen = new Set(failed.map((f) => f.name));
|
|
551
|
-
for (const f of reply.failed) {
|
|
552
|
-
if (!seen.has(f.name)) failed.push(f);
|
|
553
|
-
seen.add(f.name);
|
|
554
|
-
}
|
|
555
|
-
const result: RestoreResult = { path: config.path, restored: reply.restored, failed };
|
|
517
|
+
// v1/v2/v3 dispatch, un-pickling, and the save-time failure merge all live in the bridge
|
|
518
|
+
const reply = await kernel.restore(config.path);
|
|
519
|
+
const result: RestoreResult = { path: config.path, restored: reply.restored, failed: reply.failed };
|
|
556
520
|
this.settleRestore(result);
|
|
557
521
|
return result;
|
|
558
522
|
} catch {
|
|
@@ -574,49 +538,4 @@ export class EngineManager {
|
|
|
574
538
|
return null;
|
|
575
539
|
}
|
|
576
540
|
}
|
|
577
|
-
|
|
578
|
-
/** Snapshot on name change, or when the last persisted snapshot went stale (periodMs) — same-name mutations would otherwise never re-arm; a failed write leaves the gate in place. */
|
|
579
|
-
private async scheduleSnapshotIfChanged(): Promise<void> {
|
|
580
|
-
const config = this.options.snapshot;
|
|
581
|
-
if (!config) return;
|
|
582
|
-
const names = await this.listNamespaceNames();
|
|
583
|
-
if (names === null || names.length === 0) return;
|
|
584
|
-
const key = [...names].sort().join(",");
|
|
585
|
-
const prev = this.lastNamespaceNames ? [...this.lastNamespaceNames].sort().join(",") : undefined;
|
|
586
|
-
const changed = prev === undefined || prev !== key;
|
|
587
|
-
const periodMs = config.periodMs ?? DEFAULT_SNAPSHOT_PERIOD_MS;
|
|
588
|
-
const stale =
|
|
589
|
-
periodMs > 0 &&
|
|
590
|
-
this.lastPersistedAt > 0 &&
|
|
591
|
-
Date.now() - this.lastPersistedAt >= periodMs &&
|
|
592
|
-
this.lastSnapshotBytes <= FORCED_SNAPSHOT_MAX_BYTES;
|
|
593
|
-
if (!changed && !stale) return;
|
|
594
|
-
this.scheduleSnapshot();
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
private scheduleSnapshot(): void {
|
|
598
|
-
const config = this.options.snapshot;
|
|
599
|
-
if (!config) return;
|
|
600
|
-
this.clearSnapshotTimer();
|
|
601
|
-
const quiet = config.debounceMs ?? DEFAULT_SNAPSHOT_DEBOUNCE_MS;
|
|
602
|
-
const fire = () => {
|
|
603
|
-
this.snapshotTimer = undefined;
|
|
604
|
-
if (this.inFlightCells > 0) {
|
|
605
|
-
// --- pickling must not queue ahead of the user's next request; re-arm the quiet window until the kernel is idle ---
|
|
606
|
-
this.snapshotTimer = setTimeout(fire, quiet);
|
|
607
|
-
this.snapshotTimer.unref?.();
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
void this.snapshotState();
|
|
611
|
-
};
|
|
612
|
-
this.snapshotTimer = setTimeout(fire, quiet);
|
|
613
|
-
this.snapshotTimer.unref?.();
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
private clearSnapshotTimer(): void {
|
|
617
|
-
if (this.snapshotTimer) {
|
|
618
|
-
clearTimeout(this.snapshotTimer);
|
|
619
|
-
this.snapshotTimer = undefined;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
541
|
}
|