Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/pi-plugin/src/commands/ctx-wrapup.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/// <reference types="bun-types" />

import { describe, expect, it, mock } from "bun:test";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
acquireCompartmentLease,
releaseCompartmentLease,
Expand Down Expand Up @@ -39,6 +41,14 @@ function createDb(): Database {
return db;
}

function createFileDb(name: string): Database {
const path = join(tmpdir(), `${name}-${crypto.randomUUID()}.db`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Temporary database files accumulate

Each execution creates a UUID-named SQLite file under the system temporary directory, but teardown only closes the database handle. Repeated local and CI runs therefore leave the generated pi-wrapup-busy-release-*.db files behind.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Each execution leaves the file-backed test database in the shared temporary directory. Track the generated path and remove the database artifacts in finally, preferably by creating a temporary directory and deleting that directory after closing all connections.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi-plugin/src/commands/ctx-wrapup.test.ts, line 45:

<comment>Each execution leaves the file-backed test database in the shared temporary directory. Track the generated path and remove the database artifacts in `finally`, preferably by creating a temporary directory and deleting that directory after closing all connections.</comment>

<file context>
@@ -39,6 +41,14 @@ function createDb(): Database {
 }
 
+function createFileDb(name: string): Database {
+	const path = join(tmpdir(), `${name}-${crypto.randomUUID()}.db`);
+	const db = new Database(path);
+	initializeDatabase(db);
</file context>

const db = new Database(path);
initializeDatabase(db);
runMigrations(db);
return db;
}

function branch(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `m-${index + 1}`,
Expand Down Expand Up @@ -276,6 +286,54 @@ describe("Pi /ctx-wrapup", () => {
}
});

it("survives a busy-database lease release failure without rejecting", async () => {
// Cross-connection lock contention requires a shared file; two :memory:
// databases never contend. Mirrors the production shape: a second
// connection (omp session, dreamer, FTS indexer) holds BEGIN IMMEDIATE
// past busy_timeout while wrapup releases its compartment lease.
const db = createFileDb("pi-wrapup-busy-release");
try {
const sessionId = "pi-wrapup-busy-release";
const runPiHistorianForWrapup = mock(async (args) => {
const start = getLastCompartmentEndMessage(db, sessionId) + 1;
const end = Math.min(
args.boundarySnapshot.eligibleEndOrdinal - 1,
start + 2,
);
appendRange(db, sessionId, start, end);
args.onPublished?.();
// Simulate the production contention window: another process holds
// the writer lock past busy_timeout while wrapup releases its lease.
const blocker = new Database(db.filename);
blocker.exec("PRAGMA busy_timeout=1; BEGIN IMMEDIATE;");
try {
// Shrink the release connection's busy wait so the expected
// SQLITE_BUSY lands well inside bun's 5s default test timeout
// (initializeDatabase sets 5000ms; plain `bun test` in CI has
// no override — project constraint #5917).
db.exec("PRAGMA busy_timeout=250");
releaseCompartmentLease(db, sessionId, "stale-holder");
} finally {
blocker.exec("ROLLBACK");
blocker.close();
}
});

const result = await runPiWrapup(
pi().api,
deps(db, { runPiHistorianForWrapup }),
ctx(sessionId, 8),
sessionId,
2,
);

expect(result).toContain("## Magic Wrapup");
expect(getWrapupInProgressState(db, sessionId)).toBeNull();
} finally {
closeQuietly(db);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Delete the temporary SQLite file after closing the database; this test creates a UUID-named file under tmpdir(), but closeQuietly(db) only closes the handle, so repeated runs leave pi-wrapup-busy-release-*.db files behind.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi-plugin/src/commands/ctx-wrapup.test.ts, line 333:

<comment>Delete the temporary SQLite file after closing the database; this test creates a UUID-named file under `tmpdir()`, but `closeQuietly(db)` only closes the handle, so repeated runs leave `pi-wrapup-busy-release-*.db` files behind.</comment>

<file context>
@@ -276,6 +286,54 @@ describe("Pi /ctx-wrapup", () => {
+			expect(result).toContain("## Magic Wrapup");
+			expect(getWrapupInProgressState(db, sessionId)).toBeNull();
+		} finally {
+			closeQuietly(db);
+		}
+	});
</file context>

}
});

it("signals deferred history and materialization after a wrapup publish", async () => {
const db = createDb();
try {
Expand Down
20 changes: 19 additions & 1 deletion packages/pi-plugin/src/commands/ctx-wrapup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ export async function runPiWrapup(
);
}
}, COMPARTMENT_LEASE_RENEWAL_MS);
let chunkFailure: string | null = null;
try {
const runHistorian = deps.runPiHistorianForWrapup ?? runPiHistorian;
await runHistorian({
Expand Down Expand Up @@ -451,11 +452,28 @@ export async function runPiWrapup(
signalPiDeferredMaterialization(sessionId);
},
});
} catch (err) {
// Parity with OpenCode: runCompartmentAgent carries its own .catch,
// so its wrapup finally only sees settled promises. Pi lacks that
// inner layer; record the failure instead of letting a busy-starved
// release (or any historian throw) escape the command handler.
chunkFailure = err instanceof Error ? err.message : String(err);
} finally {
clearInterval(leaseRenewal);
releaseCompartmentLease(deps.db, sessionId, leaseHolder);
try {
releaseCompartmentLease(deps.db, sessionId, leaseHolder);
} catch (err) {
// Best-effort teardown: the lease row expires via its own TTL.
console.warn(
`[magic-context][pi] /ctx-wrapup compartment lease release failed for ${sessionId} (expires via TTL): ${err instanceof Error ? err.message : String(err)}`,
);
}
}

if (chunkFailure) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Empty failures bypass containment

When the historian throws an error with an empty message, chunkFailure becomes an empty string and this truthiness check treats it as no exception. Wrapup then continues or reports no forward progress instead of stopping with the intended historian-failure result.

Suggested change
if (chunkFailure) {
if (chunkFailure !== null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a historian rejects with an empty error message, chunkFailure is "", so this check treats the failed chunk as successful progress. Track failure with a boolean or an object instead of using the formatted message as the sentinel.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi-plugin/src/commands/ctx-wrapup.ts, line 473:

<comment>When a historian rejects with an empty error message, `chunkFailure` is `""`, so this check treats the failed chunk as successful progress. Track failure with a boolean or an object instead of using the formatted message as the sentinel.</comment>

<file context>
@@ -451,11 +452,28 @@ export async function runPiWrapup(
+					}
 				}
 
+				if (chunkFailure) {
+					failure = `historian chunk failed (${chunkFailure}); wrapped up through message ${lastEnd}. Run /ctx-wrapup again to continue.`;
+					break;
</file context>

failure = `historian chunk failed (${chunkFailure}); wrapped up through message ${lastEnd}. Run /ctx-wrapup again to continue.`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a historian publishes the chunk and then onPublished throws, lastEnd still contains the pre-chunk value, so the Partial result reports the wrong wrapped-through message. Refresh lastEnd from getLastCompartmentEndMessage before formatting this failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi-plugin/src/commands/ctx-wrapup.ts, line 474:

<comment>When a historian publishes the chunk and then `onPublished` throws, `lastEnd` still contains the pre-chunk value, so the Partial result reports the wrong wrapped-through message. Refresh `lastEnd` from `getLastCompartmentEndMessage` before formatting this failure.</comment>

<file context>
@@ -451,11 +452,28 @@ export async function runPiWrapup(
 				}
 
+				if (chunkFailure) {
+					failure = `historian chunk failed (${chunkFailure}); wrapped up through message ${lastEnd}. Run /ctx-wrapup again to continue.`;
+					break;
+				}
</file context>
Suggested change
failure = `historian chunk failed (${chunkFailure}); wrapped up through message ${lastEnd}. Run /ctx-wrapup again to continue.`;
lastEnd = getLastCompartmentEndMessage(deps.db, sessionId);
failure = `historian chunk failed (${chunkFailure}); wrapped up through message ${lastEnd}. Run /ctx-wrapup again to continue.`;

break;
}
const afterEnd = getLastCompartmentEndMessage(deps.db, sessionId);
if (afterEnd <= lastEnd) {
failure = `No forward progress after chunk ${chunkIndex}; wrapped up through message ${lastEnd}. Run /ctx-wrapup again to continue.`;
Expand Down
43 changes: 34 additions & 9 deletions packages/pi-plugin/src/context-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3640,7 +3640,15 @@ function spawnPiHistorianRun(args: {
// Close the cross-process check/lease race: /ctx-wrapup may have published
// its marker after the first check but before this process won the lease.
sessionLog(sessionId, "historian skipped: /ctx-wrapup became active");
releaseCompartmentLease(db, sessionId, holderId);
try {
releaseCompartmentLease(db, sessionId, holderId);
} catch (err) {
// Same best-effort contract as the finally-block release below.
sessionLog(
sessionId,
`historian lease release failed (expires via TTL): ${err instanceof Error ? err.message : String(err)}`,
);
}
return;
}
const renewal = startPiCompartmentLeaseRenewal(db, sessionId, holderId);
Expand Down Expand Up @@ -3726,15 +3734,32 @@ function spawnPiHistorianRun(args: {
});
} finally {
clearInterval(renewal);
releaseCompartmentLease(db, sessionId, holderId);
}
})().finally(() => {
inFlightHistorian.delete(sessionId);
unregister();
if (isContextHandlerSessionActive(sessionId)) {
historian.onStatusChange?.(ctx, sessionId);
try {
releaseCompartmentLease(db, sessionId, holderId);
} catch (err) {
// Best-effort teardown: the lease row expires via its own TTL, so a
// busy_timeout-starved DELETE must not escape and reject this promise.
sessionLog(
sessionId,
`historian lease release failed (expires via TTL): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
});
})()
.catch((err) => {
// Parity with OpenCode's startCompartmentAgent .catch: the historian run
// promise is parked in inFlightHistorian for emergency/shutdown awaits,
// so an unhandled rejection here crashes the host via
// processTicksAndRejections instead of surfacing as a session log.
sessionLog(sessionId, "historian run failed:", err);
})
.finally(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When historian.onStatusChange throws during teardown, this .catch has already completed, so the promise returned by .finally rejects without a handler and can still trigger an unhandled rejection. Wrap the finalizer in a try/catch or add a catch after .finally so cleanup and status-update failures are contained too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/pi-plugin/src/context-handler.ts, line 3756:

<comment>When `historian.onStatusChange` throws during teardown, this `.catch` has already completed, so the promise returned by `.finally` rejects without a handler and can still trigger an unhandled rejection. Wrap the finalizer in a `try/catch` or add a catch after `.finally` so cleanup and status-update failures are contained too.</comment>

<file context>
@@ -3726,15 +3734,32 @@ function spawnPiHistorianRun(args: {
+			// processTicksAndRejections instead of surfacing as a session log.
+			sessionLog(sessionId, "historian run failed:", err);
+		})
+		.finally(() => {
+			inFlightHistorian.delete(sessionId);
+			unregister();
</file context>

inFlightHistorian.delete(sessionId);
unregister();
if (isContextHandlerSessionActive(sessionId)) {
historian.onStatusChange?.(ctx, sessionId);
}
});
inFlightHistorian.set(sessionId, runPromise);
historian.onStatusChange?.(ctx, sessionId);
}
Expand Down