pi-repl-py 0.6.14 → 0.7.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.
@@ -1,54 +1,46 @@
1
- // --- EngineManager: the host half of pi-repl's evaluator, driving a real ipykernel over ---
2
- // --- ZMTP directly (no guest.py middleman). Owns venv resolution, spawn, queue, ---
3
- // --- snapshots, abort grace, and teardown — the wire lives in kernel.ts. ---
1
+ // --- EngineManager: venv resolution, spawn, queue, snapshots, abort grace, teardown; the wire lives in kernel.ts ---
4
2
 
5
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
4
  import { homedir } from "node:os";
7
5
  import { dirname, join } from "node:path";
8
- import { KernelClient, type SnapshotEntry } from "./kernel.js";
6
+ import { type HelperLoadResult, KernelClient, type SnapshotEntry } from "./kernel.js";
7
+
8
+ export type { HelperLoadResult } from "./kernel.js";
9
9
 
10
10
  function installVenvPython(): string {
11
11
  return join(homedir(), ".pi", "agent", "pi-repl", "venv", "bin", "python3");
12
12
  }
13
13
 
14
- /** Prefer a venv with ipykernel; else $PYTHON or python3. */
15
14
  function resolvePythonPath(_cwd: string | undefined): string {
16
- // Only ever use the install venv: a project or repo `.venv` may lack ipykernel and
17
- // shadow the good environment, killing the kernel. No auto-picking.
15
+ // --- only the install venv: a repo `.venv` may lack ipykernel and would shadow the good one ---
18
16
  const installVenv = installVenvPython();
19
17
  if (existsSync(installVenv)) return installVenv;
20
18
  return process.env.PYTHON ?? "python3";
21
19
  }
22
20
 
23
21
  const DEFAULT_MAX_OUTPUT_CHARS = 46080;
24
- /** Per-line cap: one genuinely oversized line must not own the channel budget, while legitimately long
25
- * REPL output (JSON, reprs, errors) still fits under the cap in one piece. Generous enough that only
26
- * pathological giant lines are trimmed, unlike pi's grep where the line cap keeps matches terse. */
22
+ /** Per-line cap: one giant line must not own the channel budget while long JSON/reprs/errors still pass whole. */
27
23
  export const MAX_OUTPUT_LINE_CHARS = 4096;
28
24
  const ABORT_GRACE_MS = 20_000;
29
25
  const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500;
30
- /** Total snapshot size cap (base64 payload). Per-entry entries are capped at the same
31
- * bound; larger bindings are reported as skipped names. Mirrors the pi-codex scheme. */
26
+ const DEFAULT_SNAPSHOT_PERIOD_MS = 120_000;
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;
29
+ /** Snapshot size cap, also per-entry; oversized bindings are reported as skipped names. */
32
30
  const DEFAULT_SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024;
33
- /** Quiet-gap window before the background restore fires after boot; never ahead of a user cell. */
34
31
  const RESTORE_QUIET_MS = 250;
35
- /** Deadline for one restore cell (a poisoned pickle can wedge the kernel's single queue for
36
- * ever). The reaper kills the kernel and marks the restore skipped so the next call rebuilds
37
- * honestly. Mirrors the boot deadline in the lifecycle; also settable per engine via env. */
32
+ /** Restore-cell deadline: a poisoned pickle would wedge the kernel's single queue forever — kill and mark skipped. */
38
33
  const DEFAULT_RESTORE_DEADLINE_MS = 90_000;
39
34
 
40
35
  interface EngineExecuteError {
41
- /** Error class name, e.g. "TypeError". */
42
36
  name: string;
43
37
  message: string;
44
- /** Stack trace, split into lines. */
45
38
  stack: string[];
46
39
  }
47
40
 
