Skip to content

Commit aa800bb

Browse files
committed
fix(compact): a synchronous compact throw no longer wedges auto-compaction (audit)
ctx.compact() can throw synchronously (not only via onError); that left the module-level `compacting` flag stuck true, permanently disabling auto-compaction, and in the input-preflight path left the promise unresolved so the turn stalled. New src/dispatch.ts (dispatchCompact) try/catches ctx.compact() and routes a synchronous throw through onError, which resets the flag and (in compactAndWait) resolves so the input hook still returns {action:"continue"}. dispatch unit tests + two extension integration tests. 49 pass, typecheck clean.
1 parent 1ed72ad commit aa800bb

5 files changed

Lines changed: 261 additions & 3 deletions

File tree

extensions/compact.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { join } from "node:path";
2929
import { shouldCompact } from "../src/decide.ts";
3030
import { guardDegeneration } from "../src/degenerate.ts";
3131
import { anchorInstructions } from "../src/anchors.ts";
32+
import { dispatchCompact } from "../src/dispatch.ts";
3233
import { DEFAULT_SETTINGS, resolveSettings, type CompactSettings } from "../src/settings.ts";
3334

3435
type UiContext = ExtensionContext;
@@ -168,7 +169,10 @@ export default function compact(pi: ExtensionAPI) {
168169

169170
function runCompaction(ctx: UiContext, forced: boolean): void {
170171
compacting = true;
171-
ctx.compact({
172+
// dispatchCompact routes a SYNCHRONOUS throw from ctx.compact() through
173+
// onError (→ onCompactError), so a synchronous failure still clears the
174+
// `compacting` flag instead of stranding it true and disabling us for good.
175+
dispatchCompact(ctx, {
172176
customInstructions: compactionInstructions(ctx),
173177
onComplete: (result) => onCompactComplete(ctx, forced, result),
174178
onError: (err) => onCompactError(ctx, err),
@@ -179,7 +183,10 @@ export default function compact(pi: ExtensionAPI) {
179183
function compactAndWait(ctx: UiContext): Promise<void> {
180184
return new Promise((resolve) => {
181185
compacting = true;
182-
ctx.compact({
186+
// A SYNCHRONOUS throw here would otherwise leave both the `compacting`
187+
// flag stuck true and this promise unresolved, so the input hook never
188+
// returns. Route it through onError, which clears the flag and resolves.
189+
dispatchCompact(ctx, {
183190
customInstructions: compactionInstructions(ctx),
184191
onComplete: (result) => {
185192
onCompactComplete(ctx, false, result);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pify/compact",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Proactive auto-compaction: when the context window crosses a threshold, compact between turns so a long session never hits the wall or needs a restart",
55
"keywords": [
66
"pi-package",

src/dispatch.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Dispatching a compaction safely — the one bit of the ctx.compact() call that
3+
* needs guarding, factored out so it is testable without a live session.
4+
*
5+
* ctx.compact() reports the usual failures asynchronously via onError, but it
6+
* can also throw SYNCHRONOUSLY (e.g. no model configured, or an internal
7+
* precondition fails before it dispatches). An unguarded synchronous throw would
8+
* escape the caller mid-cleanup: the `compacting` flag stays true — permanently
9+
* disabling auto-compaction for the session — and, on the input-preflight path,
10+
* the pending promise is never resolved, so the input hook never returns
11+
* {action:"continue"} and the turn stalls.
12+
*
13+
* Routing the synchronous throw through the same onError callback the async path
14+
* uses lets each caller run its normal recovery (reset the flag, notify, and —
15+
* in the preflight — resolve), so a throwing compact leaves no stuck state.
16+
*/
17+
18+
export interface CompactDispatchOptions {
19+
customInstructions?: string;
20+
onComplete?: (result: { tokensBefore: number; estimatedTokensAfter?: number }) => void;
21+
onError?: (error: Error) => void;
22+
}
23+
24+
export interface CompactCapable {
25+
compact(options: CompactDispatchOptions): void;
26+
}
27+
28+
/**
29+
* Call ctx.compact(), routing a synchronous throw to options.onError instead of
30+
* letting it escape. Returns true when compaction was dispatched, false when it
31+
* threw synchronously (onError has already run in that case).
32+
*/
33+
export function dispatchCompact(ctx: CompactCapable, options: CompactDispatchOptions): boolean {
34+
try {
35+
ctx.compact(options);
36+
return true;
37+
} catch (err) {
38+
options.onError?.(err instanceof Error ? err : new Error(String(err)));
39+
return false;
40+
}
41+
}

test/compact-extension.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Regression tests for the whole extension: a ctx.compact() that throws
3+
* SYNCHRONOUSLY must not strand the `compacting` flag (which permanently
4+
* disables auto-compaction) nor, on the input-preflight path, leave the promise
5+
* unresolved so the input hook never returns {action:"continue"}.
6+
*
7+
* The extension only leaves its dormant state when pi's own compaction is off,
8+
* so each test points ctx.cwd at a temp project whose .pi/settings.json disables
9+
* pi's built-in compaction (roots are injected, never via HOME — os.homedir may
10+
* be cached under bun). Everything else is a hand-rolled fake ctx/pi.
11+
*/
12+
import { test } from "node:test";
13+
import assert from "node:assert/strict";
14+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
15+
import { tmpdir } from "node:os";
16+
import { join } from "node:path";
17+
import compact from "../extensions/compact.ts";
18+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19+
20+
type Handler = (event: unknown, ctx: unknown) => unknown;
21+
22+
/** Instantiate the extension against a fake pi, returning its captured handlers. */
23+
function loadExtension(): Map<string, Handler> {
24+
const handlers = new Map<string, Handler>();
25+
const pi = {
26+
on(evt: string, fn: Handler) {
27+
handlers.set(evt, fn);
28+
},
29+
registerCommand() {},
30+
} as unknown as ExtensionAPI;
31+
compact(pi);
32+
return handlers;
33+
}
34+
35+
/** A temp project dir with pi's built-in compaction OFF (so the ext is active). */
36+
function makeActiveProjectDir(): string {
37+
const dir = mkdtempSync(join(tmpdir(), "pify-compact-"));
38+
mkdirSync(join(dir, ".pi"), { recursive: true });
39+
writeFileSync(join(dir, ".pi", "settings.json"), JSON.stringify({ compaction: { enabled: false } }));
40+
return dir;
41+
}
42+
43+
function cleanup(dir: string): void {
44+
try {
45+
rmSync(dir, { recursive: true, force: true });
46+
} catch {
47+
// best-effort; a lingering lockfile on Windows must not fail the test
48+
}
49+
}
50+
51+
interface FakeCtx {
52+
cwd: string;
53+
hasUI: boolean;
54+
isProjectTrusted: () => boolean;
55+
sessionManager: { getBranch: () => unknown[] };
56+
ui: { notify: (msg: string, level: string) => void; setStatus: () => void };
57+
getContextUsage: () => { percent: number; tokens: number; contextWindow: number };
58+
compact: () => void;
59+
}
60+
61+
/** ctx whose compact() always throws synchronously, counting each attempt. */
62+
function makeThrowingCtx(dir: string, usage: { percent: number; tokens: number; contextWindow: number }) {
63+
const state = { compactAttempts: 0, warnings: [] as string[] };
64+
const ctx: FakeCtx = {
65+
cwd: dir,
66+
hasUI: true,
67+
isProjectTrusted: () => true,
68+
sessionManager: { getBranch: () => [] },
69+
ui: {
70+
notify: (msg, level) => {
71+
if (level === "warning") state.warnings.push(msg);
72+
},
73+
setStatus: () => {},
74+
},
75+
getContextUsage: () => usage,
76+
compact: () => {
77+
state.compactAttempts++;
78+
throw new Error("synthetic synchronous compact failure");
79+
},
80+
};
81+
return { ctx, state };
82+
}
83+
84+
test("agent_settled: a synchronous compact throw resets the flag, so the next settle compacts again", async () => {
85+
const dir = makeActiveProjectDir();
86+
try {
87+
const handlers = loadExtension();
88+
const { ctx, state } = makeThrowingCtx(dir, { percent: 95, tokens: 500_000, contextWindow: 1_000_000 });
89+
90+
await handlers.get("session_start")!({}, ctx); // determines active = true
91+
92+
// First settle: over threshold → compact → ctx.compact() throws synchronously.
93+
await handlers.get("agent_settled")!({}, ctx);
94+
// Second settle: if the throw had stranded `compacting` at true, shouldCompact
95+
// would refuse and this would be a no-op. It must compact again.
96+
await handlers.get("agent_settled")!({}, ctx);
97+
98+
assert.equal(state.compactAttempts, 2, "compaction is attempted on both settles (flag was reset)");
99+
assert.equal(state.warnings.length, 2, "each synchronous failure is surfaced as a warning");
100+
} finally {
101+
cleanup(dir);
102+
}
103+
});
104+
105+
test("input preflight: a synchronous compact throw still resolves and returns continue", async () => {
106+
const dir = makeActiveProjectDir();
107+
try {
108+
const handlers = loadExtension();
109+
const { ctx, state } = makeThrowingCtx(dir, { percent: 95, tokens: 850_000, contextWindow: 1_000_000 });
110+
111+
await handlers.get("session_start")!({}, ctx);
112+
113+
// A big-enough prompt while idle trips the preflight → compactAndWait → throw.
114+
const result = await handlers.get("input")!({ text: "hello", streamingBehavior: undefined }, ctx);
115+
assert.deepEqual(result, { action: "continue" }, "input hook returns despite the synchronous throw");
116+
assert.equal(state.compactAttempts, 1, "preflight attempted the compaction");
117+
118+
// Flag must be cleared: a following settle over threshold compacts again.
119+
await handlers.get("agent_settled")!({}, ctx);
120+
assert.equal(state.compactAttempts, 2, "compacting flag was reset by the preflight failure");
121+
} finally {
122+
cleanup(dir);
123+
}
124+
});

test/dispatch.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { dispatchCompact, type CompactCapable, type CompactDispatchOptions } from "../src/dispatch.ts";
4+
5+
/** A ctx.compact() that throws synchronously, like pi does when it cannot start. */
6+
const throwingCtx = (message = "no model configured"): CompactCapable => ({
7+
compact() {
8+
throw new Error(message);
9+
},
10+
});
11+
12+
/** A ctx.compact() that dispatches fine and reports async (never synchronously). */
13+
const dispatchingCtx = (): CompactCapable => ({
14+
compact() {
15+
// returns void; onComplete/onError would fire later in the real runtime
16+
},
17+
});
18+
19+
test("a synchronous throw is routed to onError, not rethrown", () => {
20+
let seen: Error | null = null;
21+
const ok = dispatchCompact(throwingCtx("boom"), {
22+
onError: (err) => {
23+
seen = err;
24+
},
25+
});
26+
assert.equal(ok, false, "returns false when compact threw synchronously");
27+
assert.ok(seen, "onError was invoked with the thrown error");
28+
assert.equal((seen as unknown as Error).message, "boom");
29+
});
30+
31+
test("a throwing compact runs the caller's flag-reset (compacting → false)", () => {
32+
// Mirror the extension: `compacting` is set true just before dispatch and the
33+
// onError callback (onCompactError) is what flips it back to false.
34+
let compacting = true;
35+
const options: CompactDispatchOptions = {
36+
onError: () => {
37+
compacting = false;
38+
},
39+
};
40+
dispatchCompact(throwingCtx(), options);
41+
assert.equal(compacting, false, "flag reset even though compact threw synchronously");
42+
});
43+
44+
test("the preflight path resolves its promise on a synchronous throw", async () => {
45+
// Mirror compactAndWait: onError both clears the flag and resolves the promise.
46+
let compacting = true;
47+
const done = new Promise<void>((resolve) => {
48+
dispatchCompact(throwingCtx(), {
49+
onError: () => {
50+
compacting = false;
51+
resolve();
52+
},
53+
});
54+
});
55+
await done; // would hang forever if the throw escaped instead of hitting onError
56+
assert.equal(compacting, false);
57+
});
58+
59+
test("a clean dispatch returns true and does not call onError", () => {
60+
let errored = false;
61+
const ok = dispatchCompact(dispatchingCtx(), {
62+
onError: () => {
63+
errored = true;
64+
},
65+
});
66+
assert.equal(ok, true);
67+
assert.equal(errored, false, "onError is left for the async path, not fired on a clean dispatch");
68+
});
69+
70+
test("a non-Error throw is wrapped into an Error for onError", () => {
71+
let wasError = false;
72+
let message: string | null = null;
73+
const ctx: CompactCapable = {
74+
compact() {
75+
throw "stringly failure";
76+
},
77+
};
78+
dispatchCompact(ctx, {
79+
onError: (err) => {
80+
wasError = err instanceof Error;
81+
message = err.message;
82+
},
83+
});
84+
assert.equal(wasError, true, "a non-Error throw is wrapped into an Error");
85+
assert.equal(message, "stringly failure");
86+
});

0 commit comments

Comments
 (0)