botholomew 0.7.8 → 0.7.9

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/src/daemon/llm.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  } from "@anthropic-ai/sdk/resources/messages";
8
8
  import type { McpxClient } from "@evantahler/mcpx";
9
9
  import type { BotholomewConfig } from "../config/schemas.ts";
10
- import type { DbConnection } from "../db/connection.ts";
10
+ import { withDb } from "../db/connection.ts";
11
11
  import { getTask, type Task } from "../db/tasks.ts";
12
12
  import { logInteraction } from "../db/threads.ts";
13
13
  import { registerAllTools } from "../tools/registry.ts";
@@ -44,32 +44,32 @@ export async function runAgentLoop(input: {
44
44
  systemPrompt: string;
45
45
  task: Task;
46
46
  config: Required<BotholomewConfig>;
47
- conn: DbConnection;
47
+ dbPath: string;
48
48
  threadId: string;
49
49
  projectDir: string;
50
50
  mcpxClient?: McpxClient | null;
51
51
  callbacks?: DaemonStreamCallbacks;
52
52
  }): Promise<AgentLoopResult> {
53
- const { systemPrompt, task, config, conn, threadId, projectDir, callbacks } =
54
- input;
53
+ const {
54
+ systemPrompt,
55
+ task,
56
+ config,
57
+ dbPath,
58
+ threadId,
59
+ projectDir,
60
+ callbacks,
61
+ } = input;
55
62
 
56
63
  const client = new Anthropic({
57
64
  apiKey: config.anthropic_api_key || undefined,
58
65
  });
59
66
 
60
- const toolCtx: ToolContext = {
61
- conn,
62
- projectDir,
63
- config,
64
- mcpxClient: input.mcpxClient ?? null,
65
- };
66
-
67
67
  // Build predecessor context from completed blocking tasks
68
68
  let predecessorContext = "";
69
69
  if (task.blocked_by.length > 0) {
70
70
  const predecessorOutputs: string[] = [];
71
71
  for (const blockerId of task.blocked_by) {
72
- const blocker = await getTask(conn, blockerId);
72
+ const blocker = await withDb(dbPath, (conn) => getTask(conn, blockerId));
73
73
  if (blocker?.output) {
74
74
  predecessorOutputs.push(
75
75
  `### ${blocker.name} (${blocker.id})\n${blocker.output}`,
@@ -86,11 +86,13 @@ export async function runAgentLoop(input: {
86
86
  const messages: MessageParam[] = [{ role: "user", content: userMessage }];
87
87
 
88
88
  // Log the initial user message
89
- await logInteraction(conn, threadId, {
90
- role: "user",
91
- kind: "message",
92
- content: userMessage,
93
- });
89
+ await withDb(dbPath, (conn) =>
90
+ logInteraction(conn, threadId, {
91
+ role: "user",
92
+ kind: "message",
93
+ content: userMessage,
94
+ }),
95
+ );
94
96
 
95
97
  clearLargeResults();
96
98
  const daemonTools = toAnthropicTools();
@@ -144,13 +146,15 @@ export async function runAgentLoop(input: {
144
146
  // Log assistant text blocks
145
147
  for (const block of response.content) {
146
148
  if (block.type === "text" && block.text) {
147
- await logInteraction(conn, threadId, {
148
- role: "assistant",
149
- kind: "message",
150
- content: block.text,
151
- durationMs,
152
- tokenCount,
153
- });
149
+ await withDb(dbPath, (conn) =>
150
+ logInteraction(conn, threadId, {
151
+ role: "assistant",
152
+ kind: "message",
153
+ content: block.text,
154
+ durationMs,
155
+ tokenCount,
156
+ }),
157
+ );
154
158
  }
155
159
  }
156
160
 
@@ -173,20 +177,30 @@ export async function runAgentLoop(input: {
173
177
  for (const toolUse of toolUseBlocks) {
174
178
  const toolInput = JSON.stringify(toolUse.input);
175
179
  callbacks?.onToolStart(toolUse.name, toolInput);
176
- await logInteraction(conn, threadId, {
177
- role: "assistant",
178
- kind: "tool_use",
179
- content: `Calling ${toolUse.name}`,
180
- toolName: toolUse.name,
181
- toolInput,
182
- });
180
+ await withDb(dbPath, (conn) =>
181
+ logInteraction(conn, threadId, {
182
+ role: "assistant",
183
+ kind: "tool_use",
184
+ content: `Calling ${toolUse.name}`,
185
+ toolName: toolUse.name,
186
+ toolInput,
187
+ }),
188
+ );
183
189
  }
184
190
 
185
- // Execute all tools in parallel
191
+ // Execute all tools in parallel. Each tool call opens its own short-lived
192
+ // connection (or none, if the tool uses dbPath internally) via
193
+ // executeToolCall — so parallel tool calls share the process-local
194
+ // DuckDB instance and release the file lock as soon as they finish.
186
195
  const execResults = await Promise.all(
187
196
  toolUseBlocks.map(async (toolUse) => {
188
197
  const start = Date.now();
189
- const result = await executeToolCall(toolUse, toolCtx);
198
+ const result = await executeToolCall(toolUse, {
199
+ dbPath,
200
+ projectDir,
201
+ config,
202
+ mcpxClient: input.mcpxClient ?? null,
203
+ });
190
204
  const elapsed = Date.now() - start;
191
205
  callbacks?.onToolEnd(
192
206
  toolUse.name,
@@ -201,13 +215,15 @@ export async function runAgentLoop(input: {
201
215
  // Log results and collect tool_result messages
202
216
  const toolResults: ToolResultBlockParam[] = [];
203
217
  for (const { toolUse, result, durationMs } of execResults) {
204
- await logInteraction(conn, threadId, {
205
- role: "tool",
206
- kind: "tool_result",
207
- content: result.output,
208
- toolName: toolUse.name,
209
- durationMs,
210
- });
218
+ await withDb(dbPath, (conn) =>
219
+ logInteraction(conn, threadId, {
220
+ role: "tool",
221
+ kind: "tool_result",
222
+ content: result.output,
223
+ toolName: toolUse.name,
224
+ durationMs,
225
+ }),
226
+ );
211
227
 
212
228
  if (result.terminal && result.agentResult) {
213
229
  return result.agentResult;
@@ -234,9 +250,16 @@ interface ToolCallResult {
234
250
  agentResult?: AgentLoopResult;
235
251
  }
236
252
 
253
+ interface ToolCallCtx {
254
+ dbPath: string;
255
+ projectDir: string;
256
+ config: Required<BotholomewConfig>;
257
+ mcpxClient: McpxClient | null;
258
+ }
259
+
237
260
  async function executeToolCall(
238
261
  toolUse: ToolUseBlock,
239
- ctx: ToolContext,
262
+ baseCtx: ToolCallCtx,
240
263
  ): Promise<ToolCallResult> {
241
264
  const tool = getTool(toolUse.name);
242
265
  if (!tool) {
@@ -261,7 +284,10 @@ async function executeToolCall(
261
284
 
262
285
  let result: unknown;
263
286
  try {
264
- result = await tool.execute(parsed.data, ctx);
287
+ result = await withDb(baseCtx.dbPath, (conn) => {
288
+ const ctx: ToolContext = { ...baseCtx, conn };
289
+ return tool.execute(parsed.data, ctx);
290
+ });
265
291
  } catch (err) {
266
292
  return {
267
293
  output: `Tool ${toolUse.name} threw an error: ${err}. You may retry with different parameters or try an alternative approach.`,
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import type { BotholomewConfig } from "../config/schemas.ts";
4
4
  import { getBotholomewDir } from "../constants.ts";
5
5
  import { embedSingle } from "../context/embedder.ts";
6
- import type { DbConnection } from "../db/connection.ts";
6
+ import { withDb } from "../db/connection.ts";
7
7
  import { hybridSearch } from "../db/embeddings.ts";
8
8
  import type { Task } from "../db/tasks.ts";
9
9
  import { parseContextFile } from "../utils/frontmatter.ts";
@@ -89,7 +89,7 @@ export function buildMetaHeader(projectDir: string): string[] {
89
89
  export async function buildSystemPrompt(
90
90
  projectDir: string,
91
91
  task?: Task,
92
- conn?: DbConnection,
92
+ dbPath?: string,
93
93
  _config?: Required<BotholomewConfig>,
94
94
  options?: { hasMcpTools?: boolean },
95
95
  ): Promise<string> {
@@ -107,11 +107,13 @@ export async function buildSystemPrompt(
107
107
  parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
108
108
 
109
109
  // Relevant context from embeddings search
110
- if (task && conn && _config?.openai_api_key) {
110
+ if (task && dbPath && _config?.openai_api_key) {
111
111
  try {
112
112
  const query = `${task.name} ${task.description}`;
113
113
  const queryVec = await embedSingle(query, _config);
114
- const results = await hybridSearch(conn, query, queryVec, 5);
114
+ const results = await withDb(dbPath, (conn) =>
115
+ hybridSearch(conn, query, queryVec, 5),
116
+ );
115
117
 
116
118
  if (results.length > 0) {
117
119
  parts.push("## Relevant Context");
@@ -1,6 +1,6 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import type { DbConnection } from "../db/connection.ts";
3
+ import { withDb } from "../db/connection.ts";
4
4
  import {
5
5
  listSchedules,
6
6
  markScheduleRun,
@@ -105,14 +105,17 @@ Is this schedule due to run? If yes, what tasks should be created?`;
105
105
  }
106
106
 
107
107
  export async function processSchedules(
108
- conn: DbConnection,
108
+ dbPath: string,
109
109
  config: Required<BotholomewConfig>,
110
110
  ): Promise<void> {
111
- const schedules = await listSchedules(conn, { enabled: true });
111
+ const schedules = await withDb(dbPath, (conn) =>
112
+ listSchedules(conn, { enabled: true }),
113
+ );
112
114
  if (schedules.length === 0) return;
113
115
 
114
116
  for (const schedule of schedules) {
115
117
  try {
118
+ // LLM evaluation does no DB work — no connection held here.
116
119
  const evaluation = await evaluateSchedule(config, schedule);
117
120
 
118
121
  if (!evaluation.isDue) {
@@ -128,16 +131,18 @@ export async function processSchedules(
128
131
  .map((i: number) => createdIds[i])
129
132
  .filter(Boolean) as string[];
130
133
 
131
- const task = await createTask(conn, {
132
- name: taskDef.name,
133
- description: taskDef.description,
134
- priority: taskDef.priority,
135
- blocked_by: blockedBy,
136
- });
134
+ const task = await withDb(dbPath, (conn) =>
135
+ createTask(conn, {
136
+ name: taskDef.name,
137
+ description: taskDef.description,
138
+ priority: taskDef.priority,
139
+ blocked_by: blockedBy,
140
+ }),
141
+ );
137
142
  createdIds.push(task.id);
138
143
  }
139
144
 
140
- await markScheduleRun(conn, schedule.id);
145
+ await withDb(dbPath, (conn) => markScheduleRun(conn, schedule.id));
141
146
  logger.info(
142
147
  `Schedule "${schedule.name}" fired, created ${createdIds.length} task(s)`,
143
148
  );
@@ -1,6 +1,6 @@
1
1
  import type { McpxClient } from "@evantahler/mcpx";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import type { DbConnection } from "../db/connection.ts";
3
+ import { withDb } from "../db/connection.ts";
4
4
  import { listSchedules } from "../db/schedules.ts";
5
5
  import {
6
6
  claimNextTask,
@@ -17,7 +17,7 @@ import { processSchedules } from "./schedules.ts";
17
17
 
18
18
  export async function tick(
19
19
  projectDir: string,
20
- conn: DbConnection,
20
+ dbPath: string,
21
21
  config: Required<BotholomewConfig>,
22
22
  mcpxClient?: McpxClient | null,
23
23
  callbacks?: DaemonStreamCallbacks,
@@ -27,9 +27,8 @@ export async function tick(
27
27
  logger.phase("tick-start", `#${tickNum}`);
28
28
 
29
29
  // Reset stale tasks stuck in in_progress
30
- const resetIds = await resetStaleTasks(
31
- conn,
32
- config.max_tick_duration_seconds * 3,
30
+ const resetIds = await withDb(dbPath, (conn) =>
31
+ resetStaleTasks(conn, config.max_tick_duration_seconds * 3),
33
32
  );
34
33
  if (resetIds.length > 0) {
35
34
  logger.warn(
@@ -37,12 +36,16 @@ export async function tick(
37
36
  );
38
37
  }
39
38
 
40
- // Process schedules (may create new tasks)
41
- const enabledSchedules = await listSchedules(conn, { enabled: true });
39
+ // Process schedules (may create new tasks). Check enabled count so we only
40
+ // log the phase when there's work to evaluate — the call itself is a no-op
41
+ // otherwise.
42
+ const enabledSchedules = await withDb(dbPath, (conn) =>
43
+ listSchedules(conn, { enabled: true }),
44
+ );
42
45
  if (enabledSchedules.length > 0) {
43
46
  logger.phase("evaluating-schedules", `${enabledSchedules.length} enabled`);
44
47
  try {
45
- await processSchedules(conn, config);
48
+ await processSchedules(dbPath, config);
46
49
  } catch (err) {
47
50
  logger.error(`Schedule processing failed: ${err}`);
48
51
  }
@@ -50,7 +53,7 @@ export async function tick(
50
53
 
51
54
  // Claim a task
52
55
  logger.phase("claiming-task");
53
- const task = await claimNextTask(conn);
56
+ const task = await withDb(dbPath, (conn) => claimNextTask(conn));
54
57
  if (!task) {
55
58
  logger.info("No task claimed (queue empty or all blocked)");
56
59
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
@@ -62,67 +65,80 @@ export async function tick(
62
65
  callbacks?.onTaskStart(task);
63
66
 
64
67
  // Create a thread for this tick
65
- const threadId = await createThread(
66
- conn,
67
- "daemon_tick",
68
- task.id,
69
- `Working: ${task.name}`,
68
+ const threadId = await withDb(dbPath, (conn) =>
69
+ createThread(conn, "daemon_tick", task.id, `Working: ${task.name}`),
70
70
  );
71
71
 
72
72
  // Build system prompt (includes task-relevant context from embeddings)
73
- const systemPrompt = await buildSystemPrompt(projectDir, task, conn, config, {
74
- hasMcpTools: mcpxClient != null,
75
- });
73
+ const systemPrompt = await buildSystemPrompt(
74
+ projectDir,
75
+ task,
76
+ dbPath,
77
+ config,
78
+ {
79
+ hasMcpTools: mcpxClient != null,
80
+ },
81
+ );
76
82
 
77
83
  try {
78
84
  const result = await runAgentLoop({
79
85
  systemPrompt,
80
86
  task,
81
87
  config,
82
- conn,
88
+ dbPath,
83
89
  threadId,
84
90
  projectDir,
85
91
  mcpxClient,
86
92
  callbacks,
87
93
  });
88
94
 
95
+ // Update task status and store output. Only completed tasks have an
96
+ // `output`; waiting/failed tasks put their reason in `waiting_reason`.
89
97
  const isComplete = result.status === "complete";
90
- await updateTaskStatus(
91
- conn,
92
- task.id,
93
- result.status,
94
- isComplete ? null : result.reason,
95
- isComplete ? result.reason : null,
98
+ await withDb(dbPath, (conn) =>
99
+ updateTaskStatus(
100
+ conn,
101
+ task.id,
102
+ result.status,
103
+ isComplete ? null : result.reason,
104
+ isComplete ? result.reason : null,
105
+ ),
96
106
  );
97
107
 
98
108
  // Log the status change
99
- await logInteraction(conn, threadId, {
100
- role: "system",
101
- kind: "status_change",
102
- content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
103
- });
109
+ await withDb(dbPath, (conn) =>
110
+ logInteraction(conn, threadId, {
111
+ role: "system",
112
+ kind: "status_change",
113
+ content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
114
+ }),
115
+ );
104
116
 
105
117
  logger.info(`Task ${task.id} -> ${result.status}`);
106
118
 
107
- // Generate a descriptive title for the thread
119
+ // Generate a descriptive title for the thread (fire-and-forget)
108
120
  void generateThreadTitle(
109
121
  config,
110
- conn,
122
+ dbPath,
111
123
  threadId,
112
124
  `Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
113
125
  );
114
126
  } catch (err) {
115
- await updateTaskStatus(conn, task.id, "failed", String(err), null);
127
+ await withDb(dbPath, (conn) =>
128
+ updateTaskStatus(conn, task.id, "failed", String(err), null),
129
+ );
116
130
 
117
- await logInteraction(conn, threadId, {
118
- role: "system",
119
- kind: "status_change",
120
- content: `Task ${task.id} failed: ${err}`,
121
- });
131
+ await withDb(dbPath, (conn) =>
132
+ logInteraction(conn, threadId, {
133
+ role: "system",
134
+ kind: "status_change",
135
+ content: `Task ${task.id} failed: ${err}`,
136
+ }),
137
+ );
122
138
 
123
139
  logger.error(`Task ${task.id} failed: ${err}`);
124
140
  } finally {
125
- await endThread(conn, threadId);
141
+ await withDb(dbPath, (conn) => endThread(conn, threadId));
126
142
  }
127
143
 
128
144
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
@@ -11,12 +11,20 @@ export class DbConnection {
11
11
  // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
12
12
  private conn: any;
13
13
  // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
14
- private instance: any;
14
+ private readonly ownedInstance: any;
15
+ private readonly dbPath: string;
16
+ private closed = false;
15
17
 
16
- // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
17
- constructor(conn: any, instance: any) {
18
+ constructor(
19
+ // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
20
+ conn: any,
21
+ // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
22
+ ownedInstance: any,
23
+ dbPath: string,
24
+ ) {
18
25
  this.conn = conn;
19
- this.instance = instance;
26
+ this.ownedInstance = ownedInstance;
27
+ this.dbPath = dbPath;
20
28
  }
21
29
 
22
30
  /** Execute raw SQL with no return value. */
@@ -62,10 +70,22 @@ export class DbConnection {
62
70
  return { changes: result.rowsChanged };
63
71
  }
64
72
 
65
- /** Close the connection and dispose of the instance. */
73
+ /**
74
+ * Disconnect and release this connection's share of the DuckDB instance.
75
+ * For file-backed DBs, the instance is closed (and the OS file lock
76
+ * released) once every overlapping connection in this process has closed.
77
+ * For `:memory:` DBs, the instance is owned by this connection and closed
78
+ * immediately.
79
+ */
66
80
  close(): void {
81
+ if (this.closed) return;
82
+ this.closed = true;
67
83
  this.conn.disconnectSync();
68
- this.instance.closeSync();
84
+ if (this.ownedInstance) {
85
+ this.ownedInstance.closeSync();
86
+ } else {
87
+ releaseInstance(this.dbPath);
88
+ }
69
89
  }
70
90
  }
71
91
 
@@ -98,31 +118,140 @@ function flattenParams(params: SqlParam[]): SqlParam[] {
98
118
  return params.map((p) => (Array.isArray(p) ? JSON.stringify(p) : p));
99
119
  }
100
120
 
121
+ /**
122
+ * Refcounted, process-local cache of open DuckDB instances keyed by dbPath.
123
+ *
124
+ * DuckDB's file lock is held at the instance level, so we must close the
125
+ * instance — not just the connection — to let another process acquire the
126
+ * writer lock. At the same time, opening two instances for the same file
127
+ * from one process is unsafe. This cache resolves both: overlapping
128
+ * `getConnection` calls in the same process share a single instance; once
129
+ * every connection has closed, the instance is closed and evicted, which
130
+ * releases the OS file lock.
131
+ *
132
+ * `:memory:` paths bypass the cache so each test/caller gets its own
133
+ * isolated in-memory database.
134
+ */
135
+ interface CachedInstance {
136
+ // biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
137
+ instance: any;
138
+ refCount: number;
139
+ }
140
+ const instanceCache = new Map<string, CachedInstance>();
141
+ const pendingInstance = new Map<string, Promise<CachedInstance>>();
142
+
143
+ function isMemoryPath(path: string): boolean {
144
+ return path === ":memory:" || path.startsWith(":memory:");
145
+ }
146
+
147
+ async function acquireSharedInstance(dbPath: string): Promise<CachedInstance> {
148
+ const existing = instanceCache.get(dbPath);
149
+ if (existing) {
150
+ existing.refCount += 1;
151
+ return existing;
152
+ }
153
+ const inFlight = pendingInstance.get(dbPath);
154
+ if (inFlight) {
155
+ const cached = await inFlight;
156
+ cached.refCount += 1;
157
+ return cached;
158
+ }
159
+ const creation = (async () => {
160
+ const instance = await DuckDBInstance.create(dbPath);
161
+ const cached: CachedInstance = { instance, refCount: 1 };
162
+ instanceCache.set(dbPath, cached);
163
+ return cached;
164
+ })();
165
+ pendingInstance.set(dbPath, creation);
166
+ try {
167
+ return await creation;
168
+ } finally {
169
+ pendingInstance.delete(dbPath);
170
+ }
171
+ }
172
+
173
+ function releaseInstance(dbPath: string): void {
174
+ const cached = instanceCache.get(dbPath);
175
+ if (!cached) return;
176
+ cached.refCount -= 1;
177
+ if (cached.refCount <= 0) {
178
+ instanceCache.delete(dbPath);
179
+ cached.instance.closeSync();
180
+ }
181
+ }
182
+
101
183
  export async function getConnection(dbPath?: string): Promise<DbConnection> {
102
- const instance = await DuckDBInstance.create(dbPath ?? ":memory:");
103
- const conn = await instance.connect();
184
+ const path = dbPath ?? ":memory:";
104
185
 
105
- // Load VSS extension for vector similarity search
106
- await conn.run("INSTALL vss; LOAD vss;");
107
- // Enable HNSW index persistence for file-backed databases
108
- await conn.run("SET hnsw_enable_experimental_persistence = true;");
186
+ if (isMemoryPath(path)) {
187
+ const instance = await DuckDBInstance.create(path);
188
+ const conn = await instance.connect();
189
+ await conn.run("INSTALL vss; LOAD vss;");
190
+ await conn.run("SET hnsw_enable_experimental_persistence = true;");
191
+ return new DbConnection(conn, instance, path);
192
+ }
109
193
 
110
- return new DbConnection(conn, instance);
194
+ const cached = await acquireSharedInstance(path);
195
+ try {
196
+ const conn = await cached.instance.connect();
197
+ // INSTALL is a no-op after the first successful install (the extension
198
+ // is persisted to the user's DuckDB extension directory). LOAD is
199
+ // cheap per connection.
200
+ await conn.run("INSTALL vss; LOAD vss;");
201
+ await conn.run("SET hnsw_enable_experimental_persistence = true;");
202
+ return new DbConnection(conn, null, path);
203
+ } catch (err) {
204
+ releaseInstance(path);
205
+ throw err;
206
+ }
111
207
  }
112
208
 
209
+ /**
210
+ * Open a DuckDB connection for a single logical unit of work and guarantee
211
+ * it is closed afterward. Retries on lock conflicts so two processes that
212
+ * race on the file lock cooperate instead of failing hard.
213
+ *
214
+ * Prefer one `withDb` per logical operation. The file lock is only released
215
+ * when every connection (across this process's overlapping callers) has
216
+ * been closed, so holding the connection across non-DB work (LLM calls,
217
+ * network I/O, filesystem walks) keeps other processes blocked.
218
+ */
219
+ export async function withDb<T>(
220
+ dbPath: string,
221
+ fn: (conn: DbConnection) => Promise<T>,
222
+ ): Promise<T> {
223
+ const conn = await withRetry(() => getConnection(dbPath));
224
+ try {
225
+ return await fn(conn);
226
+ } finally {
227
+ conn.close();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Retry `fn` with exponential backoff when it fails with a DuckDB file-lock
233
+ * conflict ("Conflicting lock is held…"). Other errors propagate immediately.
234
+ */
113
235
  export async function withRetry<T>(
114
236
  fn: () => Promise<T>,
115
- maxRetries = 5,
237
+ maxRetries = 8,
116
238
  ): Promise<T> {
117
239
  let lastError: unknown;
118
240
  for (let attempt = 0; attempt < maxRetries; attempt++) {
119
241
  try {
120
242
  return await fn();
121
243
  } catch (err) {
244
+ if (!isLockConflict(err)) throw err;
122
245
  lastError = err;
123
246
  if (attempt === maxRetries - 1) throw err;
247
+ // 100, 200, 400, 800, 1600, 3200, 6400, 12800 — up to ~25s total
124
248
  await Bun.sleep(100 * 2 ** attempt);
125
249
  }
126
250
  }
127
251
  throw lastError;
128
252
  }
253
+
254
+ function isLockConflict(err: unknown): boolean {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ return msg.includes("Conflicting lock") || msg.includes("could not be set");
257
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "./connection.ts";
2
- import { buildSetClauses, buildWhereClause } from "./query.ts";
2
+ import { buildSetClauses, buildWhereClause, sanitizeInt } from "./query.ts";
3
3
  import { uuidv7 } from "./uuid.ts";
4
4
 
5
5
  export interface Schedule {
@@ -72,7 +72,7 @@ export async function getSchedule(
72
72
 
73
73
  export async function listSchedules(
74
74
  db: DbConnection,
75
- filters?: { enabled?: boolean },
75
+ filters?: { enabled?: boolean; limit?: number; offset?: number },
76
76
  ): Promise<Schedule[]> {
77
77
  const { where, params } = buildWhereClause([
78
78
  [
@@ -80,9 +80,13 @@ export async function listSchedules(
80
80
  filters?.enabled !== undefined ? (filters.enabled ? 1 : 0) : undefined,
81
81
  ],
82
82
  ]);
83
+ const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
84
+ const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
83
85
 
84
86
  const rows = await db.queryAll<ScheduleRow>(
85
- `SELECT * FROM schedules ${where} ORDER BY created_at ASC`,
87
+ `SELECT * FROM schedules ${where}
88
+ ORDER BY created_at ASC, id ASC
89
+ ${limit} ${offset}`,
86
90
  ...params,
87
91
  );
88
92
  return rows.map(rowToSchedule);