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.
- package/docs/ARCHITECTURE.md +103 -138
- package/docs/helpers.md +13 -0
- package/index.ts +57 -41
- package/package.json +1 -1
- package/scripts/setup-venv.mjs +5 -22
- package/src/engine/helpers-locate.ts +0 -1
- package/src/engine/index.ts +213 -64
- package/src/engine/kernel.ts +55 -46
- package/src/engine/session.ts +7 -16
- package/src/engine/zmtp.ts +1 -13
- package/src/extension/helpers.ts +27 -16
- package/src/extension/preview/candidates.ts +0 -4
- package/src/extension/preview/descriptor.ts +0 -1
- package/src/extension/preview/types.ts +0 -2
- package/src/extension/prompt.ts +7 -11
- package/src/extension/render-core.ts +9 -36
- package/src/extension/render.ts +1 -4
- package/src/extension/session-engine.ts +101 -83
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +58 -0
- package/src/extension/tool-meta.ts +2 -9
package/src/engine/index.ts
CHANGED
|
@@ -1,48 +1,46 @@
|
|
|
1
|
-
// --- EngineManager:
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
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
|
-
// ---
|
|
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"))
|
|
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
|
-
/**
|
|
185
|
+
/** In-flight user cells; the debounced snapshot never cuts in front of one. */
|
|
190
186
|
private inFlightCells = 0;
|
|
191
|
-
/** Last-seen
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
// ---
|
|
238
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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)
|
|
516
|
+
if (skip) {
|
|
517
|
+
this.settleRestore(null);
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
396
520
|
const config = this.options.snapshot;
|
|
397
|
-
|
|
398
|
-
if (!
|
|
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
|
-
// ---
|
|
542
|
+
// --- v1 files (pre-source-capture) restore via plain pickles; v3 value entries are zlib-compressed ---
|
|
406
543
|
const entries: SnapshotEntry[] =
|
|
407
|
-
payload.version
|
|
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
|
|
411
|
-
|
|
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
|
|
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
|
-
|
|
442
|
-
|
|
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
|
-
// ---
|
|
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;
|