pi-repl-py 0.6.11 → 0.6.13
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 +24 -4
- package/index.ts +19 -4
- package/package.json +1 -1
- package/src/engine/index.ts +44 -2
- package/src/engine/session.ts +7 -1
- package/src/extension/prompt.ts +1 -1
- package/src/extension/render-core.ts +10 -0
- package/src/extension/session-engine.ts +100 -7
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -102,6 +102,14 @@ backstop for cells wedged in C code (which ignore interrupts), the engine gives
|
|
|
102
102
|
cell up to 20 seconds to settle and keeps the kernel if it does; only a cell that is still
|
|
103
103
|
running after that grace is killed, and the next call rebuilds from the last snapshot.
|
|
104
104
|
|
|
105
|
+
**History is off.** Every execute goes out with `store_history: false`. IPython's `In`/`Out`
|
|
106
|
+
retention keeps every last-expression result object alive in the kernel, and that retention
|
|
107
|
+
cannot be reclaimed from a user cell — deleting `Out` and `_`/`__`/`___` from `user_ns`
|
|
108
|
+
followed by `gc.collect()` leaves the objects alive (measured: 62 MB idle grows past 400 MB
|
|
109
|
+
after two bare big results and never comes back). Disabling history bounds the kernel to at
|
|
110
|
+
most the latest result. The transcript is the record instead, and results still publish over
|
|
111
|
+
iopub: single-mode execution calls `sys.displayhook` regardless of `store_history`.
|
|
112
|
+
|
|
105
113
|
## Helpers loading
|
|
106
114
|
|
|
107
115
|
At boot, the kernel and the host both read the same merged helper list (project
|
|
@@ -144,11 +152,15 @@ pickle cannot revive them, since they live in `__main__`). Bindings that still f
|
|
|
144
152
|
handles, open resources, source-less functions — are reported by name, never dropped silently.
|
|
145
153
|
Entries are capped per-binding and in total (128 MiB default), and the snapshot file is written
|
|
146
154
|
via temp-file-and-rename so a crash cannot corrupt the last good copy; old session snapshot
|
|
147
|
-
directories are pruned to the newest 25
|
|
155
|
+
directories are pruned to the newest 25, and snapshot dirs whose owning conversation file no
|
|
156
|
+
longer exists in any project session root are swept entirely (deleting a conversation deletes
|
|
157
|
+
its snapshots with it). "ephemeral" and the live session are always exempt. If the evaluator was rebuilt mid-session, the next
|
|
148
158
|
cell's result is prefixed with a `<repl_engine_reset>` block that names what was revived and
|
|
149
|
-
what was lost, so the model re-verifies before reusing state that may be gone.
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
what was lost, so the model re-verifies before reusing state that may be gone. The human gets
|
|
160
|
+
only a terse `ui.notify` toast ("repl kernel rebuilt, 3 names revived") instead of the marker;
|
|
161
|
+
the two are derived from the same restore result, so they never disagree. A resumed
|
|
162
|
+
conversation announces the same pair on its first cell, but only when the conversation has a
|
|
163
|
+
saved past; a first-ever session stays quiet.
|
|
152
164
|
|
|
153
165
|
## Failure modes
|
|
154
166
|
|
|
@@ -193,6 +205,14 @@ global one and both sides are guaranteed to agree. The venv is built automatical
|
|
|
193
205
|
interpreter follows the order above. No setting is needed. The per-cell silence watchdog is
|
|
194
206
|
off by default (`PI_REPL_TIMEOUT_MS=0`: a silent but working cell may run on).
|
|
195
207
|
|
|
208
|
+
The boot itself is bounded regardless: kernel start, helpers preload, and snapshot restore are
|
|
209
|
+
kernel cells with no deadline of their own, and `acquire()` dedupes, so one wedged boot (an npm
|
|
210
|
+
update swapping the venv under a live kernel, a snapshot value whose unpickling never returns)
|
|
211
|
+
would hang the first cell and every cell after it. The lifecycle races each boot attempt against
|
|
212
|
+
`PI_REPL_BOOT_TIMEOUT_MS` (default 90s): a wedged attempt is killed and retried once with the
|
|
213
|
+
snapshot deliberately skipped, landing on an honest "wedged while reviving; skipped" notice; a
|
|
214
|
+
second wedge fails the cell loudly instead of hanging.
|
|
215
|
+
|
|
196
216
|
## Reference documentation
|
|
197
217
|
|
|
198
218
|
- Design rationale: [design.md](design.md)
|
package/index.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import { withSkillsBlock } from "./src/extension/skill-hook.js";
|
|
8
|
-
import { EngineManager, pruneSnapshotDirs } from "./src/engine/index.js";
|
|
8
|
+
import { EngineManager, pruneOrphanedSnapshotDirs, pruneSnapshotDirs } from "./src/engine/index.js";
|
|
9
9
|
import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
|
|
10
|
-
import { EngineLifecycle } from "./src/extension/session-engine.js";
|
|
10
|
+
import { EngineLifecycle, formatResetToast } from "./src/extension/session-engine.js";
|
|
11
11
|
import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
|
|
12
12
|
|
|
13
13
|
const executeSchema = Type.Object({
|
|
@@ -58,6 +58,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
58
58
|
const pendingErrorResults = new Map<string, { details: ExecuteDetails }>();
|
|
59
59
|
|
|
60
60
|
const lifecycle = new EngineLifecycle<EngineManager>({
|
|
61
|
+
// --- boot deadline: bounds kernel start + helpers preload + snapshot restore. An npm
|
|
62
|
+
// --- update swaps the venv and helpers under a live kernel, and the first boot after
|
|
63
|
+
// --- it can wedge (poisoned pickle, half-built venv); without this the first cell
|
|
64
|
+
// --- hangs forever, because acquire() dedupes onto the same hung boot. ---
|
|
65
|
+
bootTimeoutMs: Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? 90_000) || 90_000,
|
|
61
66
|
create() {
|
|
62
67
|
const { cwd, sessionFile } = location;
|
|
63
68
|
const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
|
|
@@ -68,6 +73,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
68
73
|
try {
|
|
69
74
|
pruneSnapshotDirs(join(stateDir, ".."), 25, sessionKey);
|
|
70
75
|
} catch {}
|
|
76
|
+
// --- cascade deletions: if a conversation is deleted, its snapshots die with it.
|
|
77
|
+
// --- sessionFile is sessions/<project-root>/<name>.jsonl, so the sessions root is
|
|
78
|
+
// --- two parent hops up; dirs whose conversation file exists in no project root
|
|
79
|
+
// --- (and that aren't this session or the ephemeral fallback) are swept. ---
|
|
80
|
+
try {
|
|
81
|
+
pruneOrphanedSnapshotDirs(join(stateDir, ".."), sessionFile ? dirname(dirname(sessionFile)) : undefined, sessionKey);
|
|
82
|
+
} catch {}
|
|
71
83
|
}
|
|
72
84
|
return new EngineManager({
|
|
73
85
|
cwd,
|
|
@@ -174,8 +186,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
174
186
|
onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
|
|
175
187
|
},
|
|
176
188
|
});
|
|
177
|
-
// --- reset notice leads so the model reads that its namespace was rebuilt
|
|
178
|
-
|
|
189
|
+
// --- reset notice leads so the model reads that its namespace was rebuilt; the
|
|
190
|
+
// --- human gets a terse notification instead of the marker, fire and forget ---
|
|
191
|
+
const reset = lifecycle.takeResetNotice();
|
|
192
|
+
if (reset?.notice) ctx?.ui?.notify?.(formatResetToast(reset.origin, reset.restore), "info");
|
|
193
|
+
const sections = [reset?.notice, r.stdout, r.stderr, r.result];
|
|
179
194
|
const errorLines = r.error ? composeErrorLines(r.error) : undefined;
|
|
180
195
|
if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
|
|
181
196
|
if (r.status === "aborted") sections.push("[cell aborted]");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-repl-py",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A pi extension with a single tool: execute, running a TypeScript host with a persistent Python (ipykernel) evaluator and a user-configurable toolbox of functions.",
|
|
6
6
|
"keywords": [
|
package/src/engine/index.ts
CHANGED
|
@@ -141,6 +141,44 @@ export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
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. ---
|
|
150
|
+
export function pruneOrphanedSnapshotDirs(
|
|
151
|
+
stateRoot: string,
|
|
152
|
+
sessionsRoot: string | undefined,
|
|
153
|
+
currentDir?: string,
|
|
154
|
+
): number {
|
|
155
|
+
if (!sessionsRoot || !existsSync(sessionsRoot)) return 0;
|
|
156
|
+
const liveNames = new Set<string>();
|
|
157
|
+
try {
|
|
158
|
+
for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
159
|
+
if (!proj.isDirectory()) continue;
|
|
160
|
+
for (const f of readdirSync(join(sessionsRoot, proj.name))) {
|
|
161
|
+
if (f.endsWith(".jsonl")) liveNames.add(f.slice(0, -".jsonl".length));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
let removed = 0;
|
|
168
|
+
try {
|
|
169
|
+
for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
|
|
170
|
+
if (!entry.isDirectory() || entry.name === currentDir || entry.name === "ephemeral") continue;
|
|
171
|
+
if (liveNames.has(entry.name)) continue;
|
|
172
|
+
if (!existsSync(join(stateRoot, entry.name, "namespace.snapshot"))) continue;
|
|
173
|
+
rmSync(join(stateRoot, entry.name), { recursive: true, force: true });
|
|
174
|
+
removed++;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// readdir can race a concurrent sweep; give up quietly rather than partial-delete
|
|
178
|
+
}
|
|
179
|
+
return removed;
|
|
180
|
+
}
|
|
181
|
+
|
|
144
182
|
export class EngineManager {
|
|
145
183
|
private readonly options: EngineOptions;
|
|
146
184
|
private kernel?: KernelClient;
|
|
@@ -349,11 +387,15 @@ export class EngineManager {
|
|
|
349
387
|
}
|
|
350
388
|
}
|
|
351
389
|
|
|
352
|
-
async restoreState(): Promise<RestoreResult | null> {
|
|
390
|
+
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. ---
|
|
394
|
+
await this.start();
|
|
395
|
+
if (skip) return null;
|
|
353
396
|
const config = this.options.snapshot;
|
|
354
397
|
if (!config) return null;
|
|
355
398
|
if (!existsSync(config.path)) return null;
|
|
356
|
-
await this.start();
|
|
357
399
|
try {
|
|
358
400
|
const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
|
|
359
401
|
version?: number;
|
package/src/engine/session.ts
CHANGED
|
@@ -123,10 +123,16 @@ export class JupyterSession {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function executeRequest(code: string, silent: boolean): Record<string, unknown> {
|
|
126
|
+
// --- store_history is always false: IPython retains every last-expression result in its
|
|
127
|
+
// --- In/Out history, and that retention is NOT reclaimable from user cells (deleting Out
|
|
128
|
+
// --- entries and _/__/___ from user_ns leaves the objects alive). With history off, cells
|
|
129
|
+
// --- stop feeding that growth entirely. The contract does not depend on In/Out: results
|
|
130
|
+
// --- are published over iopub via the display hook (single-mode execution, unaffected by
|
|
131
|
+
// --- store_history) and returned in the cell's transcript. ---
|
|
126
132
|
return {
|
|
127
133
|
code,
|
|
128
134
|
silent,
|
|
129
|
-
store_history:
|
|
135
|
+
store_history: false,
|
|
130
136
|
user_expressions: {},
|
|
131
137
|
allow_stdin: false,
|
|
132
138
|
stop_on_error: true,
|
package/src/extension/prompt.ts
CHANGED
|
@@ -14,7 +14,7 @@ export function buildPromptGuidelines(preloaded: string[]): string[] {
|
|
|
14
14
|
return [
|
|
15
15
|
"Write modern idiomatic Python.",
|
|
16
16
|
"Find, filter, fetch, sample: narrow the output in Python, then print only the window that decides the next step (a head, a shape, a slice), not the whole.",
|
|
17
|
-
"Make surgical, precise changes over rewrites or whole-file dumps:
|
|
17
|
+
"Make surgical, precise changes over rewrites or whole-file dumps: replace, verify, read the file back before trusting it.",
|
|
18
18
|
"Prefer to reuse existing variables, functions, imports, classes, and data from prior cells/namespaces over recomputing.",
|
|
19
19
|
"If output begins with <repl_engine_reset>, the runtime rebuilt and the notebook was restored from the last snapshot; reverify surviving names before building on them.",
|
|
20
20
|
...(preloaded.length
|
|
@@ -517,6 +517,9 @@ function renderOutput(
|
|
|
517
517
|
}
|
|
518
518
|
|
|
519
519
|
if (details?.errorStack && details.errorStack.length > 0) {
|
|
520
|
+
// --- a traceback IS output: without this flag a pure-traceback error cell (no
|
|
521
|
+
// --- stdout/stderr/result, the common error shape) also rendered "no output" below it ---
|
|
522
|
+
renderedText = true;
|
|
520
523
|
output.push(` ${OUTPUT_INDENT}${deps.fg("dim", "traceback:")}`);
|
|
521
524
|
for (const line of details.errorStack) {
|
|
522
525
|
const safe = sanitizeTuiOutput(line || " ");
|
|
@@ -529,6 +532,13 @@ function renderOutput(
|
|
|
529
532
|
addWrapped(output, OUTPUT_INDENT, deps.fg("muted", message), width, deps, { sanitize: false });
|
|
530
533
|
}
|
|
531
534
|
|
|
535
|
+
// --- bottom cushion: streams end with a newline, so blobs already render a trailing blank
|
|
536
|
+
// --- row; traceback and the placeholder have no such newline and would sit flush against
|
|
537
|
+
// --- the panel's bottom edge. Normalize: the panel always ends with one blank painted row.
|
|
538
|
+
// --- (SGR stripped before the blank test; the colored rows themselves stay untouched.) ---
|
|
539
|
+
const lastRow = output[output.length - 1];
|
|
540
|
+
if (lastRow !== undefined && lastRow.replace(SGR_PATTERN, "").trim() !== "") output.push("");
|
|
541
|
+
|
|
532
542
|
// --- expanded cells render the whole output: the data cap bounds it ---
|
|
533
543
|
if (output.length > 0 && hasCode) lines.push("");
|
|
534
544
|
lines.push(...output);
|
|
@@ -10,7 +10,8 @@ function summarizeNames(names: readonly string[], limit: number): string {
|
|
|
10
10
|
|
|
11
11
|
/** The part of EngineManager this lifecycle needs; narrowed so tests can fake it. */
|
|
12
12
|
export interface RevivableEngine {
|
|
13
|
-
|
|
13
|
+
/** Boot the engine and revive the snapshot; `skip` boots fresh, deliberately not reviving. */
|
|
14
|
+
restoreState(skip?: boolean): Promise<RestoreResult | null>;
|
|
14
15
|
/** True when this conversation's state dir already exists, so the engine was resumed. */
|
|
15
16
|
hasSnapshotHistory(): boolean;
|
|
16
17
|
}
|
|
@@ -22,11 +23,50 @@ export interface EngineLifecycleDeps<E extends RevivableEngine> {
|
|
|
22
23
|
dispose(engine: E): Promise<void>;
|
|
23
24
|
/** Kill-then-rebuild when a wedged engine cannot serve the snapshot flush. */
|
|
24
25
|
discard?(engine: E): Promise<void>;
|
|
26
|
+
/** Boot deadline in ms; a boot (kernel start, helpers preload, snapshot restore) that
|
|
27
|
+
* outlives it is killed and retried fresh. Default 90s. */
|
|
28
|
+
bootTimeoutMs?: number;
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
/** Sentinel: the boot outlived its deadline. Distinct from null, which means "booted, nothing revived". */
|
|
32
|
+
const BOOT_WEDGED = Symbol("boot wedged");
|
|
33
|
+
|
|
27
34
|
/** `startup` restores then announces on the first cell when the conversation has a saved past; `cell` means an engine was rebuilt mid-session and announces immediately. */
|
|
28
35
|
export type AcquireOrigin = "startup" | "cell";
|
|
29
36
|
|
|
37
|
+
const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
|
|
38
|
+
|
|
39
|
+
/** Model-facing body for a boot whose snapshot revive wedged and was skipped. */
|
|
40
|
+
function revivedNoticeBody(origin: AcquireOrigin): string {
|
|
41
|
+
const resumed = origin === "startup";
|
|
42
|
+
return resumed
|
|
43
|
+
? "This session's evaluator wedged while reviving the saved namespace, so the snapshot was skipped and the namespace is empty."
|
|
44
|
+
: "The evaluator wedged while reviving its saved namespace, so the snapshot was skipped and the namespace is empty.";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Terse TUI toast for the human, separate from the model-facing cell marker: the user
|
|
48
|
+
// --- asked for the classic subtle notification instead of a showy in-cell message. Counts
|
|
49
|
+
// --- come from the same restore the marker describes, so the two never disagree. ---
|
|
50
|
+
export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null): string {
|
|
51
|
+
const resumed = origin === "startup";
|
|
52
|
+
const revived = restore?.restored.length ?? 0;
|
|
53
|
+
const lost = restore?.failed.length ?? 0;
|
|
54
|
+
if (restore && revived > 0) {
|
|
55
|
+
const counts = lost > 0 ? `, ${lost} lost` : "";
|
|
56
|
+
const noun = revived === 1 ? "name" : "names";
|
|
57
|
+
return resumed
|
|
58
|
+
? `repl session resumed, ${revived} ${noun} revived${counts}`
|
|
59
|
+
: `repl kernel rebuilt, ${revived} ${noun} revived${counts}`;
|
|
60
|
+
}
|
|
61
|
+
return resumed
|
|
62
|
+
? restore === null
|
|
63
|
+
? "repl session resumed, nothing saved to revive"
|
|
64
|
+
: "repl session resumed, nothing could be revived"
|
|
65
|
+
: restore === null
|
|
66
|
+
? "repl kernel rebuilt, nothing saved to revive"
|
|
67
|
+
: "repl kernel rebuilt, nothing could be revived";
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireOrigin): string {
|
|
31
71
|
const resumed = origin === "startup";
|
|
32
72
|
const lines = ["<repl_engine_reset>"];
|
|
@@ -82,12 +122,30 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
82
122
|
private engine?: E;
|
|
83
123
|
private revival?: Promise<RestoreResult | null>;
|
|
84
124
|
private pendingNotice?: string;
|
|
125
|
+
private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null };
|
|
85
126
|
private teardown?: Promise<void>;
|
|
86
127
|
/** First-build in progress. */
|
|
87
128
|
private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
|
|
88
129
|
|
|
89
130
|
constructor(private readonly deps: EngineLifecycleDeps<E>) {}
|
|
90
131
|
|
|
132
|
+
/** Race one boot attempt against the deadline. The losing attempt is abandoned (and the
|
|
133
|
+
* engine killed by the caller): its promise gets a no-op catch so killing the kernel
|
|
134
|
+
* cannot surface an unhandled rejection later. */
|
|
135
|
+
private bootOnce(
|
|
136
|
+
engine: E,
|
|
137
|
+
deadlineMs: number,
|
|
138
|
+
skipRestore: boolean,
|
|
139
|
+
): Promise<RestoreResult | null | typeof BOOT_WEDGED> {
|
|
140
|
+
const work = engine.restoreState(skipRestore).catch(() => null);
|
|
141
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
const guard = new Promise<typeof BOOT_WEDGED>((resolve) => {
|
|
143
|
+
timer = setTimeout(() => resolve(BOOT_WEDGED), deadlineMs);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
});
|
|
146
|
+
return Promise.race([work, guard]).finally(() => clearTimeout(timer));
|
|
147
|
+
}
|
|
148
|
+
|
|
91
149
|
/** Built and revived on demand; awaited so callers never see an un-revived namespace. */
|
|
92
150
|
async acquire(origin: AcquireOrigin): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
|
|
93
151
|
if (this.engine) {
|
|
@@ -100,14 +158,46 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
100
158
|
const held: E = this.engine;
|
|
101
159
|
return { engine: held, restore: await this.revival!, created: false };
|
|
102
160
|
}
|
|
103
|
-
|
|
161
|
+
let engine = this.deps.create();
|
|
104
162
|
this.engine = engine;
|
|
105
|
-
|
|
106
|
-
|
|
163
|
+
// --- the boot is bounded: kernel start, helpers preload, and snapshot restore all
|
|
164
|
+
// --- run as kernel cells with no deadline of their own, and acquire() dedupes, so
|
|
165
|
+
// --- one wedged boot (venv swapped mid-update, a poisoned pickle) would otherwise
|
|
166
|
+
// --- hang the first cell and every cell after it. ---
|
|
167
|
+
const deadline = this.deps.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
|
|
168
|
+
let restore = await this.bootOnce(engine, deadline, false);
|
|
169
|
+
if (restore === BOOT_WEDGED) {
|
|
170
|
+
// --- the kernel is alive but stuck; only a kill frees it. Retry once WITHOUT the
|
|
171
|
+
// --- snapshot, so a poisoned pickle cannot wedge the session twice in a row. ---
|
|
172
|
+
await (this.deps.discard ?? this.deps.dispose)(engine);
|
|
173
|
+
engine = this.deps.create();
|
|
174
|
+
this.engine = engine;
|
|
175
|
+
restore = await this.bootOnce(engine, deadline, true);
|
|
176
|
+
if (restore === BOOT_WEDGED) {
|
|
177
|
+
await (this.deps.discard ?? this.deps.dispose)(engine);
|
|
178
|
+
this.engine = undefined;
|
|
179
|
+
throw new Error("evaluator boot timed out twice (kernel/helpers wedged); no session was started");
|
|
180
|
+
}
|
|
181
|
+
// --- the snapshot existed but reviving it is what wedged: say exactly that, not
|
|
182
|
+
// --- "no snapshot available", so the model doesn't go hunting for a missing file ---
|
|
183
|
+
if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
|
|
184
|
+
this.pendingNotice = [
|
|
185
|
+
"<repl_engine_reset>",
|
|
186
|
+
revivedNoticeBody(origin),
|
|
187
|
+
"Re-verify a variable before reusing it, especially inside shell interpolation.",
|
|
188
|
+
"</repl_engine_reset>",
|
|
189
|
+
].join("\n");
|
|
190
|
+
this.pendingReset = { origin, restore: null };
|
|
191
|
+
}
|
|
192
|
+
this.revival = Promise.resolve(null);
|
|
193
|
+
return { engine, restore: null, created: true };
|
|
194
|
+
}
|
|
195
|
+
this.revival = Promise.resolve(restore);
|
|
107
196
|
// --- mid-session rebuilds always announce; startup announces only when the
|
|
108
197
|
// --- conversation has a saved past, so a first-ever session stays quiet ---
|
|
109
198
|
if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
|
|
110
199
|
this.pendingNotice = formatEngineResetNotice(restore, origin);
|
|
200
|
+
this.pendingReset = { origin, restore };
|
|
111
201
|
}
|
|
112
202
|
return { engine, restore, created: true };
|
|
113
203
|
})();
|
|
@@ -119,11 +209,14 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
119
209
|
}
|
|
120
210
|
}
|
|
121
211
|
|
|
122
|
-
/** Returns the pending reset notice exactly once, then clears it. */
|
|
123
|
-
takeResetNotice(): string | undefined {
|
|
212
|
+
/** Returns the pending reset notice exactly once (alongside its origin and restore result), then clears it. */
|
|
213
|
+
takeResetNotice(): { notice: string; origin: AcquireOrigin; restore: RestoreResult | null } | undefined {
|
|
214
|
+
const reset = this.pendingReset;
|
|
124
215
|
const notice = this.pendingNotice;
|
|
125
216
|
this.pendingNotice = undefined;
|
|
126
|
-
|
|
217
|
+
this.pendingReset = undefined;
|
|
218
|
+
if (!notice || !reset) return undefined;
|
|
219
|
+
return { notice, origin: reset.origin, restore: reset.restore };
|
|
127
220
|
}
|
|
128
221
|
|
|
129
222
|
async shutdown(): Promise<void> {
|