botholomew 0.7.13 → 0.8.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/README.md +37 -32
- package/package.json +1 -1
- package/src/chat/agent.ts +13 -11
- package/src/cli.ts +2 -2
- package/src/commands/chat.ts +29 -44
- package/src/commands/nuke.ts +11 -8
- package/src/commands/schedule.ts +1 -1
- package/src/commands/thread.ts +2 -2
- package/src/commands/with-db.ts +1 -1
- package/src/commands/worker.ts +231 -0
- package/src/config/schemas.ts +12 -0
- package/src/constants.ts +1 -27
- package/src/db/schedules.ts +66 -0
- package/src/db/schema.ts +5 -4
- package/src/db/sql/12-workers.sql +66 -0
- package/src/db/tasks.ts +25 -1
- package/src/db/threads.ts +1 -1
- package/src/db/workers.ts +207 -0
- package/src/init/index.ts +3 -1
- package/src/tools/context/read-large-result.ts +1 -1
- package/src/tools/mcp/exec.ts +1 -1
- package/src/tools/mcp/search.ts +1 -1
- package/src/tools/registry.ts +5 -0
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/worker/spawn.ts +50 -0
- package/src/tui/App.tsx +15 -7
- package/src/tui/components/HelpPanel.tsx +5 -5
- package/src/tui/components/StatusBar.tsx +22 -18
- package/src/tui/components/TabBar.tsx +3 -2
- package/src/tui/components/ThreadPanel.tsx +7 -7
- package/src/tui/components/WorkerPanel.tsx +207 -0
- package/src/utils/title.ts +1 -1
- package/src/worker/heartbeat.ts +78 -0
- package/src/worker/index.ts +200 -0
- package/src/{daemon → worker}/llm.ts +5 -5
- package/src/{daemon → worker}/prompt.ts +2 -2
- package/src/worker/run.ts +26 -0
- package/src/{daemon → worker}/schedules.ts +30 -2
- package/src/worker/spawn.ts +48 -0
- package/src/{daemon → worker}/tick.ts +93 -35
- package/src/commands/daemon.ts +0 -152
- package/src/daemon/ensure-running.ts +0 -16
- package/src/daemon/healthcheck.ts +0 -47
- package/src/daemon/index.ts +0 -106
- package/src/daemon/run.ts +0 -14
- package/src/daemon/spawn.ts +0 -38
- package/src/daemon/watchdog.ts +0 -306
- package/src/utils/pid.ts +0 -55
- package/src/utils/project-registry.ts +0 -48
- /package/src/{daemon → worker}/context.ts +0 -0
- /package/src/{daemon → worker}/fake-llm.ts +0 -0
- /package/src/{daemon → worker}/fake-mcp.ts +0 -0
- /package/src/{daemon → worker}/large-results.ts +0 -0
- /package/src/{daemon → worker}/llm-client.ts +0 -0
package/src/config/schemas.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface BotholomewConfig {
|
|
|
9
9
|
max_tick_duration_seconds?: number;
|
|
10
10
|
system_prompt_override?: string;
|
|
11
11
|
max_turns?: number;
|
|
12
|
+
worker_heartbeat_interval_seconds?: number;
|
|
13
|
+
worker_dead_after_seconds?: number;
|
|
14
|
+
worker_reap_interval_seconds?: number;
|
|
15
|
+
worker_stopped_retention_seconds?: number;
|
|
16
|
+
schedule_min_interval_seconds?: number;
|
|
17
|
+
schedule_claim_stale_seconds?: number;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
|
|
@@ -22,4 +28,10 @@ export const DEFAULT_CONFIG: Required<BotholomewConfig> = {
|
|
|
22
28
|
max_tick_duration_seconds: 120,
|
|
23
29
|
system_prompt_override: "",
|
|
24
30
|
max_turns: 0,
|
|
31
|
+
worker_heartbeat_interval_seconds: 15,
|
|
32
|
+
worker_dead_after_seconds: 60,
|
|
33
|
+
worker_reap_interval_seconds: 30,
|
|
34
|
+
worker_stopped_retention_seconds: 3600,
|
|
35
|
+
schedule_min_interval_seconds: 60,
|
|
36
|
+
schedule_claim_stale_seconds: 300,
|
|
25
37
|
};
|
package/src/constants.ts
CHANGED
|
@@ -13,19 +13,14 @@ export const DEFAULTS = {
|
|
|
13
13
|
UPDATE_CHECK_TIMEOUT_MS: 5_000,
|
|
14
14
|
} as const;
|
|
15
15
|
export const DB_FILENAME = "data.duckdb";
|
|
16
|
-
export const
|
|
17
|
-
export const LOG_FILENAME = "daemon.log";
|
|
16
|
+
export const LOG_FILENAME = "worker.log";
|
|
18
17
|
export const CONFIG_FILENAME = "config.json";
|
|
19
18
|
export const MCPX_DIR = "mcpx";
|
|
20
19
|
export const SKILLS_DIR = "skills";
|
|
21
20
|
export const MCPX_SERVERS_FILENAME = "servers.json";
|
|
22
21
|
export const EMBEDDING_DIMENSION = 1536;
|
|
23
22
|
export const EMBEDDING_MODEL = "text-embedding-3-small";
|
|
24
|
-
|
|
25
|
-
export const LAUNCHD_LABEL_PREFIX = "com.botholomew.";
|
|
26
|
-
export const SYSTEMD_UNIT_PREFIX = "botholomew-";
|
|
27
23
|
export const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
28
|
-
export const WATCHDOG_LOG_FILENAME = "watchdog.log";
|
|
29
24
|
|
|
30
25
|
export function getBotholomewDir(projectDir: string): string {
|
|
31
26
|
return join(projectDir, BOTHOLOMEW_DIR);
|
|
@@ -35,10 +30,6 @@ export function getDbPath(projectDir: string): string {
|
|
|
35
30
|
return join(projectDir, BOTHOLOMEW_DIR, DB_FILENAME);
|
|
36
31
|
}
|
|
37
32
|
|
|
38
|
-
export function getPidPath(projectDir: string): string {
|
|
39
|
-
return join(projectDir, BOTHOLOMEW_DIR, PID_FILENAME);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
33
|
export function getLogPath(projectDir: string): string {
|
|
43
34
|
return join(projectDir, BOTHOLOMEW_DIR, LOG_FILENAME);
|
|
44
35
|
}
|
|
@@ -54,20 +45,3 @@ export function getMcpxDir(projectDir: string): string {
|
|
|
54
45
|
export function getSkillsDir(projectDir: string): string {
|
|
55
46
|
return join(projectDir, BOTHOLOMEW_DIR, SKILLS_DIR);
|
|
56
47
|
}
|
|
57
|
-
|
|
58
|
-
export function getWatchdogLogPath(projectDir: string): string {
|
|
59
|
-
return join(projectDir, BOTHOLOMEW_DIR, WATCHDOG_LOG_FILENAME);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Convert an absolute directory path into a service-name-safe string.
|
|
64
|
-
* e.g. "/Users/evan/myproject" → "users-evan-myproject"
|
|
65
|
-
*/
|
|
66
|
-
export function sanitizePathForServiceName(projectDir: string): string {
|
|
67
|
-
return projectDir
|
|
68
|
-
.toLowerCase()
|
|
69
|
-
.replace(/[/\\:]+/g, "-")
|
|
70
|
-
.replace(/^-+/, "")
|
|
71
|
-
.replace(/-+$/, "")
|
|
72
|
-
.replace(/-+/g, "-");
|
|
73
|
-
}
|
package/src/db/schedules.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface Schedule {
|
|
|
9
9
|
frequency: string;
|
|
10
10
|
last_run_at: Date | null;
|
|
11
11
|
enabled: boolean;
|
|
12
|
+
claimed_by: string | null;
|
|
13
|
+
claimed_at: Date | null;
|
|
12
14
|
created_at: Date;
|
|
13
15
|
updated_at: Date;
|
|
14
16
|
}
|
|
@@ -20,6 +22,8 @@ interface ScheduleRow {
|
|
|
20
22
|
frequency: string;
|
|
21
23
|
last_run_at: string | null;
|
|
22
24
|
enabled: boolean;
|
|
25
|
+
claimed_by: string | null;
|
|
26
|
+
claimed_at: string | null;
|
|
23
27
|
created_at: string;
|
|
24
28
|
updated_at: string;
|
|
25
29
|
}
|
|
@@ -32,6 +36,8 @@ function rowToSchedule(row: ScheduleRow): Schedule {
|
|
|
32
36
|
frequency: row.frequency,
|
|
33
37
|
last_run_at: row.last_run_at ? new Date(row.last_run_at) : null,
|
|
34
38
|
enabled: !!row.enabled,
|
|
39
|
+
claimed_by: row.claimed_by ?? null,
|
|
40
|
+
claimed_at: row.claimed_at ? new Date(row.claimed_at) : null,
|
|
35
41
|
created_at: new Date(row.created_at),
|
|
36
42
|
updated_at: new Date(row.updated_at),
|
|
37
43
|
};
|
|
@@ -145,3 +151,63 @@ export async function markScheduleRun(
|
|
|
145
151
|
id,
|
|
146
152
|
);
|
|
147
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Atomically claim a schedule for evaluation. Returns the schedule if
|
|
157
|
+
* successfully claimed, or null if another worker already holds the claim
|
|
158
|
+
* or the schedule ran too recently to re-evaluate.
|
|
159
|
+
*
|
|
160
|
+
* `staleAfterSeconds`: how long an existing claim is considered still-held
|
|
161
|
+
* before another worker may steal it (protects against crashed claimers).
|
|
162
|
+
* `minIntervalSeconds`: minimum gap since `last_run_at` before re-evaluation.
|
|
163
|
+
*/
|
|
164
|
+
export async function claimSchedule(
|
|
165
|
+
db: DbConnection,
|
|
166
|
+
id: string,
|
|
167
|
+
claimedBy: string,
|
|
168
|
+
opts: { staleAfterSeconds: number; minIntervalSeconds: number },
|
|
169
|
+
): Promise<Schedule | null> {
|
|
170
|
+
const row = await db.queryGet<ScheduleRow>(
|
|
171
|
+
`UPDATE schedules
|
|
172
|
+
SET claimed_by = ?1,
|
|
173
|
+
claimed_at = current_timestamp::VARCHAR
|
|
174
|
+
WHERE id = ?2
|
|
175
|
+
AND enabled = true
|
|
176
|
+
AND (
|
|
177
|
+
claimed_by IS NULL
|
|
178
|
+
OR claimed_at IS NULL
|
|
179
|
+
OR claimed_at::TIMESTAMP
|
|
180
|
+
< current_timestamp - to_seconds(CAST(?3 AS BIGINT))
|
|
181
|
+
)
|
|
182
|
+
AND (
|
|
183
|
+
last_run_at IS NULL
|
|
184
|
+
OR last_run_at::TIMESTAMP
|
|
185
|
+
< current_timestamp - to_seconds(CAST(?4 AS BIGINT))
|
|
186
|
+
)
|
|
187
|
+
RETURNING *`,
|
|
188
|
+
claimedBy,
|
|
189
|
+
id,
|
|
190
|
+
opts.staleAfterSeconds,
|
|
191
|
+
opts.minIntervalSeconds,
|
|
192
|
+
);
|
|
193
|
+
return row ? rowToSchedule(row) : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Release a schedule claim without modifying `last_run_at`. Safe to call
|
|
198
|
+
* even if the claim has already expired — the WHERE guard ensures we only
|
|
199
|
+
* clear our own claim.
|
|
200
|
+
*/
|
|
201
|
+
export async function releaseSchedule(
|
|
202
|
+
db: DbConnection,
|
|
203
|
+
id: string,
|
|
204
|
+
claimedBy: string,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
await db.queryRun(
|
|
207
|
+
`UPDATE schedules
|
|
208
|
+
SET claimed_by = NULL, claimed_at = NULL
|
|
209
|
+
WHERE id = ?1 AND claimed_by = ?2`,
|
|
210
|
+
id,
|
|
211
|
+
claimedBy,
|
|
212
|
+
);
|
|
213
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -11,11 +11,9 @@ interface Migration {
|
|
|
11
11
|
const sqlDir = join(import.meta.dir, "sql");
|
|
12
12
|
|
|
13
13
|
function loadMigrations(): Migration[] {
|
|
14
|
-
const files = readdirSync(sqlDir)
|
|
15
|
-
.filter((f) => f.endsWith(".sql"))
|
|
16
|
-
.sort();
|
|
14
|
+
const files = readdirSync(sqlDir).filter((f) => f.endsWith(".sql"));
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
const migrations = files.map((file) => {
|
|
19
17
|
const match = file.match(/^(\d+)-(.+)\.sql$/);
|
|
20
18
|
if (!match) throw new Error(`Invalid migration filename: ${file}`);
|
|
21
19
|
const id = match[1];
|
|
@@ -27,6 +25,9 @@ function loadMigrations(): Migration[] {
|
|
|
27
25
|
sql: readFileSync(join(sqlDir, file), "utf-8"),
|
|
28
26
|
};
|
|
29
27
|
});
|
|
28
|
+
|
|
29
|
+
// Sort by numeric id so `12-` runs after `2-`, not between `11-` and `2-`.
|
|
30
|
+
return migrations.sort((a, b) => a.id - b.id);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export async function migrate(db: DbConnection): Promise<void> {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
-- Worker agents: replaces the PID-file + OS-watchdog single-daemon model
|
|
2
|
+
-- with multiple in-DB registered workers that heartbeat and can be reaped.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE workers (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
pid INTEGER NOT NULL,
|
|
7
|
+
hostname TEXT NOT NULL,
|
|
8
|
+
mode TEXT NOT NULL CHECK(mode IN ('persist', 'once')),
|
|
9
|
+
task_id TEXT,
|
|
10
|
+
status TEXT NOT NULL CHECK(status IN ('running', 'stopped', 'dead')),
|
|
11
|
+
started_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
|
|
12
|
+
last_heartbeat_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
|
|
13
|
+
stopped_at TEXT
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE INDEX idx_workers_status_heartbeat ON workers(status, last_heartbeat_at);
|
|
17
|
+
|
|
18
|
+
-- Schedule claim columns: only one worker evaluates a schedule per window.
|
|
19
|
+
ALTER TABLE schedules ADD COLUMN claimed_by TEXT;
|
|
20
|
+
ALTER TABLE schedules ADD COLUMN claimed_at TEXT;
|
|
21
|
+
|
|
22
|
+
-- Rewrite threads.type values: daemon_tick → worker_tick. The existing
|
|
23
|
+
-- CHECK constraint forbids the new value, so we rebuild both threads and
|
|
24
|
+
-- interactions (whose FK to threads would block a DROP). Dropping the FK
|
|
25
|
+
-- follows the 7-drop_embeddings_fk.sql precedent.
|
|
26
|
+
CREATE TABLE threads_backup AS SELECT * FROM threads;
|
|
27
|
+
CREATE TABLE interactions_backup AS SELECT * FROM interactions;
|
|
28
|
+
|
|
29
|
+
DROP TABLE interactions;
|
|
30
|
+
DROP TABLE threads;
|
|
31
|
+
|
|
32
|
+
CREATE TABLE threads (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
type TEXT NOT NULL CHECK(type IN ('worker_tick', 'chat_session')),
|
|
35
|
+
task_id TEXT,
|
|
36
|
+
title TEXT NOT NULL DEFAULT '',
|
|
37
|
+
started_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
|
|
38
|
+
ended_at TEXT,
|
|
39
|
+
metadata TEXT
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE interactions (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
thread_id TEXT NOT NULL,
|
|
45
|
+
sequence INTEGER NOT NULL,
|
|
46
|
+
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
|
47
|
+
kind TEXT NOT NULL CHECK(kind IN ('message', 'thinking', 'tool_use', 'tool_result', 'context_update', 'status_change')),
|
|
48
|
+
content TEXT NOT NULL,
|
|
49
|
+
tool_name TEXT,
|
|
50
|
+
tool_input TEXT,
|
|
51
|
+
duration_ms INTEGER,
|
|
52
|
+
token_count INTEGER,
|
|
53
|
+
created_at TEXT NOT NULL DEFAULT (current_timestamp::VARCHAR),
|
|
54
|
+
UNIQUE(thread_id, sequence)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
INSERT INTO threads
|
|
58
|
+
SELECT id,
|
|
59
|
+
CASE WHEN type = 'daemon_tick' THEN 'worker_tick' ELSE type END,
|
|
60
|
+
task_id, title, started_at, ended_at, metadata
|
|
61
|
+
FROM threads_backup;
|
|
62
|
+
|
|
63
|
+
INSERT INTO interactions SELECT * FROM interactions_backup;
|
|
64
|
+
|
|
65
|
+
DROP TABLE threads_backup;
|
|
66
|
+
DROP TABLE interactions_backup;
|
package/src/db/tasks.ts
CHANGED
|
@@ -270,7 +270,7 @@ export async function resetStaleTasks(
|
|
|
270
270
|
|
|
271
271
|
export async function claimNextTask(
|
|
272
272
|
db: DbConnection,
|
|
273
|
-
claimedBy
|
|
273
|
+
claimedBy: string,
|
|
274
274
|
): Promise<Task | null> {
|
|
275
275
|
// Find highest-priority unblocked pending task
|
|
276
276
|
// Use application-level filtering for blocked_by since DuckDB doesn't have json_each
|
|
@@ -321,3 +321,27 @@ export async function claimNextTask(
|
|
|
321
321
|
|
|
322
322
|
return null;
|
|
323
323
|
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Atomically claim a specific task by id. Returns the task if successfully
|
|
327
|
+
* claimed, or null if the task doesn't exist, is already claimed, or isn't
|
|
328
|
+
* in `pending` state.
|
|
329
|
+
*/
|
|
330
|
+
export async function claimSpecificTask(
|
|
331
|
+
db: DbConnection,
|
|
332
|
+
taskId: string,
|
|
333
|
+
claimedBy: string,
|
|
334
|
+
): Promise<Task | null> {
|
|
335
|
+
const row = await db.queryGet<TaskRow>(
|
|
336
|
+
`UPDATE tasks
|
|
337
|
+
SET status = 'in_progress',
|
|
338
|
+
claimed_by = ?1,
|
|
339
|
+
claimed_at = current_timestamp::VARCHAR,
|
|
340
|
+
updated_at = current_timestamp::VARCHAR
|
|
341
|
+
WHERE id = ?2 AND status = 'pending'
|
|
342
|
+
RETURNING *`,
|
|
343
|
+
claimedBy,
|
|
344
|
+
taskId,
|
|
345
|
+
);
|
|
346
|
+
return row ? rowToTask(row) : null;
|
|
347
|
+
}
|
package/src/db/threads.ts
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { DbConnection } from "./connection.ts";
|
|
2
|
+
import { buildWhereClause, sanitizeInt } from "./query.ts";
|
|
3
|
+
|
|
4
|
+
export const WORKER_MODES = ["persist", "once"] as const;
|
|
5
|
+
export const WORKER_STATUSES = ["running", "stopped", "dead"] as const;
|
|
6
|
+
|
|
7
|
+
export interface Worker {
|
|
8
|
+
id: string;
|
|
9
|
+
pid: number;
|
|
10
|
+
hostname: string;
|
|
11
|
+
mode: (typeof WORKER_MODES)[number];
|
|
12
|
+
task_id: string | null;
|
|
13
|
+
status: (typeof WORKER_STATUSES)[number];
|
|
14
|
+
started_at: Date;
|
|
15
|
+
last_heartbeat_at: Date;
|
|
16
|
+
stopped_at: Date | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WorkerRow {
|
|
20
|
+
id: string;
|
|
21
|
+
pid: number;
|
|
22
|
+
hostname: string;
|
|
23
|
+
mode: string;
|
|
24
|
+
task_id: string | null;
|
|
25
|
+
status: string;
|
|
26
|
+
started_at: string;
|
|
27
|
+
last_heartbeat_at: string;
|
|
28
|
+
stopped_at: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rowToWorker(row: WorkerRow): Worker {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id,
|
|
34
|
+
pid: row.pid,
|
|
35
|
+
hostname: row.hostname,
|
|
36
|
+
mode: row.mode as Worker["mode"],
|
|
37
|
+
task_id: row.task_id,
|
|
38
|
+
status: row.status as Worker["status"],
|
|
39
|
+
started_at: new Date(row.started_at),
|
|
40
|
+
last_heartbeat_at: new Date(row.last_heartbeat_at),
|
|
41
|
+
stopped_at: row.stopped_at ? new Date(row.stopped_at) : null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function registerWorker(
|
|
46
|
+
db: DbConnection,
|
|
47
|
+
params: {
|
|
48
|
+
id: string;
|
|
49
|
+
pid: number;
|
|
50
|
+
hostname: string;
|
|
51
|
+
mode: Worker["mode"];
|
|
52
|
+
taskId?: string | null;
|
|
53
|
+
},
|
|
54
|
+
): Promise<Worker> {
|
|
55
|
+
const row = await db.queryGet<WorkerRow>(
|
|
56
|
+
`INSERT INTO workers (id, pid, hostname, mode, task_id, status)
|
|
57
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'running')
|
|
58
|
+
RETURNING *`,
|
|
59
|
+
params.id,
|
|
60
|
+
params.pid,
|
|
61
|
+
params.hostname,
|
|
62
|
+
params.mode,
|
|
63
|
+
params.taskId ?? null,
|
|
64
|
+
);
|
|
65
|
+
if (!row) throw new Error("INSERT did not return a row");
|
|
66
|
+
return rowToWorker(row);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function heartbeat(db: DbConnection, id: string): Promise<void> {
|
|
70
|
+
await db.queryRun(
|
|
71
|
+
`UPDATE workers
|
|
72
|
+
SET last_heartbeat_at = current_timestamp::VARCHAR
|
|
73
|
+
WHERE id = ?1 AND status = 'running'`,
|
|
74
|
+
id,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function markWorkerStopped(
|
|
79
|
+
db: DbConnection,
|
|
80
|
+
id: string,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
await db.queryRun(
|
|
83
|
+
`UPDATE workers
|
|
84
|
+
SET status = 'stopped',
|
|
85
|
+
stopped_at = current_timestamp::VARCHAR
|
|
86
|
+
WHERE id = ?1 AND status = 'running'`,
|
|
87
|
+
id,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function markWorkerDead(
|
|
92
|
+
db: DbConnection,
|
|
93
|
+
id: string,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
await db.queryRun(
|
|
96
|
+
`UPDATE workers
|
|
97
|
+
SET status = 'dead',
|
|
98
|
+
stopped_at = current_timestamp::VARCHAR
|
|
99
|
+
WHERE id = ?1 AND status = 'running'`,
|
|
100
|
+
id,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Find running workers whose heartbeat is older than `staleAfterSeconds`,
|
|
106
|
+
* mark them dead, and release any tasks/schedule claims they held back
|
|
107
|
+
* to the pool. Returns the ids of reaped workers.
|
|
108
|
+
*/
|
|
109
|
+
export async function reapDeadWorkers(
|
|
110
|
+
db: DbConnection,
|
|
111
|
+
staleAfterSeconds: number,
|
|
112
|
+
): Promise<string[]> {
|
|
113
|
+
const stale = await db.queryAll<{ id: string }>(
|
|
114
|
+
`UPDATE workers
|
|
115
|
+
SET status = 'dead',
|
|
116
|
+
stopped_at = current_timestamp::VARCHAR
|
|
117
|
+
WHERE status = 'running'
|
|
118
|
+
AND last_heartbeat_at::TIMESTAMP
|
|
119
|
+
< current_timestamp - to_seconds(CAST(?1 AS BIGINT))
|
|
120
|
+
RETURNING id`,
|
|
121
|
+
staleAfterSeconds,
|
|
122
|
+
);
|
|
123
|
+
const reapedIds = stale.map((r) => r.id);
|
|
124
|
+
if (reapedIds.length === 0) return reapedIds;
|
|
125
|
+
|
|
126
|
+
for (const reapedId of reapedIds) {
|
|
127
|
+
await db.queryRun(
|
|
128
|
+
`UPDATE tasks
|
|
129
|
+
SET status = 'pending',
|
|
130
|
+
claimed_by = NULL,
|
|
131
|
+
claimed_at = NULL,
|
|
132
|
+
updated_at = current_timestamp::VARCHAR
|
|
133
|
+
WHERE claimed_by = ?1 AND status = 'in_progress'`,
|
|
134
|
+
reapedId,
|
|
135
|
+
);
|
|
136
|
+
await db.queryRun(
|
|
137
|
+
`UPDATE schedules
|
|
138
|
+
SET claimed_by = NULL,
|
|
139
|
+
claimed_at = NULL
|
|
140
|
+
WHERE claimed_by = ?1`,
|
|
141
|
+
reapedId,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return reapedIds;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Delete cleanly-stopped workers (status='stopped') whose `stopped_at` is
|
|
149
|
+
* older than `afterSeconds`. Dead workers are intentionally left alone —
|
|
150
|
+
* they're forensic evidence that something crashed.
|
|
151
|
+
* Returns the ids that were pruned.
|
|
152
|
+
*/
|
|
153
|
+
export async function pruneStoppedWorkers(
|
|
154
|
+
db: DbConnection,
|
|
155
|
+
afterSeconds: number,
|
|
156
|
+
): Promise<string[]> {
|
|
157
|
+
const rows = await db.queryAll<{ id: string }>(
|
|
158
|
+
`DELETE FROM workers
|
|
159
|
+
WHERE status = 'stopped'
|
|
160
|
+
AND stopped_at IS NOT NULL
|
|
161
|
+
AND stopped_at::TIMESTAMP
|
|
162
|
+
< current_timestamp - to_seconds(CAST(?1 AS BIGINT))
|
|
163
|
+
RETURNING id`,
|
|
164
|
+
afterSeconds,
|
|
165
|
+
);
|
|
166
|
+
return rows.map((r) => r.id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function listWorkers(
|
|
170
|
+
db: DbConnection,
|
|
171
|
+
filters?: {
|
|
172
|
+
status?: Worker["status"];
|
|
173
|
+
limit?: number;
|
|
174
|
+
offset?: number;
|
|
175
|
+
},
|
|
176
|
+
): Promise<Worker[]> {
|
|
177
|
+
const { where, params } = buildWhereClause([["status", filters?.status]]);
|
|
178
|
+
const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
|
|
179
|
+
const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
|
|
180
|
+
|
|
181
|
+
const rows = await db.queryAll<WorkerRow>(
|
|
182
|
+
`SELECT * FROM workers ${where}
|
|
183
|
+
ORDER BY started_at DESC, id DESC
|
|
184
|
+
${limit} ${offset}`,
|
|
185
|
+
...params,
|
|
186
|
+
);
|
|
187
|
+
return rows.map(rowToWorker);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function getWorker(
|
|
191
|
+
db: DbConnection,
|
|
192
|
+
id: string,
|
|
193
|
+
): Promise<Worker | null> {
|
|
194
|
+
const row = await db.queryGet<WorkerRow>(
|
|
195
|
+
"SELECT * FROM workers WHERE id = ?1",
|
|
196
|
+
id,
|
|
197
|
+
);
|
|
198
|
+
return row ? rowToWorker(row) : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function deleteWorker(
|
|
202
|
+
db: DbConnection,
|
|
203
|
+
id: string,
|
|
204
|
+
): Promise<boolean> {
|
|
205
|
+
const result = await db.queryRun("DELETE FROM workers WHERE id = ?1", id);
|
|
206
|
+
return result.changes > 0;
|
|
207
|
+
}
|
package/src/init/index.ts
CHANGED
|
@@ -77,7 +77,9 @@ export async function initProject(
|
|
|
77
77
|
logger.dim("Next steps:");
|
|
78
78
|
logger.dim(" 1. Set ANTHROPIC_API_KEY or add it to .botholomew/config.json");
|
|
79
79
|
logger.dim(" 2. Run 'botholomew task add' to create your first task");
|
|
80
|
-
logger.dim(
|
|
80
|
+
logger.dim(
|
|
81
|
+
" 3. Run 'botholomew worker start --persist' to start a background worker",
|
|
82
|
+
);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
async function updateGitignore(projectDir: string): Promise<void> {
|
package/src/tools/mcp/exec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { fakeMcpExec, isCaptureMode } from "../../daemon/fake-mcp.ts";
|
|
3
2
|
import { formatCallToolResult } from "../../mcpx/client.ts";
|
|
3
|
+
import { fakeMcpExec, isCaptureMode } from "../../worker/fake-mcp.ts";
|
|
4
4
|
import type { ToolDefinition } from "../tool.ts";
|
|
5
5
|
|
|
6
6
|
const inputSchema = z.object({
|
package/src/tools/mcp/search.ts
CHANGED
package/src/tools/registry.ts
CHANGED
|
@@ -43,6 +43,8 @@ import { waitTaskTool } from "./task/wait.ts";
|
|
|
43
43
|
import { listThreadsTool } from "./thread/list.ts";
|
|
44
44
|
import { viewThreadTool } from "./thread/view.ts";
|
|
45
45
|
import { registerTool } from "./tool.ts";
|
|
46
|
+
// Worker tools
|
|
47
|
+
import { spawnWorkerTool } from "./worker/spawn.ts";
|
|
46
48
|
|
|
47
49
|
export function registerAllTools(): void {
|
|
48
50
|
// Task
|
|
@@ -91,4 +93,7 @@ export function registerAllTools(): void {
|
|
|
91
93
|
registerTool(mcpSearchTool);
|
|
92
94
|
registerTool(mcpInfoTool);
|
|
93
95
|
registerTool(mcpExecTool);
|
|
96
|
+
|
|
97
|
+
// Worker
|
|
98
|
+
registerTool(spawnWorkerTool);
|
|
94
99
|
}
|
package/src/tools/thread/list.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { ToolDefinition } from "../tool.ts";
|
|
|
4
4
|
|
|
5
5
|
const inputSchema = z.object({
|
|
6
6
|
type: z
|
|
7
|
-
.enum(["
|
|
7
|
+
.enum(["worker_tick", "chat_session"])
|
|
8
8
|
.optional()
|
|
9
9
|
.describe("Filter by thread type"),
|
|
10
10
|
limit: z.number().optional().describe("Max number of threads to return"),
|
|
@@ -27,7 +27,7 @@ const outputSchema = z.object({
|
|
|
27
27
|
|
|
28
28
|
export const listThreadsTool = {
|
|
29
29
|
name: "list_threads",
|
|
30
|
-
description: "List conversation threads (
|
|
30
|
+
description: "List conversation threads (worker ticks or chat sessions).",
|
|
31
31
|
group: "thread",
|
|
32
32
|
inputSchema,
|
|
33
33
|
outputSchema,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnWorker } from "../../worker/spawn.ts";
|
|
3
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
4
|
+
|
|
5
|
+
const inputSchema = z.object({
|
|
6
|
+
task_id: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe(
|
|
10
|
+
"Specific task ID to run. If omitted, the worker claims the next eligible task from the queue.",
|
|
11
|
+
),
|
|
12
|
+
persist: z
|
|
13
|
+
.boolean()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
"If true, spawn a long-running worker that loops over the tick cycle. Defaults to false (one-shot).",
|
|
17
|
+
),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const outputSchema = z.object({
|
|
21
|
+
worker_pid: z.number(),
|
|
22
|
+
mode: z.enum(["once", "persist"]),
|
|
23
|
+
message: z.string(),
|
|
24
|
+
is_error: z.boolean(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const spawnWorkerTool = {
|
|
28
|
+
name: "spawn_worker",
|
|
29
|
+
description:
|
|
30
|
+
"Spawn a background worker to run a task without blocking this chat. One-shot by default (claims one task and exits). Use for work the user wants executed now rather than simply queued.",
|
|
31
|
+
group: "worker",
|
|
32
|
+
inputSchema,
|
|
33
|
+
outputSchema,
|
|
34
|
+
execute: async (input, ctx) => {
|
|
35
|
+
const mode = input.persist ? "persist" : "once";
|
|
36
|
+
const { pid } = await spawnWorker(ctx.projectDir, {
|
|
37
|
+
mode,
|
|
38
|
+
taskId: input.task_id,
|
|
39
|
+
});
|
|
40
|
+
const target = input.task_id
|
|
41
|
+
? `task ${input.task_id}`
|
|
42
|
+
: "next eligible task";
|
|
43
|
+
return {
|
|
44
|
+
worker_pid: pid,
|
|
45
|
+
mode,
|
|
46
|
+
message: `Spawned ${mode} worker (pid ${pid}) for ${target}.`,
|
|
47
|
+
is_error: false,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|