pi-mega-compact 0.9.0 → 0.9.2

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/README.md CHANGED
@@ -61,6 +61,7 @@ Set env vars before starting pi. Defaults are in `src/config/dedup.ts`.
61
61
  | `MEGACOMPACT_DEDUP_SIM` | `0.90` | Cosine threshold for near-dup collapse |
62
62
  | `MEGACOMPACT_CROSSREPO_ENABLED` | `true` | Cross-repo recall on resume |
63
63
  | `MEGACOMPACT_EMBEDDING_URL` | _(unset)_ | BYO localhost embedder endpoint |
64
+ | `MEGACOMPACT_TUI_WIDGET` | `true` | Render the above-editor panel. Set to `0` to suppress it — useful when pi runs inside an editor terminal (e.g. Neovim `:terminal`) where you drive scrollback yourself and the panel's repaints fight it. Compaction is unaffected. |
64
65
 
65
66
  Full config reference: [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md)
66
67
 
@@ -25,6 +25,8 @@
25
25
  * This file is the thin wiring layer: it owns the default export, constructs
26
26
  * the runtime, and registers handlers/commands. Behavior is unchanged.
27
27
  */
28
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
29
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
28
30
  import { loadConfig } from "./mega-config.js";
29
31
  import { MegaRuntime } from "./mega-runtime.js";
30
32
  import { registerEventHandlers } from "./mega-events.js";
@@ -79,5 +81,18 @@ export default function (pi) {
79
81
  // dispose() is idempotent, and the next snapshot() re-opens the watcher
80
82
  // lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
81
83
  // leak and no per-session fd accumulation.
82
- pi.on("session_shutdown", () => runtime.dispose());
84
+ // The PGlite indexes (vectorIndex / memoryIndex) are lazily opened module
85
+ // singletons. closeVectorIndex()/closeMemoryIndex() existed but had no
86
+ // non-test callers, so a session left both open: PGlite is WASM Postgres and
87
+ // its handles keep node's event loop alive, so `pi -p` produced its answer
88
+ // and then hung until killed rather than exiting. dispose() only released the
89
+ // fs.watch handle and the perf interval, neither of which was the culprit
90
+ // (the interval is unref'd).
91
+ //
92
+ // Both closes are idempotent and safe when the index was never opened, and
93
+ // the next initVectorIndex()/initMemoryIndex() re-opens lazily.
94
+ pi.on("session_shutdown", async () => {
95
+ runtime.dispose();
96
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
97
+ });
83
98
  }
@@ -160,6 +160,7 @@ export function loadConfig() {
160
160
  memoryReviewInterval: envFlag("MEGACOMPACT_MEMORY_REVIEW_INTERVAL", 10),
161
161
  recallMaxTokens: envFlag("MEGACOMPACT_RECALL_MAX_TOKENS", 1500),
162
162
  windowDedupe: envBool("MEGACOMPACT_WINDOW_DEDUPE", true),
163
+ tuiWidget: envBool("MEGACOMPACT_TUI_WIDGET", true),
163
164
  debug: envBool("MEGACOMPACT_DEBUG", false),
164
165
  };
165
166
  }
@@ -108,6 +108,13 @@ export function ensureGameStateWatcherImpl(self, view) {
108
108
  }
109
109
  }
110
110
  });
111
+ // The watcher is a cache-eviction convenience, never a reason to stay
112
+ // alive: an active+referenced fs_event handle holds node's event loop
113
+ // open, so a runtime that was never dispose()d (every extension test, and
114
+ // any `pi -p` run that skips session_shutdown) hangs the process after all
115
+ // work is done. unref'd like the perf interval — while pi runs there are
116
+ // always other referenced handles, so the watcher still fires normally.
117
+ self.gameStateWatcher.unref?.();
111
118
  self.gameStateWatchDir = self.currentStateDir;
112
119
  }
