Skip to content

Commit aa536b2

Browse files
committed
fix(transform): handle SQLITE_BUSY without crashing user prompt (#23)
A Discord user reported SQLITE_BUSY crashing their turn: SQLiteError: database is locked errno: 5, code: SQLITE_BUSY at prepareCompartmentInjection (inject-compartments.ts:215) ## Root cause The DB is configured with busy_timeout=5000 (5s) and WAL mode, which should handle concurrent access. SQLITE_BUSY surfaces only when another writer holds the lock longer than 5 seconds — typically a dreamer or historian child session, a second OpenCode process, or a slow WAL checkpoint. When the failing statement is a write inside transform, the thrown error propagates all the way up through OpenCode's Effect pipeline and crashes the user's turn. That's the actual user-visible damage; the DB contention itself is recoverable. ## Fix — two layers 1. **Swallow BUSY on the failing write specifically.** The memory_block_cache UPDATE in prepareCompartmentInjection is a pure optimization — the block itself is computed and returned regardless of whether the cache row writes. On SQLITE_BUSY we log and continue; next turn will retry naturally. Non-BUSY errors still propagate so real bugs aren't hidden. 2. **Top-level try/catch in messages-transform.ts.** Any unexpected error inside the plugin transform is now caught at the OpenCode boundary and logged instead of thrown. On failure the messages array is returned unmodified (no injection, no drops, no tagging for this pass only), and OpenCode's prompt loop proceeds normally. Next transform pass retries with full behavior. Correctness is preserved because all persistent mutations are idempotent. ## Why both layers The surgical fix (layer 1) is preferred because losing ONE optional cache write is cheaper than losing the whole transform pass. The broad guard (layer 2) is defense-in-depth for writes we haven't specifically hardened yet (tagger, apply-operations, heuristic cleanup, etc.). Users get a degraded pass instead of a dead prompt if any of those hit BUSY next. ## Tests - 505 plugin tests pass (+6 new targeting this issue). - inject-compartments.test.ts: SQLITE_BUSY is swallowed; non-BUSY SQLite errors still rethrow. - messages-transform.test.ts: SQLITE_BUSY, TypeError, and null-plugin cases all resolve without throwing; normal transforms pass through. Closes #23
1 parent 3207f2f commit aa536b2

4 files changed

Lines changed: 226 additions & 5 deletions

File tree

packages/plugin/src/hooks/magic-context/inject-compartments.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,95 @@ describe("prepareCompartmentInjection — transition from empty to compartment",
243243
expect(deferMessages.length).toBe(2);
244244
});
245245
});
246+
247+
describe("prepareCompartmentInjection — SQLITE_BUSY handling (issue #23)", () => {
248+
it("swallows SQLITE_BUSY on memory_block_cache UPDATE and returns computed block anyway", () => {
249+
db = makeDb();
250+
insertMemory(db, {
251+
projectPath: PROJECT_PATH,
252+
category: "USER_DIRECTIVES",
253+
content: "never run migrations manually",
254+
});
255+
256+
// Proxy the db to throw SQLITE_BUSY specifically on the UPDATE statement
257+
// used by memory_block_cache. Other prepares pass through unchanged so
258+
// the rest of prepareCompartmentInjection can complete normally.
259+
const busyProxy: Database = new Proxy(db, {
260+
get(target, prop, receiver) {
261+
if (prop === "prepare") {
262+
return (sql: string) => {
263+
if (sql.includes("UPDATE session_meta SET memory_block_cache")) {
264+
return {
265+
run: () => {
266+
const err = new Error("database is locked") as Error & {
267+
code: string;
268+
errno: number;
269+
};
270+
err.code = "SQLITE_BUSY";
271+
err.errno = 5;
272+
throw err;
273+
},
274+
get: () => null,
275+
all: () => [],
276+
};
277+
}
278+
return target.prepare(sql);
279+
};
280+
}
281+
return Reflect.get(target, prop, receiver);
282+
},
283+
});
284+
285+
const messages: MessageLike[] = [userMessage("m1", "hello")];
286+
// Should not throw — the BUSY on the optional cache write must be swallowed.
287+
const result = prepareCompartmentInjection(
288+
busyProxy,
289+
SESSION_ID,
290+
messages,
291+
true,
292+
PROJECT_PATH,
293+
);
294+
295+
expect(result).not.toBeNull();
296+
expect(result?.memoryCount).toBe(1);
297+
expect(result?.block).toContain("never run migrations manually");
298+
});
299+
300+
it("rethrows non-BUSY errors from memory_block_cache UPDATE", () => {
301+
db = makeDb();
302+
insertMemory(db, {
303+
projectPath: PROJECT_PATH,
304+
category: "USER_DIRECTIVES",
305+
content: "test directive",
306+
});
307+
308+
const errorProxy: Database = new Proxy(db, {
309+
get(target, prop, receiver) {
310+
if (prop === "prepare") {
311+
return (sql: string) => {
312+
if (sql.includes("UPDATE session_meta SET memory_block_cache")) {
313+
return {
314+
run: () => {
315+
const err = new Error("schema mismatch") as Error & {
316+
code: string;
317+
};
318+
err.code = "SQLITE_CORRUPT";
319+
throw err;
320+
},
321+
get: () => null,
322+
all: () => [],
323+
};
324+
}
325+
return target.prepare(sql);
326+
};
327+
}
328+
return Reflect.get(target, prop, receiver);
329+
},
330+
});
331+
332+
const messages: MessageLike[] = [userMessage("m1", "hello")];
333+
expect(() =>
334+
prepareCompartmentInjection(errorProxy, SESSION_ID, messages, true, PROJECT_PATH),
335+
).toThrow("schema mismatch");
336+
});
337+
});

