pi-repl-py 0.6.12 → 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.
@@ -205,6 +205,14 @@ global one and both sides are guaranteed to agree. The venv is built automatical
205
205
  interpreter follows the order above. No setting is needed. The per-cell silence watchdog is
206
206
  off by default (`PI_REPL_TIMEOUT_MS=0`: a silent but working cell may run on).
207
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
+
208
216
  ## Reference documentation
209
217
 
210
218
  - Design rationale: [design.md](design.md)
package/index.ts CHANGED
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.6.12",
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": [
@@ -387,11 +387,15 @@ export class EngineManager {
387
387
  }
388
388
  }
389
389
 
390
- 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;
391
396
  const config = this.options.snapshot;
392
397
  if (!config) return null;
393
398
  if (!existsSync(config.path)) return null;
394
- await this.start();
395
399
  try {
396
400
  const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
397
401
  version?: number;
@@ -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: a small unique anchor, replace, verify, read the file back before trusting it.",
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
- restoreState(): Promise<RestoreResult | null>;
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,27 @@ 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
+
30
47
  // --- Terse TUI toast for the human, separate from the model-facing cell marker: the user
31
48
  // --- asked for the classic subtle notification instead of a showy in-cell message. Counts
32
49
  // --- come from the same restore the marker describes, so the two never disagree. ---
@@ -112,6 +129,23 @@ export class EngineLifecycle<E extends RevivableEngine> {
112
129
 
113
130
  constructor(private readonly deps: EngineLifecycleDeps<E>) {}
114
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
+
115
149
  /** Built and revived on demand; awaited so callers never see an un-revived namespace. */
116
150
  async acquire(origin: AcquireOrigin): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
117
151
  if (this.engine) {
@@ -124,10 +158,41 @@ export class EngineLifecycle<E extends RevivableEngine> {
124
158
  const held: E = this.engine;
125
159
  return { engine: held, restore: await this.revival!, created: false };
126
160
  }
127
- const engine = this.deps.create();
161
+ let engine = this.deps.create();
128
162
  this.engine = engine;
129
- this.revival = engine.restoreState().catch(() => null);
130
- const restore = await this.revival;
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);
131
196
  // --- mid-session rebuilds always announce; startup announces only when the
132
197
  // --- conversation has a saved past, so a first-ever session stays quiet ---
133
198
  if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {