botholomew 0.5.0 → 0.6.0

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.
Files changed (41) hide show
  1. package/package.json +2 -2
  2. package/src/chat/session.ts +2 -2
  3. package/src/commands/context.ts +53 -42
  4. package/src/commands/daemon.ts +1 -1
  5. package/src/commands/schedule.ts +1 -1
  6. package/src/commands/task.ts +2 -1
  7. package/src/commands/thread.ts +6 -40
  8. package/src/commands/with-db.ts +2 -2
  9. package/src/constants.ts +1 -1
  10. package/src/context/chunker.ts +23 -46
  11. package/src/context/describer.ts +146 -0
  12. package/src/context/ingest.ts +27 -25
  13. package/src/daemon/index.ts +51 -5
  14. package/src/daemon/llm.ts +80 -12
  15. package/src/daemon/prompt.ts +3 -4
  16. package/src/daemon/schedules.ts +7 -1
  17. package/src/daemon/tick.ts +17 -5
  18. package/src/db/connection.ts +102 -40
  19. package/src/db/context.ts +120 -94
  20. package/src/db/embeddings.ts +55 -77
  21. package/src/db/query.ts +11 -0
  22. package/src/db/schedules.ts +27 -28
  23. package/src/db/schema.ts +9 -9
  24. package/src/db/sql/1-core_tables.sql +11 -11
  25. package/src/db/sql/2-logging_tables.sql +3 -3
  26. package/src/db/sql/3-daemon_state.sql +2 -2
  27. package/src/db/sql/6-vss_index.sql +1 -0
  28. package/src/db/sql/7-drop_embeddings_fk.sql +24 -0
  29. package/src/db/sql/8-task_output.sql +1 -0
  30. package/src/db/tasks.ts +89 -78
  31. package/src/db/threads.ts +52 -41
  32. package/src/init/index.ts +2 -2
  33. package/src/tools/file/move.ts +5 -3
  34. package/src/tools/file/write.ts +2 -30
  35. package/src/tools/search/semantic.ts +7 -4
  36. package/src/tools/task/list.ts +2 -0
  37. package/src/tools/task/view.ts +2 -0
  38. package/src/tui/App.tsx +20 -3
  39. package/src/tui/components/SchedulePanel.tsx +389 -0
  40. package/src/tui/components/TabBar.tsx +3 -2
  41. package/src/tui/components/TaskPanel.tsx +6 -0
@@ -19,7 +19,7 @@ interface ScheduleRow {
19
19
  description: string;
20
20
  frequency: string;
21
21
  last_run_at: string | null;
22
- enabled: number;
22
+ enabled: boolean;
23
23
  created_at: string;
24
24
  updated_at: string;
25
25
  }
@@ -31,7 +31,7 @@ function rowToSchedule(row: ScheduleRow): Schedule {
31
31
  description: row.description,
32
32
  frequency: row.frequency,
33
33
  last_run_at: row.last_run_at ? new Date(row.last_run_at) : null,
34
- enabled: row.enabled === 1,
34
+ enabled: !!row.enabled,
35
35
  created_at: new Date(row.created_at),
36
36
  updated_at: new Date(row.updated_at),
37
37
  };
@@ -46,18 +46,15 @@ export async function createSchedule(
46
46
  },