packages/plugin/src/hooks/magic-context/inject-compartments.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,10 +211,28 @@ export function prepareCompartmentInjection(
211211
memoryCount = memories.length;
212212
memoryBlock = renderMemoryBlock(memories) ?? undefined;
213213

214-
// Snapshot so subsequent turns reuse the same block without cache bust
215-
db.prepare(
216-
"UPDATE session_meta SET memory_block_cache = ?, memory_block_count = ? WHERE session_id = ?",
217-
).run(memoryBlock ?? "", memoryCount, sessionId);
214+
// Snapshot so subsequent turns reuse the same block without cache bust.
215+
// Swallow SQLITE_BUSY: the cache is a pure optimization (the block itself
216+
// is already computed and returned below). If another writer holds the DB
217+
// past busy_timeout=5s — typically a concurrent dreamer/historian child
218+
// session or a second OpenCode process — we'd rather let the transform
219+
// proceed with a one-turn cache miss than crash the user's prompt.
220+
// Issue: https://github.com/cortexkit/opencode-magic-context/issues/23
221+
try {
222+
db.prepare(
223+
"UPDATE session_meta SET memory_block_cache = ?, memory_block_count = ? WHERE session_id = ?",
224+
).run(memoryBlock ?? "", memoryCount, sessionId);
225+
} catch (error) {
226+
const code = (error as { code?: string } | null)?.code;
227+
if (code === "SQLITE_BUSY") {
228+
sessionLog(
229+
sessionId,
230+
"memory_block_cache UPDATE hit SQLITE_BUSY, skipping snapshot for this turn",
231+
);
232+
} else {
233+
throw error;
234+
}
235+
}
218236
}
219237
}
220238

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/// <reference types="bun-types" />
2+
3+
import { describe, expect, it } from "bun:test";
4+
import { createMessagesTransformHandler } from "./messages-transform";
5+
6+
// Minimal fake message shape — just needs info + parts.
7+
// biome-ignore lint/suspicious/noExplicitAny: test fixture does not need full SDK types
8+
function makeOutput(): any {
9+
return {
10+
messages: [
11+
{
12+
info: { id: "m1", role: "user", sessionID: "ses_test" },
13+
parts: [{ type: "text", text: "hello" }],
14+
},
15+
],
16+
};
17+
}
18+
19+
describe("createMessagesTransformHandler — error boundary (issue #23)", () => {
20+
it("swallows SQLITE_BUSY from inner transform so prompt loop proceeds", async () => {
21+
const handler = createMessagesTransformHandler({
22+
magicContext: {
23+
"experimental.chat.messages.transform": async () => {
24+
const err = new Error("database is locked") as Error & {
25+
code: string;
26+
errno: number;
27+
};
28+
err.code = "SQLITE_BUSY";
29+
err.errno = 5;
30+
throw err;
31+
},
32+
},
33+
});
34+
35+
const output = makeOutput();
36+
// Should NOT throw — wrapper catches all errors.
37+
await expect(handler({}, output)).resolves.toBeUndefined();
38+
39+
// Messages are left untouched when transform fails.
40+
expect(output.messages).toHaveLength(1);
41+
expect(output.messages[0].info.id).toBe("m1");
42+
});
43+
44+
it("swallows unexpected non-SQLITE errors too", async () => {
45+
const handler = createMessagesTransformHandler({
46+
magicContext: {
47+
"experimental.chat.messages.transform": async () => {
48+
throw new TypeError("unexpected undefined access");
49+
},
50+
},
51+
});
52+
53+
const output = makeOutput();
54+
await expect(handler({}, output)).resolves.toBeUndefined();
55+
});
56+
57+
it("passes through non-error transforms normally", async () => {
58+
let called = false;
59+
const handler = createMessagesTransformHandler({
60+
magicContext: {
61+
"experimental.chat.messages.transform": async (_input, out) => {
62+
called = true;
63+
// biome-ignore lint/suspicious/noExplicitAny: test fixture — real shape irrelevant
64+
(out.messages as any).push({
65+
info: { id: "injected", role: "user", sessionID: "ses_test" },
66+
parts: [{ type: "text", text: "injected" }],
67+
});
68+
},
69+
},
70+
});
71+
72+
const output = makeOutput();
73+
await handler({}, output);
74+
expect(called).toBe(true);
75+
expect(output.messages).toHaveLength(2);
76+
});
77+
78+
it("no-ops when magicContext is null (disabled plugin path)", async () => {
79+
const handler = createMessagesTransformHandler({ magicContext: null });
80+
const output = makeOutput();
81+
await expect(handler({}, output)).resolves.toBeUndefined();
82+
expect(output.messages).toHaveLength(1);
83+
});
84+
});

