pi-repl-py 0.6.13 → 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,48 +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;
31
+ const RESTORE_QUIET_MS = 250;
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;
33
34
 
34
35
  interface EngineExecuteError {
35
- /** Error class name, e.g. "TypeError". */
36
36
  name: string;
37
37
  message: string;
38
- /** Stack trace, split into lines. */
39
38
  stack: string[];
40
39
  }
41
40
 
42
41
  export interface ExecuteResult {
43
42
  stdout: string;
44
43
  stderr: string;
45
- /** Rendered value of the cell's final expression, when it has one. */
46
44
  result?: string;
47
45
  status: "ok" | "error" | "aborted";
48
46
  error?: EngineExecuteError;
@@ -53,13 +51,11 @@ export interface ExecuteOptions {
53
51
  /** Aborting cancels the cell via kernel interrupt; the namespace is preserved. */
54
52
  signal?: AbortSignal;
55
53
  onStream?: (chunk: string, name: "stdout" | "stderr") => void;
56
- /** Cap stdout / stderr / result at this many characters. Default 45K. */
57
54
  maxOutputChars?: number;
58
55
  }
59
56
 
60
57
  export interface SnapshotResult {
61
58
  path: string;
62
- /** Top-level names successfully serialized. */
63
59
  saved: string[];
64
60
  /** Names that could not be serialized, with reasons. */
65
61
  failed: { name: string; reason: string }[];
@@ -74,14 +70,18 @@ export interface RestoreResult {
74
70
  export interface EngineOptions {
75
71
  cwd?: string;
76
72
  env?: Record<string, string>;
77
- /** Persist/revive the namespace across engine restarts. */
78
73
  snapshot?: {
79
74
  path: string;
80
- /** Debounce for the auto-snapshot after each ok cell. Default 1500 ms. */
81
75
  debounceMs?: number;
82
76
  /** Total base64 payload cap; also the per-entry cap. Oversized entries are skipped with a reason. Default 128 MiB. */
83
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;
84
80
  };
81
+ /** Do not revive the snapshot on this engine (used after a wedged restore was detected once). */
82
+ skipRestore?: boolean;
83
+ /** True when this engine's snapshot was inherited from a /fork'd parent session. */
84
+ forkInherited?: boolean;
85
85
  }
86
86
 
87
87
  // --- process-wide cleanup: a child does not die with its parent, so SIGKILL live kernels on exit ---
@@ -102,7 +102,6 @@ function truncateWithMarker(text: string, maxChars: number, wasTruncated: boolea
102
102
  return `${text.slice(0, maxChars)}\n[... output truncated at ${maxChars} chars ...]`;
103
103
  }
104
104
 
105
- /** Cap each individual line, so one giant line cannot own the whole channel budget (like grep's line cap). */
106
105
  export function capLinesForContext(text: string): { text: string; trimmed: boolean } {
107
106
  const lines = text.split("\n");
108
107
  let trimmed = false;
@@ -116,9 +115,7 @@ export function capLinesForContext(text: string): { text: string; trimmed: boole
116
115
 
117
116
  const DEFAULT_KEEP_SNAPSHOTS = 25;
118
117
 
119
- /** Scan the state root for per-session snapshot dirs and delete all but the newest `keep`,
120
- * so a long-lived machine does not accumulate one directory per session forever. The
121
- * 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. */
122
119
  export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP_SNAPSHOTS, currentDir?: string): void {
123
120
  const entries: { dir: string; mtimeMs: number }[] = [];
124
121
  try {
@@ -141,12 +138,7 @@ export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP
141
138
  }
142
139
  }
143
140
 
144
- // --- Orphaned-snapshot sweep: snapshot dirs are keyed by conversation file basename, so when
145
- // --- an owning conversation is deleted (pi removes the .jsonl), its directory becomes dead
146
- // --- weight. This drops any state dir whose conversation file exists in NONE of the project
147
- // --- session roots, so deleting a conversation deletes its snapshots with it. Safety rules:
148
- // --- only dirs that look like ours (contain a namespace.snapshot manifest) are touched, and
149
- // --- 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 ---
150
142
  export function pruneOrphanedSnapshotDirs(
151
143
  stateRoot: string,
152
144
  sessionsRoot: string | undefined,
@@ -158,7 +150,11 @@ export function pruneOrphanedSnapshotDirs(
158
150
  for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
159
151
  if (!proj.isDirectory()) continue;
160
152
  for (const f of readdirSync(join(sessionsRoot, proj.name))) {
161
- if (f.endsWith(".jsonl")) liveNames.add(f.slice(0, -".jsonl".length));
153
+ if (!f.endsWith(".jsonl")) continue;
154
+ const name = f.slice(0, -".jsonl".length);
155
+ // --- both dir formats (legacy bare-name and slug-keyed) are live while their conversation lives ---
156
+ liveNames.add(name);
157
+ liveNames.add(`${proj.name}__${name}`);
162
158
  }
163
159
  }
164
160
  } catch {
@@ -186,28 +182,56 @@ export class EngineManager {
186
182
  private startPromise?: Promise<void>;
187
183
  private executionQueue: Promise<unknown> = Promise.resolve();
188
184
  private snapshotTimer?: ReturnType<typeof setTimeout>;
189
- /** 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. */
190
186
  private inFlightCells = 0;
191
- /** 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. */
192
188
  private lastNamespaceNames?: string[];
193
189
  private pythonPath?: string;
190
+ private restoredKernel?: KernelClient;
191
+ /** A wedged revive marks the engine: later kernels boot without restoring. */
192
+ private restoreSkipped: boolean;
193
+ private restoreTimer?: ReturnType<typeof setTimeout>;
194
+ private restoreResolve?: (result: RestoreResult | null) => void;
195
+ private restorePromise?: Promise<RestoreResult | null>;
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;
194
205
 
195
206
  constructor(options: EngineOptions = {}) {
196
207
  this.options = options;
208
+ this.forkInherited = options.forkInherited ?? false;
209
+ this.restoreSkipped = options.skipRestore ?? false;
210
+ // no snapshot capability: recovery is trivially "nothing to revive"
211
+ if (!options.snapshot) this.settleRestore(null);
197
212
  }
198
213
 
199
214
  get isRunning(): boolean {
200
215
  return this.state === "running" && (this.kernel?.isRunning ?? false);
201
216
  }
202
217
 
203
- // -- state can change to "shutdown" from kill()/dispose() at any time; read it
204
- // 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 ---
205
231
  private isShutdown(): boolean {
206
232
  return this.state === "shutdown";
207
233
  }
208
234
 
209
- //lifecycle
210
-
211
235
  async start(): Promise<void> {
212
236
  if (this.state === "shutdown") throw new Error("Engine has been shut down");
213
237
  if (!this.startPromise) {
@@ -234,14 +258,17 @@ export class EngineManager {
234
258
  env: this.options.env,
235
259
  timeoutMs,
236
260
  });
237
- // --- an unexpected kernel death must not survive the next execute: drop the dying
238
- // --- 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 ---
239
265
  const current = this.kernel;
240
266
  current.setOnUnexpectedExit(() => {
241
267
  if (this.kernel !== current) return;
242
268
  this.kernel = undefined;
243
269
  this.startPromise = undefined;
244
270
  this.lastNamespaceNames = undefined;
271
+ this.helperReport = null;
245
272
  });
246
273
  } catch (error) {
247
274
  if (this.state === "starting") this.state = "idle";
@@ -255,6 +282,8 @@ export class EngineManager {
255
282
  throw new Error("Engine has been shut down");
256
283
  }
257
284
  this.state = "running";
285
+ // --- recovery runs in the first quiet gap — never ahead of a user cell, never on the first call's critical path ---
286
+ this.maybeScheduleRestore();
258
287
  }
259
288
 
260
289
  /** Abrupt teardown: SIGKILL the kernel; safe from process.on("exit"). */
@@ -270,7 +299,6 @@ export class EngineManager {
270
299
  this.killSync();
271
300
  }
272
301
 
273
- /** Graceful cleanup: flush a final snapshot, then terminate the kernel. */
274
302
  async dispose(): Promise<void> {
275
303
  if (this.state === "running") {
276
304
  await this.snapshotState().catch(() => null);
@@ -296,12 +324,19 @@ export class EngineManager {
296
324
  throw new Error("Engine has been shut down");
297
325
  }
298
326
  await this.start();
299
- // --- the kernel may have died after the boot promise resolved but before the async
300
- // --- 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 ---
301
328
  if (this.kernel && !this.kernel.isRunning) {
302
329
  this.kernel = undefined;
303
330
  this.startPromise = undefined;
304
331
  await this.start();
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 ---
333
+ await this.restoreWithReap().catch(() => null);
334
+ // --- read health via the getter: TS narrowed this.kernel away, but start() may have replaced it ---
335
+ if (!this.isRunning) {
336
+ this.kernel = undefined;
337
+ this.startPromise = undefined;
338
+ await this.start();
339
+ }
305
340
  }
306
341
  if (this.isShutdown()) {
307
342
  throw new Error("Engine has been shut down");
@@ -333,8 +368,7 @@ export class EngineManager {
333
368
  onStream: opts.onStream,
334
369
  maxOutputChars: maxChars,
335
370
  });
336
- // --- names gate runs off the critical path so the next execute's kernel
337
- // --- 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 ---
338
372
  if (r.status === "ok") setImmediate(() => void this.scheduleSnapshotIfChanged());
339
373
  const status: ExecuteResult["status"] = opts.signal?.aborted ? "aborted" : r.status;
340
374
  // Channel cap (truncateWithMarker), then per-line cap; both append a marker so truncation is explicit.
@@ -376,45 +410,157 @@ export class EngineManager {
376
410
  // --- an incomplete snapshot must not overwrite the last good file ---
377
411
  if (reply.complete === false) return null;
378
412
  mkdirSync(dirname(config.path), { recursive: true });
379
- // --- write to a temp file then rename so a crash mid-write can never corrupt
380
- // --- 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 ---
381
414
  const tmp = `${config.path}.tmp`;
382
- 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 }));
383
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();
384
420
  return { path: config.path, saved: reply.entries.map((e) => e.name), failed: reply.failed };
385
421
  } catch {
386
422
  return null;
387
423
  }
388
424
  }
389
425
 
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. */
433
+ restoreResult(): Promise<RestoreResult | null> {
434
+ if (this.restoreSettledResult !== undefined) return Promise.resolve(this.restoreSettledResult);
435
+ if (!this.restorePromise) {
436
+ this.restorePromise = new Promise<RestoreResult | null>((resolve) => {
437
+ this.restoreResolve = resolve;
438
+ });
439
+ }
440
+ return this.restorePromise;
441
+ }
442
+
443
+ /** True when the restore was deliberately skipped (prior wedge); lets the lifecycle say exactly why. */
444
+ restoreWasSkipped(): boolean {
445
+ return this.restoreSkipped;
446
+ }
447
+
448
+ private settleRestore(result: RestoreResult | null): void {
449
+ if (this.restoreSettledResult !== undefined) return;
450
+ this.restoreSettledResult = result;
451
+ this.restoreResolve?.(result);
452
+ this.restoreResolve = undefined;
453
+ }
454
+
455
+ /** Background revive: fires in the first quiet gap (never ahead of a user cell); mid-session rebuilds force it synchronously. */
456
+ private maybeScheduleRestore(): void {
457
+ const config = this.options.snapshot;
458
+ if (!config) return;
459
+ if (this.restoreSkipped) {
460
+ this.settleRestore(null);
461
+ return;
462
+ }
463
+ if (this.kernel && this.kernel === this.restoredKernel) return; // this kernel already revived
464
+ if (!existsSync(config.path)) {
465
+ this.settleRestore(null);
466
+ return;
467
+ }
468
+ if (this.restoreTimer) return; // already armed
469
+ const arm = () => {
470
+ this.restoreTimer = setTimeout(() => {
471
+ this.restoreTimer = undefined;
472
+ // --- quiet-gap rule: a pickling restore must not queue ahead of the user's next cell ---
473
+ if (this.inFlightCells > 0 || !this.kernel?.isRunning) {
474
+ arm();
475
+ return;
476
+ }
477
+ void this.runRestore();
478
+ }, RESTORE_QUIET_MS);
479
+ this.restoreTimer.unref?.();
480
+ };
481
+ arm();
482
+ }
483
+
484
+ /** Restore-cell watchdog: an unpickling that never returns would wedge the single queue forever — kill, skip, and rebuild honestly. */
485
+ private async restoreWithReap(): Promise<RestoreResult | null> {
486
+ const config = this.options.snapshot;
487
+ if (!config || !this.kernel || this.restoreSkipped) {
488
+ this.settleRestore(null);
489
+ return null;
490
+ }
491
+ if (this.kernel === this.restoredKernel) return this.restoreResult();
492
+ const deadlineMs =
493
+ Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? this.options.env?.PI_REPL_BOOT_TIMEOUT_MS ?? 0) ||
494
+ DEFAULT_RESTORE_DEADLINE_MS;
495
+ const reaper = setTimeout(() => {
496
+ this.restoreSkipped = true;
497
+ this.settleRestore(null);
498
+ this.kernel?.kill();
499
+ }, deadlineMs);
500
+ reaper.unref?.();
501
+ try {
502
+ return await this.restoreState(false).catch(() => null);
503
+ } finally {
504
+ clearTimeout(reaper);
505
+ }
506
+ }
507
+
508
+ private async runRestore(): Promise<void> {
509
+ await this.restoreWithReap();
510
+ }
511
+
512
+ /** Restore, idempotent per kernel: a second call shares the in-flight outcome. */
390
513
  async restoreState(skip = false): Promise<RestoreResult | null> {
391
- // --- start unconditionally: the boot deadline in the lifecycle bounds this call, so
392
- // --- booting eagerly here (even with nothing to revive) is what makes a wedged boot
393
- // --- detectable instead of deferring the wedge to the first cell. ---
514
+ // --- start unconditionally: direct callers may not have started, and a wedged boot is only visible mid-attempt ---
394
515
  await this.start();
395
- if (skip) return null;
516
+ if (skip) {
517
+ this.settleRestore(null);
518
+ return null;
519
+ }
396
520
  const config = this.options.snapshot;
397
- if (!config) return null;
398
- if (!existsSync(config.path)) return null;
521
+ const kernel = this.kernel;
522
+ if (!config || !kernel || this.restoreSkipped) {
523
+ this.settleRestore(null);
524
+ return null;
525
+ }
526
+ if (kernel === this.restoredKernel) {
527
+ return this.restoreResult();
528
+ }
529
+ // claim the kernel now so the quiet-gap scheduler cannot start a second restore cell
530
+ this.restoredKernel = kernel;
531
+ if (!existsSync(config.path)) {
532
+ this.settleRestore(null);
533
+ return null;
534
+ }
399
535
  try {
400
536
  const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
401
537
  version?: number;
402
538
  entries?: SnapshotEntry[];
403
539
  vars?: Record<string, string>;
540
+ failed?: { name: string; reason: string }[];
404
541
  };
405
- // --- 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 ---
406
543
  const entries: SnapshotEntry[] =
407
- payload.version === 2
544
+ payload.version !== undefined && payload.version >= 2
408
545
  ? (payload.entries ?? [])
409
546
  : Object.entries(payload.vars ?? {}).map(([name, b64]) => ({ name, kind: "value", payload: b64 }));
410
- const reply = await this.kernel!.restore(entries);
411
- return { 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 };
556
+ this.settleRestore(result);
557
+ return result;
412
558
  } catch {
559
+ this.settleRestore(null);
413
560
  return null;
414
561
  }
415
562
  }
416
563
 
417
- /** The conversation's state dir exists, so this engine is a resume, not a first run. */
418
564
  hasSnapshotHistory(): boolean {
419
565
  const config = this.options.snapshot;
420
566
  return config ? existsSync(dirname(config.path)) : false;
@@ -429,8 +575,7 @@ export class EngineManager {
429
575
  }
430
576
  }
431
577
 
432
- /** Snapshot only if the set of top-level names changed since the last snapshot. Names-only
433
- * 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. */
434
579
  private async scheduleSnapshotIfChanged(): Promise<void> {
435
580
  const config = this.options.snapshot;
436
581
  if (!config) return;
@@ -438,8 +583,14 @@ export class EngineManager {
438
583
  if (names === null || names.length === 0) return;
439
584
  const key = [...names].sort().join(",");
440
585
  const prev = this.lastNamespaceNames ? [...this.lastNamespaceNames].sort().join(",") : undefined;
441
- if (prev !== undefined && prev === key) return; // nothing changed
442
- 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;
443
594
  this.scheduleSnapshot();
444
595
  }
445
596
 
@@ -451,9 +602,7 @@ export class EngineManager {
451
602
  const fire = () => {
452
603
  this.snapshotTimer = undefined;
453
604
  if (this.inFlightCells > 0) {
454
- // --- a pickling cell would wait ahead of the user's next request on the
455
- // --- kernel's single queue; the snapshot only lands in a real quiet gap,
456
- // --- 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 ---
457
606
  this.snapshotTimer = setTimeout(fire, quiet);
458
607
  this.snapshotTimer.unref?.();
459
608
  return;