47
47
  ): Promise<Schedule> {
48
48
  const id = uuidv7();
49
- const row = db
50
- .query(
51
- `INSERT INTO schedules (id, name, description, frequency)
49
+ const row = await db.queryGet<ScheduleRow>(
50
+ `INSERT INTO schedules (id, name, description, frequency)
52
51
  VALUES (?1, ?2, ?3, ?4)
53
52
  RETURNING *`,
54
- )
55
- .get(
56
- id,
57
- params.name,
58
- params.description ?? "",
59
- params.frequency,
60
- ) as ScheduleRow | null;
53
+ id,
54
+ params.name,
55
+ params.description ?? "",
56
+ params.frequency,
57
+ );
61
58
  if (!row) throw new Error("INSERT did not return a row");
62
59
  return rowToSchedule(row);
63
60
  }
@@ -66,9 +63,10 @@ export async function getSchedule(
66
63
  db: DbConnection,
67
64
  id: string,
68
65
  ): Promise<Schedule | null> {
69
- const row = db
70
- .query("SELECT * FROM schedules WHERE id = ?1")
71
- .get(id) as ScheduleRow | null;
66
+ const row = await db.queryGet<ScheduleRow>(
67
+ "SELECT * FROM schedules WHERE id = ?1",
68
+ id,
69
+ );
72
70
  return row ? rowToSchedule(row) : null;
73
71
  }
74
72
 
@@ -83,9 +81,10 @@ export async function listSchedules(
83
81
  ],
84
82
  ]);
85
83
 
86
- const rows = db
87
- .query(`SELECT * FROM schedules ${where} ORDER BY created_at ASC`)
88
- .all(...params) as ScheduleRow[];
84
+ const rows = await db.queryAll<ScheduleRow>(
85
+ `SELECT * FROM schedules ${where} ORDER BY created_at ASC`,
86
+ ...params,
87
+ );
89
88
  return rows.map(rowToSchedule);
90
89
  }
91
90
 
@@ -110,14 +109,13 @@ export async function updateSchedule(
110
109
  return getSchedule(db, id);
111
110
  }
112
111
 
113
- setClauses.push("updated_at = datetime('now')");
112
+ setClauses.push("updated_at = current_timestamp::VARCHAR");
114
113
  params.push(id);
115
114
 
116
- const row = db
117
- .query(
118
- `UPDATE schedules SET ${setClauses.join(", ")} WHERE id = ?${params.length} RETURNING *`,
119
- )
120
- .get(...params) as ScheduleRow | null;
115
+ const row = await db.queryGet<ScheduleRow>(
116
+ `UPDATE schedules SET ${setClauses.join(", ")} WHERE id = ?${params.length} RETURNING *`,
117
+ ...params,
118
+ );
121
119
  return row ? rowToSchedule(row) : null;
122
120
  }
123
121
 
@@ -125,7 +123,7 @@ export async function deleteSchedule(
125
123
  db: DbConnection,
126
124
  id: string,
127
125
  ): Promise<boolean> {
128
- const result = db.query("DELETE FROM schedules WHERE id = ?1").run(id);
126
+ const result = await db.queryRun("DELETE FROM schedules WHERE id = ?1", id);
129
127
  return result.changes > 0;
130
128
  }
131
129
 
@@ -133,7 +131,8 @@ export async function markScheduleRun(
133
131
  db: DbConnection,
134
132
  id: string,
135
133
  ): Promise<void> {
136
- db.query(
137
- `UPDATE schedules SET last_run_at = datetime('now'), updated_at = datetime('now') WHERE id = ?1`,
138
- ).run(id);
134
+ await db.queryRun(
135
+ `UPDATE schedules SET last_run_at = current_timestamp::VARCHAR, updated_at = current_timestamp::VARCHAR WHERE id = ?1`,
136
+ id,
137
+ );
139
138
  }
package/src/db/schema.ts CHANGED
@@ -29,20 +29,18 @@ function loadMigrations(): Migration[] {
29
29
  });
30
30
  }
31
31
 
32
- export function migrate(db: DbConnection): void {
32
+ export async function migrate(db: DbConnection): Promise<void> {
33
33
  // Create migrations tracking table
34
- db.exec(`
34
+ await db.exec(`
35
35
  CREATE TABLE IF NOT EXISTS _migrations (
36
36
  id INTEGER PRIMARY KEY,
37
37
  name TEXT NOT NULL,
38
- applied_at TEXT DEFAULT (datetime('now'))
38
+ applied_at TEXT DEFAULT (current_timestamp::VARCHAR)
39
39
  )
40
40
  `);
41
41
 
42
42
  // Get already-applied migrations
43
- const rows = db.query("SELECT id FROM _migrations").all() as {
44
- id: number;
45
- }[];
43
+ const rows = await db.queryAll<{ id: number }>("SELECT id FROM _migrations");
46
44
  const applied = new Set(rows.map((row) => row.id));
47
45
 
48
46
  // Run pending migrations in order
@@ -56,11 +54,13 @@ export function migrate(db: DbConnection): void {
56
54
  .filter((s) => s.length > 0);
57
55
 
58
56
  for (const statement of statements) {
59
- db.exec(statement);
57
+ await db.exec(statement);
60
58
  }
61
59
 
62
- db.exec(
63
- `INSERT INTO _migrations (id, name) VALUES (${migration.id}, '${migration.name}')`,
60
+ await db.queryRun(
61
+ "INSERT INTO _migrations (id, name) VALUES (?1, ?2)",
62
+ migration.id,
63
+ migration.name,
64
64
  );
65
65
  }
66
66
  }
@@ -9,8 +9,8 @@ CREATE TABLE tasks (
9
9
  claimed_at TEXT,
10
10
  blocked_by TEXT NOT NULL DEFAULT '[]',
11
11
  context_ids TEXT NOT NULL DEFAULT '[]',
12
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
13
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
12
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
13
+ updated_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR)
14
14
  );
15
15
 
16
16
  CREATE TABLE schedules (
@@ -19,9 +19,9 @@ CREATE TABLE schedules (
19
19
  description TEXT NOT NULL DEFAULT '',
20
20
  frequency TEXT NOT NULL,
21
21
  last_run_at TEXT,
22
- enabled INTEGER NOT NULL DEFAULT 1,
23
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
24
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
22
+ enabled BOOLEAN NOT NULL DEFAULT true,
23
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
24
+ updated_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR)
25
25
  );
26
26
 
27
27
  CREATE TABLE context_items (
@@ -31,12 +31,12 @@ CREATE TABLE context_items (
31
31
  content TEXT,
32
32
  content_blob BLOB,
33
33
  mime_type TEXT NOT NULL DEFAULT 'text/plain',
34
- is_textual INTEGER NOT NULL DEFAULT 1,
34
+ is_textual BOOLEAN NOT NULL DEFAULT true,
35
35
  source_path TEXT,
36
36
  context_path TEXT NOT NULL,
37
37
  indexed_at TEXT,
38
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
39
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
38
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
39
+ updated_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR)
40
40
  );
41
41
 
42
42
  CREATE TABLE embeddings (
@@ -47,7 +47,7 @@ CREATE TABLE embeddings (
47
47
  title TEXT NOT NULL,
48
48
  description TEXT NOT NULL DEFAULT '',
49
49
  source_path TEXT,
50
- embedding BLOB,
51
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
50
+ embedding FLOAT[1536],
51
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
52
52
  UNIQUE(context_item_id, chunk_index)
53
- );
53
+ );
@@ -3,7 +3,7 @@ CREATE TABLE threads (
3
3
  type TEXT NOT NULL CHECK(type IN ('daemon_tick', 'chat_session')),
4
4
  task_id TEXT,
5
5
  title TEXT NOT NULL DEFAULT '',
6
- started_at TEXT NOT NULL DEFAULT (datetime('now')),
6
+ started_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
7
7
  ended_at TEXT,
8
8
  metadata TEXT
9
9
  );
@@ -19,6 +19,6 @@ CREATE TABLE interactions (
19
19
  tool_input TEXT,
20
20
  duration_ms INTEGER,
21
21
  token_count INTEGER,
22
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
22
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
23
23
  UNIQUE(thread_id, sequence)
24
- );
24
+ );
@@ -1,5 +1,5 @@
1
1
  CREATE TABLE daemon_state (
2
2
  key TEXT PRIMARY KEY,
3
3
  value TEXT NOT NULL,
4
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
5
- );
4
+ updated_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR)
5
+ );
@@ -0,0 +1 @@
1
+ CREATE INDEX IF NOT EXISTS idx_embeddings_cosine ON embeddings USING HNSW (embedding) WITH (metric = 'cosine');
@@ -0,0 +1,24 @@
1
+ -- DuckDB implements UPDATE as delete+insert on tables with unique indexes.
2
+ -- The foreign key from embeddings → context_items causes every UPDATE to
3
+ -- context_items to fail when embeddings exist. Cascading deletes are already
4
+ -- handled in application code (deleteContextItem), so the FK is redundant.
5
+ --
6
+ -- Clear embeddings before DROP to avoid DuckDB WAL replay crash with FK refs.
7
+ -- Embeddings are recreated on next `context add` or `context refresh`.
8
+ DELETE FROM embeddings;
9
+ UPDATE context_items SET indexed_at = NULL;
10
+ DROP TABLE embeddings;
11
+ CREATE TABLE embeddings (
12
+ id TEXT PRIMARY KEY,
13
+ context_item_id TEXT NOT NULL,
14
+ chunk_index INTEGER NOT NULL,
15
+ chunk_content TEXT,
16
+ title TEXT NOT NULL,
17
+ description TEXT NOT NULL DEFAULT '',
18
+ source_path TEXT,
19
+ embedding FLOAT[1536],
20
+ created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
21
+ UNIQUE(context_item_id, chunk_index)
22
+ );
23
+ CREATE INDEX IF NOT EXISTS idx_embeddings_cosine ON embeddings USING HNSW (embedding) WITH (metric = 'cosine');
24
+ CHECKPOINT;
@@ -0,0 +1 @@
1
+ ALTER TABLE tasks ADD COLUMN output TEXT;
package/src/db/tasks.ts CHANGED
@@ -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 const TASK_PRIORITIES = ["low", "medium", "high"] as const;
@@ -22,6 +22,7 @@ export interface Task {
22
22
  claimed_at: Date | null;
23
23
  blocked_by: string[];
24
24
  context_ids: string[];
25
+ output: string | null;
25
26
  created_at: Date;
26
27
  updated_at: Date;
27
28
  }