packages/plugin/src/plugin/messages-transform.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1+
import { log } from "../shared/logger";
2+
13
type MessageWithParts = {
24
info: import("@opencode-ai/sdk").Message;
35
parts: import("@opencode-ai/sdk").Part[];
46
};
57

68
type MessagesTransformOutput = { messages: MessageWithParts[] };
79

10+
/**
11+
* Top-level transform wrapper. Swallows any unexpected error (typically
12+
* SQLITE_BUSY from concurrent plugin processes) so OpenCode's prompt loop
13+
* always proceeds. Without this guard, a transient DB contention event can
14+
* crash the user's turn through OpenCode's Effect pipeline — see issue #23
15+
* https://github.com/cortexkit/opencode-magic-context/issues/23
16+
*
17+
* On failure, the messages array is returned unmodified (i.e., magic-context
18+
* manipulation is skipped for this pass). The next transform pass will
19+
* retry with normal behavior. Correctness is preserved because all
20+
* persistent state mutations are idempotent across passes.
21+
*/
822
export function createMessagesTransformHandler(args: {
923
magicContext: {
1024
"experimental.chat.messages.transform"?: (
@@ -14,6 +28,19 @@ export function createMessagesTransformHandler(args: {
1428
} | null;
1529
}): (input: Record<string, never>, output: MessagesTransformOutput) => Promise<void> {
1630
return async (input, output): Promise<void> => {
17-
await args.magicContext?.["experimental.chat.messages.transform"]?.(input, output);
31+
try {
32+
await args.magicContext?.["experimental.chat.messages.transform"]?.(input, output);
33+
} catch (error) {
34+
const code = (error as { code?: string } | null)?.code;
35+
const name = (error as { name?: string } | null)?.name;
36+
const message = error instanceof Error ? error.message : String(error);
37+
log(
38+
`[magic-context] transform failed (code=${code ?? "none"} name=${name ?? "none"}): ${message}. Continuing with unmodified messages for this pass.`,
39+
error,
40+
);
41+
// Do NOT rethrow — OpenCode's Effect pipeline turns thrown errors into
42+
// user-visible prompt failures. We accept degraded behavior (no
43+
// injection / no drops this turn) rather than blocking the user.
44+
}
1845
};
1946
}

0 commit comments

Comments
 (0)