48
41
  export interface ExecuteResult {
49
42
  stdout: string;
50
43
  stderr: string;
51
- /** Rendered value of the cell's final expression, when it has one. */
52
44
  result?: string;
53
45
  status: "ok" | "error" | "aborted";
54
46
  error?: EngineExecuteError;
@@ -59,13 +51,11 @@ export interface ExecuteOptions {
59
51
  /** Aborting cancels the cell via kernel interrupt; the namespace is preserved. */
60
52
  signal?: AbortSignal;
61
53
  onStream?: (chunk: string, name: "stdout" | "stderr") => void;
62
- /** Cap stdout / stderr / result at this many characters. Default 45K. */
63
54
  maxOutputChars?: number;
64
55
  }
65
56
 
66
57
  export interface SnapshotResult {
67
58
  path: string;
68
- /** Top-level names successfully serialized. */
69
59
  saved: string[];
70
60
  /** Names that could not be serialized, with reasons. */
71
61
  failed: { name: string; reason: string }[];
@@ -80,16 +70,18 @@ export interface RestoreResult {
80
70
  export interface EngineOptions {
81
71
  cwd?: string;
82
72
  env?: Record<string, string>;
83
- /** Persist/revive the namespace across engine restarts. */
84
73
  snapshot?: {
85
74
  path: string;
86
- /** Debounce for the auto-snapshot after each ok cell. Default 1500 ms. */
87
75
  debounceMs?: number;
88
76
  /** Total base64 payload cap; also the per-entry cap. Oversized entries are skipped with a reason. Default 128 MiB. */
89
77
  maxBytes?: number;
78
+ /** Force a refresh when the last persisted snapshot is older than this, even if no name changed. 0 disables. Default 2 min. */
79
+ periodMs?: number;
90
80
  };
91
81
  /** Do not revive the snapshot on this engine (used after a wedged restore was detected once). */
92
82
  skipRestore?: boolean;
83
+ /** True when this engine's snapshot was inherited from a /fork'd parent session. */
84
+ forkInherited?: boolean;
93
85
  }
94
86
 
95
87
  // --- process-wide cleanup: a child does not die with its parent, so SIGKILL live kernels on exit ---
@@ -110,7 +102,6 @@ function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolea
110
102
  return `${text.slice(0, maxChars)}\n[... output truncated at ${maxChars} chars ...]`;
111
103
  }
112
104
 
113
- /** Cap each individual line, so one giant line cannot own the whole channel budget (like grep's line cap). */
114
105
  export function capLinesForContext(text: string): { text: string; trimmed: boolean } {
115
106
  const lines = text.split("\n");
116
107
  let trimmed = false;
@@ -124,9 +115,7 @@ export function capLinesForContext(text: string): { text: string; trimmed: boole
124
115
 
125
116
  const DEFAULT_KEEP_SNAPSHOTS = 25;
126
117
 
127
- /** Scan the state root for per-session snapshot dirs and delete all but the newest `keep`,
128
- * so a long-lived machine does not accumulate one directory per session forever. The
129
- * current session's dir is exempt; a snapshot dir without a usable manifest is ignored. */
118
+ /** Keep the newest `keep` snapshot dirs; the live dir is exempt and manifest-less dirs are ignored. */
130
119
  export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP_SNAPSHOTS, currentDir?: string): void {
131
120
  const entries: { dir: string; mtimeMs: number }[] = [];
132
121
  try {
@@ -149,12 +138,7 @@ export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP
149
138
  }
150
139
  }
151
140
 
152
- // --- Orphaned-snapshot sweep: snapshot dirs are keyed by conversation file basename, so when
153
- // --- an owning conversation is deleted (pi removes the .jsonl), its directory becomes dead
154
- // --- weight. This drops any state dir whose conversation file exists in NONE of the project
155
- // --- session roots, so deleting a conversation deletes its snapshots with it. Safety rules:
156
- // --- only dirs that look like ours (contain a namespace.snapshot manifest) are touched, and
157
- // --- the live session plus the no-session "ephemeral" fallback dir are always exempt. ---
141
+ // --- orphan sweep: a state dir whose conversation file exists in no project root dies with it; only manifest dirs are touched, live + ephemeral exempt ---
158
142
  export function pruneOrphanedSnapshotDirs(
159
143
  stateRoot: string,
160
144
  sessionsRoot: string | undefined,
@@ -168,8 +152,7 @@ export function pruneOrphanedSnapshotDirs(
168
152
  for (const f of readdirSync(join(sessionsRoot, proj.name))) {
169
153
  if (!f.endsWith(".jsonl")) continue;
170
154
  const name = f.slice(0, -".jsonl".length);
171
- // --- both state-dir formats are live while their conversation lives: the legacy
172
- // --- bare-name dir (pre-slug upgrade) and the slug-keyed dir (see state-layout) ---
155
+ // --- both dir formats (legacy bare-name and slug-keyed) are live while their conversation lives ---
173
156
  liveNames.add(name);
174
157
  liveNames.add(`${proj.name}__${name}`);
175
158
  }
@@ -199,22 +182,30 @@ export class EngineManager {
199
182
  private startPromise?: Promise<void>;
200
183
  private executionQueue: Promise<unknown> = Promise.resolve();
201
184
  private snapshotTimer?: ReturnType<typeof setTimeout>;
202
- /** User cells currently running on the kernel; the debounced snapshot never cuts in front of one. */
185
+ /** In-flight user cells; the debounced snapshot never cuts in front of one. */
203
186
  private inFlightCells = 0;
204
- /** Last-seen top-level namespace names; snapshots are gated on this set changing. */
187
+ /** Last-seen namespace names; the snapshot is gated on this set changing. */
205
188
  private lastNamespaceNames?: string[];
206
189
  private pythonPath?: string;
207
- /** The kernel whose namespace has (or is being) revived from the last snapshot. */
208
190
  private restoredKernel?: KernelClient;
209
- /** A wedged revive marks the engine: later kernels on this engine boot without restoring. */
191
+ /** A wedged revive marks the engine: later kernels boot without restoring. */
210
192
  private restoreSkipped: boolean;
211
193
  private restoreTimer?: ReturnType<typeof setTimeout>;
212
194
  private restoreResolve?: (result: RestoreResult | null) => void;
213
195
  private restorePromise?: Promise<RestoreResult | null>;
214
196
  private restoreSettledResult?: RestoreResult | null;
197
+ private helperReport: readonly HelperLoadResult[] | null = null;
198
+ /** Whether the current boot's report has been handed out (once per boot). */
199
+ private helperReportTaken = true;
200
+ 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;
215
205
 
216
206
  constructor(options: EngineOptions = {}) {
217
207
  this.options = options;
208
+ this.forkInherited = options.forkInherited ?? false;
218
209
  this.restoreSkipped = options.skipRestore ?? false;
219
210
  // no snapshot capability: recovery is trivially "nothing to revive"
220
211
  if (!options.snapshot) this.settleRestore(null);
@@ -224,14 +215,23 @@ export class EngineManager {
224
215
  return this.state === "running" && (this.kernel?.isRunning ?? false);
225
216
  }
226
217
 
227
- // -- state can change to "shutdown" from kill()/dispose() at any time; read it
228
- // through a method so TS doesn't narrow the union and flag a false "no overlap" --
218
+ /** True when this engine is a fork that inherited its parent's namespace (drives the fork toast). */
219
+ get inheritedFromFork(): boolean {
220
+ return this.forkInherited;
221
+ }
222
+
223
+ /** Current boot's helper verdicts, handed out once per boot (first cell of a session/rebuild); null when nothing to announce. */
224
+ takeHelperReport(): readonly HelperLoadResult[] | null {
225
+ if (this.helperReportTaken) return null;
226
+ this.helperReportTaken = true;
227
+ return this.helperReport;
228
+ }
229
+
230
+ // --- state can flip to shutdown at any time; read it via a method so TS can't narrow the union away ---
229
231
  private isShutdown(): boolean {
230
232
  return this.state === "shutdown";
231
233
  }
232
234
 
233
- //lifecycle
234
-
235
235
  async start(): Promise<void> {
236
236
  if (this.state === "shutdown") throw new Error("Engine has been shut down");
237
237
  if (!this.startPromise) {
@@ -258,14 +258,17 @@ export class EngineManager {
258
258
  env: this.options.env,
259
259
  timeoutMs,
260
260
  });
261
- // --- an unexpected kernel death must not survive the next execute: drop the dying
262
- // --- instance and clear the boot cache so start() rebuilds it on the next cell. ---
261
+ // --- a fresh boot's helper verdicts are announced once, on the first cell after it ---
262
+ this.helperReport = this.kernel.helperReport;
263
+ this.helperReportTaken = false;
264
+ // --- drop a dead kernel so the next execute rebuilds; never resume a zombie ---
263
265
  const current = this.kernel;
264
266
  current.setOnUnexpectedExit(() => {
265
267
  if (this.kernel !== current) return;
266
268
  this.kernel = undefined;
267
269
  this.startPromise = undefined;
268
270
  this.lastNamespaceNames = undefined;
271
+ this.helperReport = null;
269
272
  });
270
273
  } catch (error) {
271
274
  if (this.state === "starting") this.state = "idle";
@@ -279,8 +282,7 @@ export class EngineManager {
279
282
  throw new Error("Engine has been shut down");
280
283
  }
281
284
  this.state = "running";
282
- // recovery is not on the first call's critical path: revive this kernel in the
283
- // first quiet gap (never ahead of a user cell) and settle restoreResult().
285
+ // --- recovery runs in the first quiet gap — never ahead of a user cell, never on the first call's critical path ---
284
286
  this.maybeScheduleRestore();
285
287
  }
286
288
 
@@ -297,7 +299,6 @@ export class EngineManager {
297
299
  this.killSync();
298
300
  }
299
301
 
300
- /** Graceful cleanup: flush a final snapshot, then terminate the kernel. */
301
302
  async dispose(): Promise<void> {
302
303
  if (this.state === "running") {
303
304
  await this.snapshotState().catch(() => null);
@@ -323,20 +324,14 @@ export class EngineManager {
323
324
  throw new Error("Engine has been shut down");
324
325
  }
325
326
  await this.start();
326
- // --- the kernel may have died after the boot promise resolved but before the async
327
- // --- exit event surfaced it; drop the zombie and rebuild so the next cell runs. ---
327
+ // --- the kernel may have died after boot resolved but before its exit event; drop the zombie and rebuild ---
328
328
  if (this.kernel && !this.kernel.isRunning) {
329
329
  this.kernel = undefined;
330
330
  this.startPromise = undefined;
331
331
  await this.start();
332
- // --- a mid-session rebuild must revive the last snapshot BEFORE the cell that
333
- // --- triggered it (unlike a session-start boot, where recovery runs in the
334
- // --- background quiet gap and the first cell is served immediately). A wedged
335
- // --- revive kills the new kernel too; boot a fresh one — the restore is now
336
- // --- marked skipped, so the cell proceeds on live state instead of wedging. ---
332
+ // --- a mid-session rebuild revives the snapshot BEFORE the triggering cell (startup recovery is background); a wedged revive kills the kernel and the retry skips the restore ---
337
333
  await this.restoreWithReap().catch(() => null);
338
- // read health through the getter: TS narrows this.kernel to undefined after the
339
- // assignment above, but start() may have replaced it with a live kernel
334
+ // --- read health via the getter: TS narrowed this.kernel away, but start() may have replaced it ---
340
335
  if (!this.isRunning) {
341
336
  this.kernel = undefined;
342
337
  this.startPromise = undefined;
@@ -373,8 +368,7 @@ export class EngineManager {
373
368
  onStream: opts.onStream,
374
369
  maxOutputChars: maxChars,
375
370
  });
376
- // --- names gate runs off the critical path so the next execute's kernel
377
- // --- request enqueues before the list-names hop, not behind it ---
371
+ // --- names gate runs off the critical path so the next cell enqueues before it ---
378
372
  if (r.status === "ok") setImmediate(() => void this.scheduleSnapshotIfChanged());
379
373
  const status: ExecuteResult["status"] = opts.signal?.aborted ? "aborted" : r.status;
380
374
  // Channel cap (truncateWithMarker), then per-line cap; both append a marker so truncation is explicit.
@@ -416,21 +410,26 @@ export class EngineManager {
416
410
  // --- an incomplete snapshot must not overwrite the last good file ---
417
411
  if (reply.complete === false) return null;
418
412
  mkdirSync(dirname(config.path), { recursive: true });
419
- // --- write to a temp file then rename so a crash mid-write can never corrupt
420
- // --- the last good snapshot (the restore side parses or returns null) ---
413
+ // --- atomic write: temp file + rename, so a crash can't corrupt the last good snapshot ---
421
414
  const tmp = `${config.path}.tmp`;
422
- writeFileSync(tmp, JSON.stringify({ version: 2, entries: reply.entries, failed: reply.failed }));
415
+ writeFileSync(tmp, JSON.stringify({ version: 3, entries: reply.entries, failed: reply.failed }));
423
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();
424
420
  return { path: config.path, saved: reply.entries.map((e) => e.name), failed: reply.failed };
425
421
  } catch {
426
422
  return null;
427
423
  }
428
424
  }
429
425
 
430
- /** Resolves with this engine's recovery outcome: the revived names, or null when there was
431
- * nothing to restore, restore was skipped, or restore failed. Never rejects. The restore runs
432
- * as a background quiet-gap job, so this promise is the ONLY hook the lifecycle needs to
433
- * announce a resume or rebuild acquire() never awaits it. */
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
+ /** Restore outcome (never rejects); the background quiet-gap job — this promise is the lifecycle's only announce hook. */
434
433
  restoreResult(): Promise<RestoreResult | null> {
435
434
  if (this.restoreSettledResult !== undefined) return Promise.resolve(this.restoreSettledResult);
436
435
  if (!this.restorePromise) {
@@ -441,8 +440,7 @@ export class EngineManager {
441
440
  return this.restorePromise;
442
441
  }
443
442
 
444
- /** True when restoring was deliberately skipped (a prior revive wedged or the engine was
445
- * built with skipRestore). Lets the lifecycle say exactly why a revival did not happen. */
443
+ /** True when the restore was deliberately skipped (prior wedge); lets the lifecycle say exactly why. */
446
444
  restoreWasSkipped(): boolean {
447
445
  return this.restoreSkipped;
448
446
  }
@@ -454,10 +452,7 @@ export class EngineManager {
454
452
  this.restoreResolve = undefined;
455
453
  }
456
454
 
457
- /** Arm the background revive for the freshly booted kernel. It fires in the first quiet gap
458
- * (the same rule as the debounced snapshot: never ahead of a user cell) and settles
459
- * restoreResult(). A fresh engine therefore serves its first cell without waiting for the
460
- * restore, while a mid-session rebuild (execute's zombie path) forces it synchronously. */
455
+ /** Background revive: fires in the first quiet gap (never ahead of a user cell); mid-session rebuilds force it synchronously. */
461
456
  private maybeScheduleRestore(): void {
462
457
  const config = this.options.snapshot;
463
458
  if (!config) return;
@@ -474,8 +469,7 @@ export class EngineManager {
474
469
  const arm = () => {
475
470
  this.restoreTimer = setTimeout(() => {
476
471
  this.restoreTimer = undefined;
477
- // --- quiet-gap rule, mirroring scheduleSnapshot: a pickling restore must not
478
- // --- queue ahead of the user's next cell on the kernel's single queue ---
472
+ // --- quiet-gap rule: a pickling restore must not queue ahead of the user's next cell ---
479
473
  if (this.inFlightCells > 0 || !this.kernel?.isRunning) {
480
474
  arm();
481
475
  return;
@@ -487,10 +481,7 @@ export class EngineManager {
487
481
  arm();
488
482
  }
489
483
 
490
- /** Run the restore cell with a watchdog. A snapshot value whose unpickling never returns
491
- * wedges the kernel's single queue forever; the reaper SIGKILLs the kernel and marks the
492
- * restore skipped, so the next call rebuilds honestly ("wedged while reviving; skipped")
493
- * instead of hanging every later cell behind the restore. */
484
+ /** Restore-cell watchdog: an unpickling that never returns would wedge the single queue forever — kill, skip, and rebuild honestly. */
494
485
  private async restoreWithReap(): Promise<RestoreResult | null> {
495
486
  const config = this.options.snapshot;
496
487
  if (!config || !this.kernel || this.restoreSkipped) {
@@ -518,11 +509,9 @@ export class EngineManager {
518
509
  await this.restoreWithReap();
519
510
  }
520
511
 
521
- /** Revive this engine's kernel from the snapshot file. Idempotent per kernel: a second call
522
- * shares the outcome of an in-flight restore instead of double-running the restore cell. */
512
+ /** Restore, idempotent per kernel: a second call shares the in-flight outcome. */
523
513
  async restoreState(skip = false): Promise<RestoreResult | null> {
524
- // --- start unconditionally: direct callers may not have started the engine, and a
525
- // --- wedged boot is detectable only while a boot attempt is actually under way ---
514
+ // --- start unconditionally: direct callers may not have started, and a wedged boot is only visible mid-attempt ---
526
515
  await this.start();
527
516
  if (skip) {
528
517
  this.settleRestore(null);
@@ -535,7 +524,6 @@ export class EngineManager {
535
524
  return null;
536
525
  }
537
526
  if (kernel === this.restoredKernel) {
538
- // already revived or reviving on this kernel: share the outcome, never double-run
539
527
  return this.restoreResult();
540
528
  }
541
529
  // claim the kernel now so the quiet-gap scheduler cannot start a second restore cell
@@ -549,14 +537,22 @@ export class EngineManager {
549
537
  version?: number;
550
538
  entries?: SnapshotEntry[];
551
539
  vars?: Record<string, string>;
540
+ failed?: { name: string; reason: string }[];
552
541
  };
553
- // --- version 1 files (pre-source-capture) are still restorable: their vars are plain pickles ---
542
+ // --- v1 files (pre-source-capture) restore via plain pickles; v3 value entries are zlib-compressed ---
554
543
  const entries: SnapshotEntry[] =
555
- payload.version === 2
544
+ payload.version !== undefined && payload.version >= 2
556
545
  ? (payload.entries ?? [])
557
546
  : Object.entries(payload.vars ?? {}).map(([name, b64]) => ({ name, kind: "value", payload: b64 }));
558
- const reply = await kernel.restore(entries);
559
- const result: RestoreResult = { path: config.path, restored: reply.restored, failed: reply.failed };
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 };
560
556
  this.settleRestore(result);
561
557
  return result;
562
558
  } catch {
@@ -565,7 +561,6 @@ export class EngineManager {
565
561
  }
566
562
  }
567
563
 
568
- /** The conversation's state dir exists, so this engine is a resume, not a first run. */
569
564
  hasSnapshotHistory(): boolean {
570
565
  const config = this.options.snapshot;
571
566
  return config ? existsSync(dirname(config.path)) : false;
@@ -580,8 +575,7 @@ export class EngineManager {
580
575
  }
581
576
  }
582
577
 
583
- /** Snapshot only if the set of top-level names changed since the last snapshot. Names-only
584
- * comparison is cheap (no pickling); a cell that reuses existing state skips the heavy dump. */
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. */
585
579
  private async scheduleSnapshotIfChanged(): Promise<void> {
586
580
  const config = this.options.snapshot;
587
581
  if (!config) return;
@@ -589,8 +583,14 @@ export class EngineManager {
589
583
  if (names === null || names.length === 0) return;
590
584
  const key = [...names].sort().join(",");
591
585
  const prev = this.lastNamespaceNames ? [...this.lastNamespaceNames].sort().join(",") : undefined;
592
- if (prev !== undefined && prev === key) return; // nothing changed
593
- this.lastNamespaceNames = [...names].sort();
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
594
  this.scheduleSnapshot();
595
595
  }
596
596
 
@@ -602,9 +602,7 @@ export class EngineManager {
602
602
  const fire = () => {
603
603
  this.snapshotTimer = undefined;
604
604
  if (this.inFlightCells > 0) {
605
- // --- a pickling cell would wait ahead of the user's next request on the
606
- // --- kernel's single queue; the snapshot only lands in a real quiet gap,
607
- // --- so re-arm the full quiet window and let activity settle instead ---
605
+ // --- pickling must not queue ahead of the user's next request; re-arm the quiet window until the kernel is idle ---
608
606
  this.snapshotTimer = setTimeout(fire, quiet);
609
607
  this.snapshotTimer.unref?.();
610
608
  return;