@@ -37,6 +38,7 @@ interface TaskRow {
37
38
  claimed_at: string | null;
38
39
  blocked_by: string;
39
40
  context_ids: string;
41
+ output: string | null;
40
42
  created_at: string;
41
43
  updated_at: string;
42
44
  }
@@ -53,6 +55,7 @@ function rowToTask(row: TaskRow): Task {
53
55
  claimed_at: row.claimed_at ? new Date(row.claimed_at) : null,
54
56
  blocked_by: JSON.parse(row.blocked_by || "[]"),
55
57
  context_ids: JSON.parse(row.context_ids || "[]"),
58
+ output: row.output,
56
59
  created_at: new Date(row.created_at),
57
60
  updated_at: new Date(row.updated_at),
58
61
  };
@@ -74,20 +77,17 @@ export async function createTask(
74
77
  const blockedBy = JSON.stringify(blockedByArr);
75
78
  const contextIds = JSON.stringify(params.context_ids ?? []);
76
79
 
77
- const row = db
78
- .query(
79
- `INSERT INTO tasks (id, name, description, priority, blocked_by, context_ids)
80
+ const row = await db.queryGet<TaskRow>(
81
+ `INSERT INTO tasks (id, name, description, priority, blocked_by, context_ids)
80
82
  VALUES (?1, ?2, ?3, ?4, ?5, ?6)
81
83
  RETURNING *`,
82
- )
83
- .get(
84
- id,
85
- params.name,
86
- params.description ?? "",
87
- params.priority ?? "medium",
88
- blockedBy,
89
- contextIds,
90
- ) as TaskRow | null;
84
+ id,
85
+ params.name,
86
+ params.description ?? "",
87
+ params.priority ?? "medium",
88
+ blockedBy,
89
+ contextIds,
90
+ );
91
91
  if (!row) throw new Error("INSERT did not return a row");
92
92
  return rowToTask(row);
93
93
  }
@@ -96,9 +96,10 @@ export async function getTask(
96
96
  db: DbConnection,
97
97
  id: string,
98
98
  ): Promise<Task | null> {
99
- const row = db
100
- .query("SELECT * FROM tasks WHERE id = ?1")
101
- .get(id) as TaskRow | null;
99
+ const row = await db.queryGet<TaskRow>(
100
+ "SELECT * FROM tasks WHERE id = ?1",
101
+ id,
102
+ );
102
103
  return row ? rowToTask(row) : null;
103
104
  }
104
105
 
@@ -114,17 +115,16 @@ export async function listTasks(
114
115
  ["status", filters?.status],
115
116
  ["priority", filters?.priority],
116
117
  ]);
117
- const limit = filters?.limit ? `LIMIT ${filters.limit}` : "";
118
+ const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
118
119
 
119
- const rows = db
120
- .query(
121
- `SELECT * FROM tasks ${where}
120
+ const rows = await db.queryAll<TaskRow>(
121
+ `SELECT * FROM tasks ${where}
122
122
  ORDER BY
123
123
  CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END,
124
124
  created_at ASC
125
125
  ${limit}`,
126
- )
127
- .all(...params) as TaskRow[];
126
+ ...params,
127
+ );
128
128
  return rows.map(rowToTask);
129
129
  }
