pi-repl-py 0.6.14 → 0.7.1
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 +4 -2
- package/docs/helpers.md +13 -0
- package/index.ts +40 -38
- 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 +93 -95
- 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 +9 -13
- package/src/extension/render-core.ts +9 -36
- package/src/extension/render.ts +1 -4
- package/src/extension/session-engine.ts +42 -40
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +30 -17
- package/src/extension/tool-meta.ts +2 -9
package/src/engine/index.ts
CHANGED
|
@@ -1,54 +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;
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
// ---
|
|
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
|
|
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
|
-
/**
|
|
185
|
+
/** In-flight user cells; the debounced snapshot never cuts in front of one. */
|
|
203
186
|
private inFlightCells = 0;
|
|
204
|
-
/** Last-seen
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
// ---
|
|
262
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
// ---
|
|
542
|
+
// --- v1 files (pre-source-capture) restore via plain pickles; v3 value entries are zlib-compressed ---
|
|
554
543
|
const entries: SnapshotEntry[] =
|
|
555
|
-
payload.version
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
// ---
|
|
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;
|