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.
- package/package.json +2 -2
- package/src/chat/session.ts +2 -2
- package/src/commands/context.ts +53 -42
- package/src/commands/daemon.ts +1 -1
- package/src/commands/schedule.ts +1 -1
- package/src/commands/task.ts +2 -1
- package/src/commands/thread.ts +6 -40
- package/src/commands/with-db.ts +2 -2
- package/src/constants.ts +1 -1
- package/src/context/chunker.ts +23 -46
- package/src/context/describer.ts +146 -0
- package/src/context/ingest.ts +27 -25
- package/src/daemon/index.ts +51 -5
- package/src/daemon/llm.ts +80 -12
- package/src/daemon/prompt.ts +3 -4
- package/src/daemon/schedules.ts +7 -1
- package/src/daemon/tick.ts +17 -5
- package/src/db/connection.ts +102 -40
- package/src/db/context.ts +120 -94
- package/src/db/embeddings.ts +55 -77
- package/src/db/query.ts +11 -0
- package/src/db/schedules.ts +27 -28
- package/src/db/schema.ts +9 -9
- package/src/db/sql/1-core_tables.sql +11 -11
- package/src/db/sql/2-logging_tables.sql +3 -3
- package/src/db/sql/3-daemon_state.sql +2 -2
- package/src/db/sql/6-vss_index.sql +1 -0
- package/src/db/sql/7-drop_embeddings_fk.sql +24 -0
- package/src/db/sql/8-task_output.sql +1 -0
- package/src/db/tasks.ts +89 -78
- package/src/db/threads.ts +52 -41
- package/src/init/index.ts +2 -2
- package/src/tools/file/move.ts +5 -3
- package/src/tools/file/write.ts +2 -30
- package/src/tools/search/semantic.ts +7 -4
- package/src/tools/task/list.ts +2 -0
- package/src/tools/task/view.ts +2 -0
- package/src/tui/App.tsx +20 -3
- package/src/tui/components/SchedulePanel.tsx +389 -0
- package/src/tui/components/TabBar.tsx +3 -2
- package/src/tui/components/TaskPanel.tsx +6 -0
package/src/db/schedules.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface ScheduleRow {
|
|
|
19
19
|
description: string;
|
|
20
20
|
frequency: string;
|
|
21
21
|
last_run_at: string | null;
|
|
22
|
-
enabled:
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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 =
|
|
112
|
+
setClauses.push("updated_at = current_timestamp::VARCHAR");
|
|
114
113
|
params.push(id);
|
|
115
114
|
|
|
116
|
-
const row = db
|
|
117
|
-
.
|
|
118
|
-
|
|
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.
|
|
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.
|
|
137
|
-
`UPDATE schedules SET last_run_at =
|
|
138
|
-
|
|
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 (
|
|
38
|
+
applied_at TEXT DEFAULT (current_timestamp::VARCHAR)
|
|
39
39
|
)
|
|
40
40
|
`);
|
|
41
41
|
|
|
42
42
|
// Get already-applied migrations
|
|
43
|
-
const rows = db.
|
|
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.
|
|
63
|
-
|
|
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 (
|
|
13
|
-
updated_at TEXT NOT NULL DEFAULT (
|
|
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
|
|
23
|
-
created_at TEXT NOT NULL DEFAULT (
|
|
24
|
-
updated_at TEXT NOT NULL DEFAULT (
|
|
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
|
|
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 (
|
|
39
|
-
updated_at TEXT NOT NULL DEFAULT (
|
|
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
|
|
51
|
-
created_at TEXT NOT NULL DEFAULT (
|
|
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 (
|
|
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 (
|
|
22
|
+
created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
|
|
23
23
|
UNIQUE(thread_id, sequence)
|
|
24
|
-
);
|
|
24
|
+
);
|
|
@@ -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
|
-
|
|
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
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
138
|
+
await db.queryRun(
|
|
138
139
|
`UPDATE tasks
|
|
139
|
-
SET status = ?1, waiting_reason = ?2, updated_at =
|
|
140
|
-
WHERE id = ?
|
|
141
|
-
|
|
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 =
|
|
214
|
+
setClauses.push("updated_at = current_timestamp::VARCHAR");
|
|
210
215
|
params.push(id);
|
|
211
216
|
|
|
212
|
-
const row = db
|
|
213
|
-
.
|
|
214
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
239
|
+
waiting_reason = NULL, output = NULL, updated_at = current_timestamp::VARCHAR
|
|
237
240
|
WHERE id = ?1
|
|
238
241
|
RETURNING *`,
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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 =
|
|
256
|
+
updated_at = current_timestamp::VARCHAR
|
|
255
257
|
WHERE status = 'in_progress'
|
|
256
258
|
AND claimed_at IS NOT NULL
|
|
257
|
-
AND claimed_at <
|
|
259
|
+
AND claimed_at::TIMESTAMP < current_timestamp - to_seconds(CAST(?1 AS BIGINT))
|
|
258
260
|
RETURNING id`,
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
285
|
-
)
|
|
286
|
-
.get() as TaskRow | null;
|
|
277
|
+
created_at ASC`,
|
|
278
|
+
);
|
|
287
279
|
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
311
|
+
if (claimed) {
|
|
312
|
+
return rowToTask(claimed);
|
|
313
|
+
}
|
|
314
|
+
// Another process claimed it — try next candidate
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return null;
|
|
307
318
|
}
|