130
130
 
@@ -133,12 +133,17 @@ export async function updateTaskStatus(
133
133
  id: string,
134
134
  status: Task["status"],
135
135
  reason?: string,
136
+ output?: string,
136
137
  ): Promise<void> {
137
- db.query(
138
+ await db.queryRun(
138
139
  `UPDATE tasks
139
- SET status = ?1, waiting_reason = ?2, updated_at = datetime('now')
140
- WHERE id = ?3`,
141
- ).run(status, reason ?? null, id);
140
+ SET status = ?1, waiting_reason = ?2, output = ?3, updated_at = current_timestamp::VARCHAR
141
+ WHERE id = ?4`,
142
+ status,
143
+ reason ?? null,
144
+ output ?? null,
145
+ id,
146
+ );
142
147
  }
143
148
 
144
149
  export async function validateBlockedBy(
@@ -206,14 +211,13 @@ export async function updateTask(
206
211
  return getTask(db, id);
207
212
  }
208
213
 
209
- setClauses.push("updated_at = datetime('now')");
214
+ setClauses.push("updated_at = current_timestamp::VARCHAR");
210
215
  params.push(id);
211
216
 
212
- const row = db
213
- .query(
214
- `UPDATE tasks SET ${setClauses.join(", ")} WHERE id = ?${params.length} RETURNING *`,
215
- )
216
- .get(...params) as TaskRow | null;
217
+ const row = await db.queryGet<TaskRow>(
218
+ `UPDATE tasks SET ${setClauses.join(", ")} WHERE id = ?${params.length} RETURNING *`,
219
+ ...params,
220
+ );
217
221
  return row ? rowToTask(row) : null;
218
222
  }
219
223
 
@@ -221,7 +225,7 @@ export async function deleteTask(
221
225
  db: DbConnection,
222
226
  id: string,
223
227
  ): Promise<boolean> {
224
- const result = db.query("DELETE FROM tasks WHERE id = ?1").run(id);
228
+ const result = await db.queryRun("DELETE FROM tasks WHERE id = ?1", id);
225
229
  return result.changes > 0;
226
230
  }
227
231
 
@@ -229,15 +233,14 @@ export async function resetTask(
229
233
  db: DbConnection,
230
234
  id: string,
231
235
  ): Promise<Task | null> {
232
- const row = db
233
- .query(
234
- `UPDATE tasks
236
+ const row = await db.queryGet<TaskRow>(
237
+ `UPDATE tasks
235
238
  SET status = 'pending', claimed_by = NULL, claimed_at = NULL,
236
- waiting_reason = NULL, updated_at = datetime('now')
239
+ waiting_reason = NULL, output = NULL, updated_at = current_timestamp::VARCHAR
237
240
  WHERE id = ?1
238
241
  RETURNING *`,
239
- )
240
- .get(id) as TaskRow | null;
242
+ id,
243
+ );
241
244
  return row ? rowToTask(row) : null;
242
245
  }