113
120
  catch {
@@ -32,6 +32,14 @@ export function resetRuntimeImpl(self, sessionId) {
32
32
  errorRetryCount: 0,
33
33
  errorRetryUntil: 0,
34
34
  consecutiveErrors: 0,
35
+ lastErrorRetryAt: 0,
36
+ retryNudgePending: false,
37
+ errorRetrySessionCount: 0,
38
+ lastErrorText: undefined,
39
+ errorTextRepeatCount: 0,
40
+ poisonedAdviseSent: false,
41
+ poisonedCompactSignatures: new Set(),
42
+ poisonedCount: 0,
35
43
  };
36
44
  self.trimCache = null; // v0.8.6: never replay a stale trim into a new session
37
45
  self.statusKey = undefined;
@@ -23,6 +23,7 @@ import { captureModelImpl } from "./capture-model.js";
23
23
  import { bindRepoImpl } from "./bind-repo.js";
24
24
  import { snapshotImpl } from "./runtime-snapshot.js";
25
25
  import { pressureImpl, effectiveThresholdImpl, pressureBandImpl, } from "./pressure-getters.js";
26
+ import { resetRuntimeImpl } from "./reset-runtime.js";
26
27
  import { appendEventImpl } from "./append-event.js";
27
28
  import { getStateDirImpl } from "./get-state-dir.js";
28
29
  import { renderWidgetImpl } from "./render-widget.js";
@@ -56,7 +57,6 @@ export class MegaRuntime {
56
57
  errorRetryCount: 0,
57
58
  errorRetryUntil: 0,
58
59
  consecutiveErrors: 0,
59
- // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
60
60
  lastErrorRetryAt: 0,
61
61
  retryNudgePending: false,
62
62
  errorRetrySessionCount: 0,
@@ -255,8 +255,15 @@ export class MegaRuntime {
255
255
  snapshotImpl(this, ctx);
256
256
  }
257
257
  /** Width-aware above-editor widget factory registration — thin delegate to
258
- * `renderWidgetImpl` (render-widget.ts). */
258
+ * `renderWidgetImpl` (render-widget.ts).
259
+ *
260
+ * Gated on `config.tuiWidget` (MEGACOMPACT_TUI_WIDGET=0 to disable). This
261
+ * is the single chokepoint every caller funnels through, so returning here
262
+ * means setWidget is never called and the panel is never registered — as
263
+ * opposed to registering an empty one, which would still occupy a row. */
259
264
  renderWidget(ctx) {
265
+ if (!this.config.tuiWidget)
266
+ return;
260
267
  renderWidgetImpl(this, ctx);
261
268
  }
262
269
  /** Mirror the dashboard status text onto pi's status line — thin delegate to
@@ -264,55 +271,10 @@ export class MegaRuntime {
264
271
  setStatus(ctx, text) {
265
272
  setStatusImpl(this, ctx, text);
266
273
  }
267
- /** Per-session state reset (session_start / session_tree) — inlined by the
268
- * raptor-promotion merge (R1–R3 retry-redesign fields); reset-runtime.ts was
269
- * retired by that branch. */
274
+ /** Per-session state reset (session_start / session_tree) — thin delegate to
275
+ * `resetRuntimeImpl` (reset-runtime.ts). */
270
276
  resetRuntime(sessionId) {
271
- const sid = normalizeSessionId(sessionId);
272
- if (this.rt.sessionId === sid && this.rt.persistedThisSession)
273
- return; // same session, keep checkpoint memory
274
- this.rt = {
275
- sessionId: sid,
276
- persistedThisSession: false,
277
- lastCheckpointId: undefined,
278
- lastCompactedFrom: 0,
279
- lastCompactedTokens: 0,
280
- dedupSkips: 0,
281
- dedupAttempts: 0,
282
- tokensSaved: 0,
283
- lastCompactAt: null,
284
- lastNativeCompactAt: null,
285
- compactCount: 0,
286
- recallInjections: 0,
287
- cacheHitTokens: 0,
288
- lengthStopPending: false,
289
- errorRetryCount: 0,
290
- errorRetryUntil: 0,
291
- consecutiveErrors: 0,
292
- // R1-R3 (retry redesign): in-flight dedup, session cap, poisoned-context state.
293
- lastErrorRetryAt: 0,
294
- retryNudgePending: false,
295
- errorRetrySessionCount: 0,
296
- lastErrorText: undefined,
297
- errorTextRepeatCount: 0,
298
- poisonedAdviseSent: false,
299
- poisonedCompactSignatures: new Set(),
300
- poisonedCount: 0,
301
- };
302
- this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
303
- this.statusKey = undefined;
304
- this.activeAgents = 0;
305
- this.currentTurn = 0;
306
- this.lastActivityAt = 0;
307
- this.tierTrace = undefined;
308
- this.ticker.length = 0;
309
- this.pulsing = false;
310
- this.savedGoal = 50_000;
311
- this.lastWhy = undefined;
312
- // S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
313
- // that re-binds the repo, so drop the memo too. Cheap; the next
314
- // getCachedGameState() re-queries lazily.
315
- this.cachedGameState = undefined;
277
+ resetRuntimeImpl(this, sessionId);
316
278
  }
317
279
  captureModel(ctx) {
318
280
  captureModelImpl(this, ctx);
@@ -0,0 +1,121 @@
1
+ /**
2
+ * mega-shutdown-widget.test.ts — regression cover for two host-integration
3
+ * fixes that the rest of the suite cannot see, because both are about what
4
+ * happens at the boundary with pi rather than inside the compaction engine:
5
+ *
6
+ * 1. session_shutdown must close the PGlite indexes. They are lazily-opened
7
+ * module singletons whose handles keep node's event loop alive, so leaving
8
+ * them open made `pi -p` produce its answer and then hang instead of
9
+ * exiting. closeVectorIndex()/closeMemoryIndex() existed but had no
10
+ * non-test callers.
11
+ *
12
+ * 2. The above-editor widget must be suppressible. It is a persistent,
13
+ * animated, full-width panel that repaints on its own cadence, which fights
14
+ * terminals where the user drives scrollback (pi inside a Neovim
15
+ * `:terminal`). MEGACOMPACT_TUI_WIDGET=0 turns it off without touching
16
+ * compaction.
17
+ */
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { closeVectorIndex, initVectorIndex, isVectorIndexDisabled, } from "../src/store/vectorIndex.js";
24
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
25
+ import { loadConfig } from "./mega-config.js";
26
+ import { MegaRuntime } from "./mega-runtime.js";
27
+ const baseTmp = mkdtempSync(join(tmpdir(), "mc-shutdown-"));
28
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
29
+ let counter = 0;
30
+ /** Fresh per-test state dir so concurrent runs never collide on disk. */
31
+ function isolate() {
32
+ process.env.MEGACOMPACT_STATE_DIR = join(baseTmp, `run-${counter++}`);
33
+ }
34
+ /** Minimal ExtensionContext slice: renderWidget only reaches ctx.ui.setWidget. */
35
+ function widgetCtx(calls) {
36
+ return {
37
+ ui: {
38
+ setWidget: (key) => calls.push(key),
39
+ },
40
+ };
41
+ }
42
+ test.after(async () => {
43
+ // This file is itself a demonstration of the bug under test: without these
44
+ // closes the PGlite handles opened above keep the event loop alive and the
45
+ // test process never exits.
46
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
47
+ rmSync(baseTmp, { recursive: true, force: true });
48
+ });
49
+ test("MEGACOMPACT_TUI_WIDGET defaults on and is disabled by 0", () => {
50
+ isolate();
51
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
52
+ assert.equal(loadConfig().tuiWidget, true, "widget should default to on");
53
+ process.env.MEGACOMPACT_TUI_WIDGET = "0";
54
+ assert.equal(loadConfig().tuiWidget, false);
55
+ process.env.MEGACOMPACT_TUI_WIDGET = "1";
56
+ assert.equal(loadConfig().tuiWidget, true);
57
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
58
+ });
59
+ test("renderWidget registers the panel when tuiWidget is on", () => {
60
+ isolate();
61
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
62
+ const runtime = new MegaRuntime(loadConfig());
63
+ try {
64
+ const calls = [];
65
+ runtime.renderWidget(widgetCtx(calls));
66
+ assert.equal(calls.length, 1, "expected one setWidget registration");
67
+ }
68
+ finally {
69
+ runtime.dispose();
70
+ }
71
+ });
72
+ test("renderWidget registers nothing when tuiWidget is off", () => {
73
+ isolate();
74
+ process.env.MEGACOMPACT_TUI_WIDGET = "0";
75
+ const runtime = new MegaRuntime(loadConfig());
76
+ try {
77
+ const calls = [];
78
+ // Repeated calls, because the panel is re-registered on every snapshot
79
+ // and every game-state change — one guarded path is not enough.
80
+ runtime.renderWidget(widgetCtx(calls));
81
+ runtime.renderWidget(widgetCtx(calls));
82
+ assert.deepEqual(calls, [], "widget must never be registered when disabled");
83
+ }
84
+ finally {
85
+ runtime.dispose();
86
+ delete process.env.MEGACOMPACT_TUI_WIDGET;
87
+ }
88
+ });
89
+ test("the extension's session_shutdown handler awaits index teardown", async () => {
90
+ isolate();
91
+ const handlers = {};
92
+ const pi = {
93
+ on(event, handler) {
94
+ (handlers[event] ??= []).push(handler);
95
+ },
96
+ registerCommand() { },
97
+ registerProvider() { },
98
+ };
99
+ const { default: extension } = await import("./mega-compact.js");
100
+ extension(pi);
101
+ const shutdown = handlers["session_shutdown"] ?? [];
102
+ assert.ok(shutdown.length, "extension must register a session_shutdown handler");
103
+ const event = { type: "session_shutdown" };
104
+ const ctx = {
105
+ ui: { setStatus: () => { }, notify: () => { }, setWidget: () => { } },
106
+ cwd: process.env.MEGACOMPACT_STATE_DIR,
107
+ };
108
+ // Assert the close by identity rather than by "some handler returned a
109
+ // promise": several modules register on session_shutdown and at least one
110
+ // other is already async, so a promise proves nothing about teardown. The
111
+ // index is a module singleton, so if shutdown really closed it, re-init
112
+ // hands back a *different* instance.
113
+ const before = await initVectorIndex();
114
+ if (isVectorIndexDisabled() || !before)
115
+ return; // PGlite unavailable — nothing to close
116
+ await Promise.all(shutdown.map((handler) => handler(event, ctx)));
117
+ const after = await initVectorIndex();
118
+ assert.notEqual(after, before, "session_shutdown must close the PGlite vector index");
119
+ // Idempotent: a second shutdown (reload, double-fire) must not throw.
120
+ await Promise.all(shutdown.map((handler) => handler(event, ctx)));
121
+ });
@@ -26,7 +26,7 @@ function firstText(m) {
26
26
  /** Heuristic: does this text look like chatty filler we can collapse? */
27
27
  export function isChatty(text) {
28
28
  const low = text.toLowerCase();
29
- if (low.includes("hello") || low.includes("thanks") || low.includes("great") || low.includes("ok")) {
29
+ if (/\b(hello|thanks|great|ok)\b/i.test(low)) {
30
30
  return true;
31
31
  }
32
32
  return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
@@ -141,7 +141,9 @@ export function summarizeMessages(messages) {
141
141
  const users = messages.filter((m) => m.role === "user").length;
142
142
  const assistants = messages.filter((m) => m.role === "assistant").length;
143
143
  const tools = messages.filter((m) => m.role === "tool").length;
144
- const toolNames = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
144
+ const toolNames = [
145
+ ...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : []))),
146
+ ].sort();
145
147
  const lines = [
146
148
  "<summary>",
147
149
  "Conversation summary:",
@@ -1,3 +1,18 @@
1
+ // This file exercises the sync node:sqlite memory ops only, but every
2
+ // applyMemoryOps() write also fires indexMemoryWrite() → upsertMemoryEmbedding()
3
+ // at the machine-wide PGlite index, fire-and-forget, keyed by repoKey(stateDir)
4
+ // — which for these tmp state dirs is the tmp path itself. Left alone that lands
5
+ // in the developer's real ~/.pi/mega-compact-vector, where the rows stay
6
+ // eligible for cross-repo recall forever: a live session can be handed
7
+ // "threshold is 50k" and "the threshold is 100k" from this file as if they were
8
+ // another project's memories. scripts/run-tests.mjs sets MEGACOMPACT_INDEX_DIR
9
+ // per child, so the suite was already safe; running the file directly was not.
10
+ //
11
+ // Disabling PGlite is the same guard memoryRoundtrip.test.ts uses for the same
12
+ // reason, and it also keeps the file's exit clean (no WASM handle, and no
13
+ // initdb still running when the first test ends). MEGACOMPACT_INDEX_DIR is set
14
+ // as well so the isolation holds if a later test needs the index re-enabled.
15
+ process.env.MEGACOMPACT_PGLITE_DISABLED = "true";
1
16
  import { test } from "node:test";
2
17
  import assert from "node:assert/strict";
3
18
  import { mkdtempSync, rmSync } from "node:fs";
@@ -6,6 +21,7 @@ import { join } from "node:path";
6
21
  import { applyMemoryOps } from "./memoryOps.js";
7
22
  import { addMemory, listMemories, replaceMemory, referenceMemory, MEMORY_MAX_CHARS, } from "./store/sqlite.js";
8
23
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
24
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
9
25
  test("applyMemoryOps: ADD inserts a new memory", async () => {
10
26
  const dir = join(baseTmp, "add");
11
27
  await applyMemoryOps([{ op: "add", memory: { content: "we use node:sqlite as the store", category: "decision", sourceTurn: 0 } }], dir);
@@ -24,6 +24,7 @@ import { join } from "node:path";
24
24
  import { mkdirSync, rmSync, existsSync } from "node:fs";
25
25
  /** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
26
26
  export const MEMORY_INDEX_DIM = 512;
27
+ import { withOpenTimeout } from "./pgOpenGuard.js";
27
28
  let db;
28
29
  let initPromise;
29
30
  let disabled = false;
@@ -110,12 +111,18 @@ async function openPgLite(retryOnCorrupt) {
110
111
  return undefined;
111
112
  const dir = indexDir();
112
113
  mkdirSync(dir, { recursive: true });
113
- const pg = await new mod.PGlite({
114
- dataDir: dir,
115
- extensions: { vector: mod.vector },
116
- });
117
- await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
118
- await pg.exec(`
114
+ // Bounded open: PGlite is single-writer over a shared dataDir, so a second
115
+ // pi process on the same dir can block here forever. Without the ceiling the
116
+ // never-settling promise gets cached in initPromise and every later caller
117
+ // awaits it — which is how a stalled index wedged a whole pi turn.
118
+ let openTimedOut = false;
119
+ const pg = await withOpenTimeout((async () => {
120
+ const inst = await new mod.PGlite({
121
+ dataDir: dir,
122
+ extensions: { vector: mod.vector },
123
+ });
124
+ await inst.exec("CREATE EXTENSION IF NOT EXISTS vector;");
125
+ await inst.exec(`
119
126
  CREATE TABLE IF NOT EXISTS memory_index (
120
127
  repo_id TEXT NOT NULL,
121
128
  memory_id INTEGER NOT NULL,
@@ -124,7 +131,22 @@ async function openPgLite(retryOnCorrupt) {
124
131
  PRIMARY KEY (repo_id, memory_id)
125
132
  );
126
133
  `);
127
- await pg.exec("CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);");
134
+ await inst.exec("CREATE INDEX IF NOT EXISTS memory_index_hnsw ON memory_index USING hnsw (embedding vector_cosine_ops);");
135
+ return inst;
136
+ })(), (reason) => {
137
+ openTimedOut = true;
138
+ logWarn(`init ${reason}`);
139
+ });
140
+ if (!pg) {
141
+ if (openTimedOut) {
142
+ // Don't leave the dead open cached, and don't retry on the next call —
143
+ // a contended dataDir would just burn another full timeout per caller.
144
+ // Same terminal state as any other init failure: fall back to the scan.
145
+ initPromise = undefined;
146
+ disabled = true;
147
+ }
148
+ return undefined;
149
+ }
128
150
  db = pg;
129
151
  return pg;
130
152
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * pgOpenGuard.ts — bound the PGlite open so a stalled WASM init can never wedge
3
+ * a pi turn.
4
+ *
5
+ * Both index modules (vectorIndex / memoryIndex) cache their in-flight open in a
6
+ * module-level `initPromise`. That cache is what turns a single stalled open
7
+ * into a permanent hang: PGlite is a single-writer WASM Postgres over a shared
8
+ * dataDir (~/.pi/mega-compact-vector), so a second pi process opening the same
9
+ * dir can block indefinitely. `await new PGlite(...)` then never settles, the
10
+ * never-settling promise is cached, and every later caller awaits that same dead
11
+ * promise — with no timers and no sockets left, node reports
12
+ * "Promise resolution is still pending but the event loop has already resolved"
13
+ * and the pi turn that awaited it never ends.
14
+ *
15
+ * withOpenTimeout() puts a ceiling on that wait. On timeout the caller gets
16
+ * undefined (both modules already degrade to a synchronous scan), and the
17
+ * abandoned open is disowned: if it does eventually settle, the instance is
18
+ * closed so a stray PGlite can't keep the loop alive or hold the dataDir lock.
19
+ *
20
+ * A rejected open is NOT swallowed — it propagates so the callers' existing
21
+ * corrupt-dir detection (Aborted / RuntimeError → wipe + one retry) still runs.
22
+ * Only the timeout resolves to undefined.
23
+ */
24
+ /** Sentinel so a legitimately-undefined open is distinguishable from a timeout. */
25
+ const TIMED_OUT = Symbol("pglite-open-timeout");
26
+ /** Default ceiling for a PGlite open. Generous — a cold WASM + HNSW init is slow. */
27
+ export const DEFAULT_PG_OPEN_TIMEOUT_MS = 30_000;
28
+ /** Resolve the open timeout. 0 (or negative) disables the guard entirely. */
29
+ export function pgOpenTimeoutMs() {
30
+ const raw = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
31
+ if (raw === undefined || raw.trim() === "")
32
+ return DEFAULT_PG_OPEN_TIMEOUT_MS;
33
+ const n = Number(raw);
34
+ if (!Number.isFinite(n) || n < 0)
35
+ return DEFAULT_PG_OPEN_TIMEOUT_MS;
36
+ return n;
37
+ }
38
+ /**
39
+ * Race `open` against the configured timeout.
40
+ *
41
+ * Resolves to the opened value, or to undefined when the open outruns the
42
+ * timeout (`onTimeout` fires first so the caller can log and flip its own
43
+ * disabled state). Rejections propagate to the caller unchanged.
44
+ */
45
+ export async function withOpenTimeout(open, onTimeout, timeoutMs = pgOpenTimeoutMs()) {
46
+ // Guard disabled — preserve the original unbounded behavior verbatim.
47
+ if (timeoutMs <= 0)
48
+ return open;
49
+ let timer;
50
+ const expiry = new Promise((resolve) => {
51
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
52
+ // Never hold the process open on account of the guard itself.
53
+ timer.unref?.();
54
+ });
55
+ try {
56
+ // `open` is raced as-is so a rejection rejects the race — and therefore
57
+ // this function — leaving the caller's corrupt-retry path intact.
58
+ const winner = await Promise.race([open, expiry]);
59
+ if (winner === TIMED_OUT) {
60
+ // Disown the open. If it ever settles, close the instance so an orphaned
61
+ // PGlite cannot keep the event loop alive or hold the dataDir lock.
62
+ void open
63
+ .then((late) => {
64
+ try {
65
+ void late?.close?.();
66
+ }
67
+ catch {
68
+ /* ignore */
69
+ }
70
+ })
71
+ .catch(() => {
72
+ /* the abandoned open failed on its own — nothing left to release */
73
+ });
74
+ onTimeout(`timed out after ${timeoutMs}ms`);
75
+ return undefined;
76
+ }
77
+ return winner;
78
+ }
79
+ finally {
80
+ if (timer)
81
+ clearTimeout(timer);
82
+ }
83
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * pgOpenGuard.test.ts — the PGlite open must never hang a turn.
3
+ *
4
+ * Regression cover for the wedge: a stalled `await new PGlite(...)` was cached
5
+ * in initPromise, so every later caller awaited a promise that could not settle
6
+ * and the pi turn awaiting it never ended.
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { withOpenTimeout, pgOpenTimeoutMs, DEFAULT_PG_OPEN_TIMEOUT_MS } from "./pgOpenGuard.js";
11
+ test("a never-settling open resolves to undefined instead of hanging", async () => {
12
+ const never = new Promise(() => {
13
+ /* deliberately never settles — the wedge */
14
+ });
15
+ const reasons = [];
16
+ const t0 = Date.now();
17
+ const result = await withOpenTimeout(never, (r) => reasons.push(r), 50);
18
+ assert.equal(result, undefined, "caller gets undefined and can fall back");
19
+ assert.ok(Date.now() - t0 < 5_000, "returned promptly rather than hanging");
20
+ assert.equal(reasons.length, 1, "onTimeout fired exactly once");
21
+ assert.match(reasons[0], /timed out after 50ms/);
22
+ });
23
+ test("a successful open passes its value through untouched", async () => {
24
+ const reasons = [];
25
+ const result = await withOpenTimeout(Promise.resolve("pg"), (r) => reasons.push(r), 5_000);
26
+ assert.equal(result, "pg");
27
+ assert.deepEqual(reasons, [], "no timeout reported on the happy path");
28
+ });
29
+ test("a rejected open propagates so the corrupt-dir retry still runs", async () => {
30
+ const reasons = [];
31
+ await assert.rejects(() => withOpenTimeout(Promise.reject(new Error("Aborted()")), (r) => reasons.push(r), 5_000), /Aborted/, "rejection reaches the caller's catch, which owns the wipe-and-retry path");
32
+ assert.deepEqual(reasons, [], "a rejection is not reported as a timeout");
33
+ });
34
+ test("an abandoned open is closed if it settles after the timeout", async () => {
35
+ let closed = false;
36
+ let release = () => { };
37
+ const late = new Promise((r) => {
38
+ release = r;
39
+ });
40
+ const result = await withOpenTimeout(late, () => { }, 25);
41
+ assert.equal(result, undefined, "timed out first");
42
+ // The open finally completes, long after we stopped waiting for it.
43
+ release({
44
+ close: () => {
45
+ closed = true;
46
+ },
47
+ });
48
+ await late;
49
+ await new Promise((r) => setImmediate(r));
50
+ assert.ok(closed, "the orphaned instance was closed, not left holding the dataDir");
51
+ });
52
+ test("timeout of 0 disables the guard (unbounded, original behavior)", async () => {
53
+ const result = await withOpenTimeout(Promise.resolve("pg"), () => { }, 0);
54
+ assert.equal(result, "pg");
55
+ });
56
+ test("pgOpenTimeoutMs honors the env override and rejects junk", async () => {
57
+ const prev = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
58
+ try {
59
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = "1234";
60
+ assert.equal(pgOpenTimeoutMs(), 1234);
61
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = "0";
62
+ assert.equal(pgOpenTimeoutMs(), 0, "0 is a valid opt-out, not junk");
63
+ for (const junk of ["", " ", "abc", "-5"]) {
64
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = junk;
65
+ assert.equal(pgOpenTimeoutMs(), DEFAULT_PG_OPEN_TIMEOUT_MS, `junk "${junk}" falls back`);
66
+ }
67
+ }
68
+ finally {
69
+ if (prev === undefined)
70
+ delete process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
71
+ else
72
+ process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS = prev;
73
+ }
74
+ });
@@ -20,6 +20,7 @@ import { join } from "node:path";
20
20
  import { mkdirSync, rmSync, existsSync } from "node:fs";
21
21
  /** Vector dimension produced by the default TrigramEmbedder (src/embedder.ts). */
22
22
  export const EMBEDDING_DIM = 512;
23
+ import { withOpenTimeout } from "./pgOpenGuard.js";
23
24
  let db;
24
25
  let initPromise;
25
26
  let disabled = false;
@@ -107,12 +108,18 @@ async function openPgLite(retryOnCorrupt) {
107
108
  return undefined;
108
109
  const dir = indexDir();
109
110
  mkdirSync(dir, { recursive: true });
110
- const pg = await new mod.PGlite({
111
- dataDir: dir,
112
- extensions: { vector: mod.vector },
113
- });
114
- await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
115
- await pg.exec(`
111
+ // Bounded open: PGlite is single-writer over a shared dataDir, so a second
112
+ // pi process on the same dir can block here forever. Without the ceiling the
113
+ // never-settling promise gets cached in initPromise and every later caller
114
+ // awaits it — which is how a stalled index wedged a whole pi turn.
115
+ let openTimedOut = false;
116
+ const pg = await withOpenTimeout((async () => {
117
+ const inst = await new mod.PGlite({
118
+ dataDir: dir,
119
+ extensions: { vector: mod.vector },
120
+ });
121
+ await inst.exec("CREATE EXTENSION IF NOT EXISTS vector;");
122
+ await inst.exec(`
116
123
  CREATE TABLE IF NOT EXISTS vector_index (
117
124
  repo_id TEXT NOT NULL,
118
125
  session_id TEXT NOT NULL,
@@ -121,8 +128,23 @@ async function openPgLite(retryOnCorrupt) {
121
128
  PRIMARY KEY (repo_id, session_id, checkpoint_id)
122
129
  );
123
130
  `);
124
- // HNSW index over cosine distance for fast NN. Created idempotently.
125
- await pg.exec("CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);");
131
+ // HNSW index over cosine distance for fast NN. Created idempotently.
132
+ await inst.exec("CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);");
133
+ return inst;
134
+ })(), (reason) => {
135
+ openTimedOut = true;
136
+ logWarn(`init ${reason}`);
137
+ });
138
+ if (!pg) {
139
+ if (openTimedOut) {
140
+ // Don't leave the dead open cached, and don't retry on the next call —
141
+ // a contended dataDir would just burn another full timeout per caller.
142
+ // Same terminal state as any other init failure: fall back to the scan.
143
+ initPromise = undefined;
144
+ disabled = true;
145
+ }
146
+ return undefined;
147
+ }
126
148
  db = pg;
127
149
  return pg;
128
150
  }
@@ -27,6 +27,8 @@
27
27
  */
28
28
 
29
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { closeVectorIndex } from "../src/store/vectorIndex.js";
31
+ import { closeMemoryIndex } from "../src/store/memoryIndex.js";
30
32
  import { loadConfig } from "./mega-config.js";
31
33
  import { MegaRuntime } from "./mega-runtime.js";
32
34
  import { registerEventHandlers } from "./mega-events.js";
@@ -82,5 +84,18 @@ export default function (pi: ExtensionAPI) {
82
84
  // dispose() is idempotent, and the next snapshot() re-opens the watcher
83
85
  // lazily via bindRepo() → ensureGameStateWatcher(), so there is no permanent
84
86
  // leak and no per-session fd accumulation.
85
- pi.on("session_shutdown", () => runtime.dispose());
87
+ // The PGlite indexes (vectorIndex / memoryIndex) are lazily opened module
88
+ // singletons. closeVectorIndex()/closeMemoryIndex() existed but had no
89
+ // non-test callers, so a session left both open: PGlite is WASM Postgres and
90
+ // its handles keep node's event loop alive, so `pi -p` produced its answer
91
+ // and then hung until killed rather than exiting. dispose() only released the
92
+ // fs.watch handle and the perf interval, neither of which was the culprit
93
+ // (the interval is unref'd).
94
+ //
95
+ // Both closes are idempotent and safe when the index was never opened, and
96
+ // the next initVectorIndex()/initMemoryIndex() re-opens lazily.
97
+ pi.on("session_shutdown", async () => {
98
+ runtime.dispose();
99
+ await Promise.all([closeVectorIndex(), closeMemoryIndex()]);
100
+ });
86
101
  }