243
246
 
@@ -245,19 +248,18 @@ export async function resetStaleTasks(
245
248
  db: DbConnection,
246
249
  timeoutSeconds: number,
247
250
  ): Promise<string[]> {
248
- const rows = db
249
- .query(
250
- `UPDATE tasks
251
+ const rows = await db.queryAll<{ id: string }>(
252
+ `UPDATE tasks
251
253
  SET status = 'pending',
252
254
  claimed_by = NULL,
253
255
  claimed_at = NULL,
254
- updated_at = datetime('now')
256
+ updated_at = current_timestamp::VARCHAR
255
257
  WHERE status = 'in_progress'
256
258
  AND claimed_at IS NOT NULL
257
- AND claimed_at < datetime('now', '-' || ?1 || ' seconds')
259
+ AND claimed_at::TIMESTAMP < current_timestamp - to_seconds(CAST(?1 AS BIGINT))
258
260
  RETURNING id`,
259
- )
260
- .all(timeoutSeconds) as { id: string }[];
261
+ timeoutSeconds,
262
+ );
261
263
  return rows.map((r) => r.id);
262
264
  }
263
265
 
@@ -266,42 +268,51 @@ export async function claimNextTask(
266
268
  claimedBy = "daemon",
267
269
  ): Promise<Task | null> {
268
270
  // Find highest-priority unblocked pending task
269
- const row = db
270
- .query(
271
- `SELECT * FROM tasks
271
+ // Use application-level filtering for blocked_by since DuckDB doesn't have json_each
272
+ const allPending = await db.queryAll<TaskRow>(
273
+ `SELECT * FROM tasks
272
274
  WHERE status = 'pending'
273
- AND (
274
- blocked_by = '[]'
275
- OR blocked_by IS NULL
276
- OR NOT EXISTS (
277
- SELECT 1 FROM json_each(blocked_by) AS b
278
- WHERE b.value NOT IN (SELECT id FROM tasks WHERE status = 'complete')
279
- )
280
- )
281
275
  ORDER BY
282
276
  CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END,
283
- created_at ASC
284
- LIMIT 1`,
285
- )
286
- .get() as TaskRow | null;
277
+ created_at ASC`,
278
+ );
287
279
 
288
- if (!row) return null;
289
- const task = rowToTask(row);
280
+ for (const row of allPending) {
281
+ const blockedBy: string[] = JSON.parse(row.blocked_by || "[]");
282
+ if (blockedBy.length > 0) {
283
+ // Check if all blockers are complete
284
+ let allComplete = true;
285
+ for (const blockerId of blockedBy) {
286
+ const blocker = await db.queryGet<{ status: string }>(
287
+ "SELECT status FROM tasks WHERE id = ?1",
288
+ blockerId,
289
+ );
290
+ if (!blocker || blocker.status !== "complete") {
291
+ allComplete = false;
292
+ break;
293
+ }
294
+ }
295
+ if (!allComplete) continue;
296
+ }
290
297
 
291
- // Claim it
292
- db.query(
293
- `UPDATE tasks
294
- SET status = 'in_progress',
295
- claimed_by = ?1,
296
- claimed_at = datetime('now'),
297
- updated_at = datetime('now')
298
- WHERE id = ?2 AND status = 'pending'`,
299
- ).run(claimedBy, task.id);
298
+ // Attempt atomic claim — RETURNING confirms we actually got it
299
+ const claimed = await db.queryGet<TaskRow>(
300
+ `UPDATE tasks
301
+ SET status = 'in_progress',
302
+ claimed_by = ?1,
303
+ claimed_at = current_timestamp::VARCHAR,
304
+ updated_at = current_timestamp::VARCHAR
305
+ WHERE id = ?2 AND status = 'pending'
306
+ RETURNING *`,
307
+ claimedBy,
308
+ row.id,
309
+ );
300
310
 
301
- return {
302
- ...task,
303
- status: "in_progress",
304
- claimed_by: claimedBy,
305
- claimed_at: new Date(),
306
- };
311
+ if (claimed) {
312
+ return rowToTask(claimed);
313
+ }
314
+ // Another process claimed it — try next candidate
315
+ }
316
+
317
+ return null;
307
318
  }