botholomew 0.7.12 → 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.
Files changed (55) hide show
  1. package/README.md +37 -32
  2. package/package.json +1 -1
  3. package/src/chat/agent.ts +13 -11
  4. package/src/cli.ts +2 -2
  5. package/src/commands/chat.ts +29 -44
  6. package/src/commands/nuke.ts +11 -8
  7. package/src/commands/schedule.ts +1 -1
  8. package/src/commands/thread.ts +2 -2
  9. package/src/commands/with-db.ts +1 -1
  10. package/src/commands/worker.ts +231 -0
  11. package/src/config/schemas.ts +12 -0
  12. package/src/constants.ts +1 -27
  13. package/src/db/schedules.ts +66 -0
  14. package/src/db/schema.ts +5 -4
  15. package/src/db/sql/12-workers.sql +66 -0
  16. package/src/db/tasks.ts +25 -1
  17. package/src/db/threads.ts +1 -1
  18. package/src/db/workers.ts +207 -0
  19. package/src/init/index.ts +3 -1
  20. package/src/tools/context/read-large-result.ts +1 -1
  21. package/src/tools/mcp/exec.ts +1 -1
  22. package/src/tools/mcp/search.ts +1 -1
  23. package/src/tools/registry.ts +5 -0
  24. package/src/tools/thread/list.ts +2 -2
  25. package/src/tools/worker/spawn.ts +50 -0
  26. package/src/tui/App.tsx +15 -7
  27. package/src/tui/components/ContextPanel.tsx +5 -1
  28. package/src/tui/components/HelpPanel.tsx +5 -5
  29. package/src/tui/components/StatusBar.tsx +22 -18
  30. package/src/tui/components/TabBar.tsx +3 -2
  31. package/src/tui/components/ThreadPanel.tsx +7 -7
  32. package/src/tui/components/WorkerPanel.tsx +207 -0
  33. package/src/utils/title.ts +1 -1
  34. package/src/worker/heartbeat.ts +78 -0
  35. package/src/worker/index.ts +200 -0
  36. package/src/{daemon → worker}/llm.ts +5 -5
  37. package/src/{daemon → worker}/prompt.ts +2 -2
  38. package/src/worker/run.ts +26 -0
  39. package/src/{daemon → worker}/schedules.ts +30 -2
  40. package/src/worker/spawn.ts +48 -0
  41. package/src/{daemon → worker}/tick.ts +93 -35
  42. package/src/commands/daemon.ts +0 -152
  43. package/src/daemon/ensure-running.ts +0 -16
  44. package/src/daemon/healthcheck.ts +0 -47
  45. package/src/daemon/index.ts +0 -106
  46. package/src/daemon/run.ts +0 -14
  47. package/src/daemon/spawn.ts +0 -38
  48. package/src/daemon/watchdog.ts +0 -306
  49. package/src/utils/pid.ts +0 -55
  50. package/src/utils/project-registry.ts +0 -48
  51. /package/src/{daemon → worker}/context.ts +0 -0
  52. /package/src/{daemon → worker}/fake-llm.ts +0 -0
  53. /package/src/{daemon → worker}/fake-mcp.ts +0 -0
  54. /package/src/{daemon → worker}/large-results.ts +0 -0
  55. /package/src/{daemon → worker}/llm-client.ts +0 -0
@@ -0,0 +1,200 @@
1
+ import { hostname } from "node:os";
2
+ import ansis from "ansis";
3
+ import { loadConfig } from "../config/loader.ts";
4
+ import { getDbPath } from "../constants.ts";
5
+ import { withDb } from "../db/connection.ts";
6
+ import { migrate } from "../db/schema.ts";
7
+ import { uuidv7 } from "../db/uuid.ts";
8
+ import { markWorkerStopped, registerWorker } from "../db/workers.ts";
9
+ import { createMcpxClient } from "../mcpx/client.ts";
10
+ import { logger } from "../utils/logger.ts";
11
+ import { startHeartbeat, startReaper } from "./heartbeat.ts";
12
+ import type { WorkerStreamCallbacks } from "./llm.ts";
13
+ import { runSpecificTask, tick } from "./tick.ts";
14
+
15
+ export type WorkerMode = "persist" | "once";
16
+
17
+ export interface StartWorkerOptions {
18
+ /** When true, stream LLM tokens + tool calls to stdout for the CLI `run` command. */
19
+ foreground?: boolean;
20
+ /** 'once' (default): claim one task and exit. 'persist': run the tick loop forever. */
21
+ mode?: WorkerMode;
22
+ /**
23
+ * When mode='once', optionally pin this worker to a specific task id.
24
+ * When omitted, the worker claims the next eligible task from the queue.
25
+ */
26
+ taskId?: string;
27
+ /**
28
+ * Whether to evaluate schedules as part of this run.
29
+ * Defaults to `true` for one-shot workers without a taskId and for persist
30
+ * workers; `false` when a taskId is supplied (targeted work shouldn't fan
31
+ * out into unrelated schedule processing).
32
+ */
33
+ evalSchedules?: boolean;
34
+ }
35
+
36
+ function buildForegroundCallbacks(): WorkerStreamCallbacks {
37
+ return {
38
+ onTaskStart(task) {
39
+ process.stdout.write(
40
+ `\n${ansis.bold.blue(`Task: ${task.name}`)} ${ansis.dim(`(${task.id})`)}\n`,
41
+ );
42
+ if (task.description) {
43
+ process.stdout.write(`${ansis.dim(task.description)}\n`);
44
+ }
45
+ process.stdout.write("\n");
46
+ },
47
+ onToken(text) {
48
+ process.stdout.write(text);
49
+ },
50
+ onToolStart(name, input) {
51
+ process.stdout.write(
52
+ ` ${ansis.yellow("▶")} ${ansis.bold(name)} ${ansis.dim(input)}\n`,
53
+ );
54
+ },
55
+ onToolEnd(name, _output, isError, durationMs) {
56
+ const seconds = (durationMs / 1000).toFixed(1);
57
+ if (isError) {
58
+ process.stdout.write(
59
+ ` ${ansis.red("✗")} ${ansis.bold(name)} ${ansis.red("error")} ${ansis.dim(`(${seconds}s)`)}\n`,
60
+ );
61
+ } else {
62
+ process.stdout.write(
63
+ ` ${ansis.green("✓")} ${ansis.bold(name)} ${ansis.dim(`(${seconds}s)`)}\n`,
64
+ );
65
+ }
66
+ },
67
+ };
68
+ }
69
+
70
+ export async function startWorker(
71
+ projectDir: string,
72
+ options: StartWorkerOptions = {},
73
+ ): Promise<void> {
74
+ const mode: WorkerMode = options.mode ?? "once";
75
+ const { taskId } = options;
76
+ const evalSchedules = options.evalSchedules ?? !taskId;
77
+
78
+ const config = await loadConfig(projectDir);
79
+ const dbPath = getDbPath(projectDir);
80
+
81
+ // One short-lived connection to apply migrations, then release the lock.
82
+ await withDb(dbPath, (conn) => migrate(conn));
83
+
84
+ const mcpxClient = await createMcpxClient(projectDir);
85
+ if (mcpxClient) {
86
+ logger.info("MCPX client initialized with external tools");
87
+ }
88
+
89
+ const workerId = uuidv7();
90
+ await withDb(dbPath, (conn) =>
91
+ registerWorker(conn, {
92
+ id: workerId,
93
+ pid: process.pid,
94
+ hostname: hostname(),
95
+ mode,
96
+ taskId: taskId ?? null,
97
+ }),
98
+ );
99
+
100
+ const stopHeartbeat = startHeartbeat(
101
+ dbPath,
102
+ workerId,
103
+ config.worker_heartbeat_interval_seconds,
104
+ );
105
+ const stopReaper =
106
+ mode === "persist"
107
+ ? startReaper(
108
+ dbPath,
109
+ config.worker_reap_interval_seconds,
110
+ config.worker_dead_after_seconds,
111
+ config.worker_stopped_retention_seconds,
112
+ )
113
+ : () => {};
114
+
115
+ const shutdown = async () => {
116
+ logger.info("Worker shutting down...");
117
+ stopHeartbeat();
118
+ stopReaper();
119
+ await mcpxClient?.close();
120
+ try {
121
+ await withDb(dbPath, (conn) => markWorkerStopped(conn, workerId));
122
+ } catch (err) {
123
+ logger.warn(`failed to mark worker stopped: ${err}`);
124
+ }
125
+ process.exit(0);
126
+ };
127
+
128
+ process.on("SIGTERM", shutdown);
129
+ process.on("SIGINT", shutdown);
130
+
131
+ const callbacks = options.foreground ? buildForegroundCallbacks() : undefined;
132
+
133
+ logger.info(
134
+ `Worker ${workerId} started ${new Date().toISOString()} for ${projectDir} (PID ${process.pid}, mode=${mode})`,
135
+ );
136
+
137
+ try {
138
+ if (mode === "once") {
139
+ if (taskId) {
140
+ await runSpecificTask({
141
+ projectDir,
142
+ dbPath,
143
+ config,
144
+ workerId,
145
+ taskId,
146
+ mcpxClient,
147
+ callbacks,
148
+ });
149
+ } else {
150
+ await tick({
151
+ projectDir,
152
+ dbPath,
153
+ config,
154
+ workerId,
155
+ mcpxClient,
156
+ callbacks,
157
+ tickNum: 1,
158
+ evalSchedules,
159
+ });
160
+ }
161
+ return;
162
+ }
163
+
164
+ // persist mode: loop forever until SIGTERM/SIGINT flips us into shutdown()
165
+ logger.info(`Tick interval: ${config.tick_interval_seconds}s`);
166
+ let tickNum = 0;
167
+ while (true) {
168
+ tickNum++;
169
+ let didWork = false;
170
+ try {
171
+ didWork = await tick({
172
+ projectDir,
173
+ dbPath,
174
+ config,
175
+ workerId,
176
+ mcpxClient,
177
+ callbacks,
178
+ tickNum,
179
+ evalSchedules: true,
180
+ });
181
+ } catch (err) {
182
+ logger.error(`Tick failed: ${err}`);
183
+ }
184
+
185
+ if (!didWork) {
186
+ logger.phase("sleeping", `${config.tick_interval_seconds}s`);
187
+ await Bun.sleep(config.tick_interval_seconds * 1000);
188
+ }
189
+ }
190
+ } finally {
191
+ stopHeartbeat();
192
+ stopReaper();
193
+ try {
194
+ await withDb(dbPath, (conn) => markWorkerStopped(conn, workerId));
195
+ } catch (err) {
196
+ logger.warn(`failed to mark worker stopped: ${err}`);
197
+ }
198
+ await mcpxClient?.close();
199
+ }
200
+ }
@@ -17,7 +17,7 @@ import { createLlmClient } from "./llm-client.ts";
17
17
 
18
18
  registerAllTools();
19
19
 
20
- export interface DaemonStreamCallbacks {
20
+ export interface WorkerStreamCallbacks {
21
21
  onToken: (text: string) => void;
22
22
  onToolStart: (name: string, input: string) => void;
23
23
  onToolEnd: (
@@ -48,7 +48,7 @@ export async function runAgentLoop(input: {
48
48
  threadId: string;
49
49
  projectDir: string;
50
50
  mcpxClient?: McpxClient | null;
51
- callbacks?: DaemonStreamCallbacks;
51
+ callbacks?: WorkerStreamCallbacks;
52
52
  }): Promise<AgentLoopResult> {
53
53
  const {
54
54
  systemPrompt,
@@ -93,7 +93,7 @@ export async function runAgentLoop(input: {
93
93
  );
94
94
 
95
95
  clearLargeResults();
96
- const daemonTools = toAnthropicTools();
96
+ const workerTools = toAnthropicTools();
97
97
  const maxInputTokens = await getMaxInputTokens(
98
98
  config.anthropic_api_key,
99
99
  config.model,
@@ -113,7 +113,7 @@ export async function runAgentLoop(input: {
113
113
  max_tokens: 4096,
114
114
  system: systemPrompt,
115
115
  messages,
116
- tools: daemonTools,
116
+ tools: workerTools,
117
117
  });
118
118
 
119
119
  stream.on("text", (text) => {
@@ -133,7 +133,7 @@ export async function runAgentLoop(input: {
133
133
  max_tokens: 4096,
134
134
  system: systemPrompt,
135
135
  messages,
136
- tools: daemonTools,
136
+ tools: workerTools,
137
137
  });
138
138
  }
139
139
 
@@ -16,7 +16,7 @@ const pkg = await Bun.file(
16
16
  /**
17
17
  * Extract keyword set from free-form text: lowercase, split on whitespace,
18
18
  * keep words longer than 3 chars. Used to match `loading: contextual` files
19
- * against the agent's current intent (task text for the daemon, latest user
19
+ * against the agent's current intent (task text for the worker, latest user
20
20
  * message for the chat).
21
21
  */
22
22
  export function extractKeywords(text: string): Set<string> {
@@ -134,7 +134,7 @@ export async function buildSystemPrompt(
134
134
  // Instructions
135
135
  parts.push("## Instructions");
136
136
  parts.push(
137
- "You are Botholomew, a wise-owl daemon that works through tasks. Use available tools to complete your assigned task, then call complete_task, fail_task, or wait_task. Use create_task for subtasks and update_task to refine pending tasks. Batch independent tool calls in a single response for parallel execution.\n\nWhen calling complete_task, write a summary that captures your key findings, decisions, and outputs. This summary becomes the task's output and is provided to any downstream tasks that depend on this one. Include specific results (data, names, paths, conclusions) rather than vague descriptions of what you did — downstream tasks will rely on this information to do their work.",
137
+ "You are Botholomew, a wise-owl worker that works through tasks. Use available tools to complete your assigned task, then call complete_task, fail_task, or wait_task. Use create_task for subtasks and update_task to refine pending tasks. Batch independent tool calls in a single response for parallel execution.\n\nWhen calling complete_task, write a summary that captures your key findings, decisions, and outputs. This summary becomes the task's output and is provided to any downstream tasks that depend on this one. Include specific results (data, names, paths, conclusions) rather than vague descriptions of what you did — downstream tasks will rely on this information to do their work.",
138
138
  );
139
139
  if (options?.hasMcpTools) {
140
140
  parts.push("");
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // Standalone entry point for a worker when spawned as a detached process.
4
+ // Usage: bun run src/worker/run.ts <projectDir> [--persist] [--task-id=<uuid>] [--no-eval-schedules]
5
+
6
+ import { startWorker } from "./index.ts";
7
+
8
+ const projectDir = process.argv[2];
9
+ if (!projectDir) {
10
+ console.error(
11
+ "Usage: bun run src/worker/run.ts <projectDir> [--persist] [--task-id=<uuid>] [--no-eval-schedules]",
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ const args = process.argv.slice(3);
17
+ const persist = args.includes("--persist");
18
+ const noEvalSchedules = args.includes("--no-eval-schedules");
19
+ const taskIdArg = args.find((a) => a.startsWith("--task-id="));
20
+ const taskId = taskIdArg ? taskIdArg.slice("--task-id=".length) : undefined;
21
+
22
+ await startWorker(projectDir, {
23
+ mode: persist ? "persist" : "once",
24
+ taskId,
25
+ evalSchedules: noEvalSchedules ? false : undefined,
26
+ });
@@ -2,8 +2,10 @@ import Anthropic from "@anthropic-ai/sdk";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
3
  import { withDb } from "../db/connection.ts";
4
4
  import {
5
+ claimSchedule,
5
6
  listSchedules,
6
7
  markScheduleRun,
8
+ releaseSchedule,
7
9
  type Schedule,
8
10
  } from "../db/schedules.ts";
9
11
  import { createTask } from "../db/tasks.ts";
@@ -107,16 +109,35 @@ Is this schedule due to run? If yes, what tasks should be created?`;
107
109
  export async function processSchedules(
108
110
  dbPath: string,
109
111
  config: Required<BotholomewConfig>,
112
+ workerId: string,
110
113
  ): Promise<void> {
111
114
  const schedules = await withDb(dbPath, (conn) =>
112
115
  listSchedules(conn, { enabled: true }),
113
116
  );
114
117
  if (schedules.length === 0) return;
115
118
 
119
+ logger.phase("evaluating-schedules", `${schedules.length} enabled`);
120
+
116
121
  for (const schedule of schedules) {
122
+ // Only one worker evaluates a schedule per window. claimSchedule is an
123
+ // atomic UPDATE ... RETURNING guarded by both a claim-stale window and
124
+ // a minimum-interval-since-last-run window; if it returns null, another
125
+ // worker already holds the claim or the schedule ran too recently.
126
+ const claimed = await withDb(dbPath, (conn) =>
127
+ claimSchedule(conn, schedule.id, workerId, {
128
+ staleAfterSeconds: config.schedule_claim_stale_seconds,
129
+ minIntervalSeconds: config.schedule_min_interval_seconds,
130
+ }),
131
+ );
132
+ if (!claimed) {
133
+ logger.debug(
134
+ `Schedule "${schedule.name}" skipped: claimed by another worker or too recent`,
135
+ );
136
+ continue;
137
+ }
138
+
117
139
  try {
118
- // LLM evaluation does no DB work — no connection held here.
119
- const evaluation = await evaluateSchedule(config, schedule);
140
+ const evaluation = await evaluateSchedule(config, claimed);
120
141
 
121
142
  if (!evaluation.isDue) {
122
143
  logger.debug(
@@ -148,6 +169,13 @@ export async function processSchedules(
148
169
  );
149
170
  } catch (err) {
150
171
  logger.error(`Error processing schedule "${schedule.name}": ${err}`);
172
+ } finally {
173
+ // Release the claim so other workers (or the next tick) can re-evaluate
174
+ // once the min-interval window has elapsed. markScheduleRun above
175
+ // updates last_run_at, which is the actual cooldown.
176
+ await withDb(dbPath, (conn) =>
177
+ releaseSchedule(conn, schedule.id, workerId),
178
+ );
151
179
  }
152
180
  }
153
181
  }
@@ -0,0 +1,48 @@
1
+ import { join } from "node:path";
2
+ import { getBotholomewDir, getLogPath } from "../constants.ts";
3
+ import { logger } from "../utils/logger.ts";
4
+ import type { WorkerMode } from "./index.ts";
5
+
6
+ export interface SpawnWorkerOptions {
7
+ mode?: WorkerMode;
8
+ taskId?: string;
9
+ }
10
+
11
+ /**
12
+ * Spawn a worker as a detached background process. Unlike the old daemon
13
+ * model, multiple workers per project are allowed and expected — this just
14
+ * launches a new one.
15
+ */
16
+ export async function spawnWorker(
17
+ projectDir: string,
18
+ options: SpawnWorkerOptions = {},
19
+ ): Promise<{ pid: number }> {
20
+ const dotDir = getBotholomewDir(projectDir);
21
+ const dirExists = await Bun.file(join(dotDir, "config.json")).exists();
22
+ if (!dirExists) {
23
+ logger.error("Project not initialized. Run 'botholomew init' first.");
24
+ process.exit(1);
25
+ }
26
+
27
+ const logPath = getLogPath(projectDir);
28
+ const logFile = Bun.file(logPath);
29
+
30
+ const workerScript = new URL("./run.ts", import.meta.url).pathname;
31
+ const args = ["bun", "run", workerScript, projectDir];
32
+ if (options.mode === "persist") args.push("--persist");
33
+ if (options.taskId) args.push(`--task-id=${options.taskId}`);
34
+
35
+ const proc = Bun.spawn(args, {
36
+ stdio: ["ignore", logFile, logFile],
37
+ env: { ...process.env },
38
+ });
39
+ proc.unref();
40
+
41
+ const mode = options.mode ?? "once";
42
+ logger.success(
43
+ `Worker spawned in background (PID ${proc.pid}, mode=${mode}${options.taskId ? `, task=${options.taskId}` : ""})`,
44
+ );
45
+ logger.dim(` Log: ${logPath}`);
46
+
47
+ return { pid: proc.pid ?? 0 };
48
+ }
@@ -1,28 +1,48 @@
1
1
  import type { McpxClient } from "@evantahler/mcpx";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
3
  import { withDb } from "../db/connection.ts";
4
- import { listSchedules } from "../db/schedules.ts";
5
4
  import {
6
5
  claimNextTask,
6
+ claimSpecificTask,
7
7
  resetStaleTasks,
8
+ type Task,
8
9
  updateTaskStatus,
9
10
  } from "../db/tasks.ts";
10
11
  import { createThread, endThread, logInteraction } from "../db/threads.ts";
11
12
  import { logger } from "../utils/logger.ts";
12
13
  import { generateThreadTitle } from "../utils/title.ts";
13
- import type { DaemonStreamCallbacks } from "./llm.ts";
14
+ import type { WorkerStreamCallbacks } from "./llm.ts";
14
15
  import { runAgentLoop } from "./llm.ts";
15
16
  import { buildSystemPrompt } from "./prompt.ts";
16
17
  import { processSchedules } from "./schedules.ts";
17
18
 
18
- export async function tick(
19
- projectDir: string,
20
- dbPath: string,
21
- config: Required<BotholomewConfig>,
22
- mcpxClient?: McpxClient | null,
23
- callbacks?: DaemonStreamCallbacks,
24
- tickNum = 1,
25
- ): Promise<boolean> {
19
+ export interface TickOptions {
20
+ projectDir: string;
21
+ dbPath: string;
22
+ config: Required<BotholomewConfig>;
23
+ workerId: string;
24
+ mcpxClient?: McpxClient | null;
25
+ callbacks?: WorkerStreamCallbacks;
26
+ tickNum?: number;
27
+ evalSchedules?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Run one unit of work for a worker: optionally evaluate schedules, claim
32
+ * the next eligible task, and process it. Returns true if work was done.
33
+ */
34
+ export async function tick(opts: TickOptions): Promise<boolean> {
35
+ const {
36
+ projectDir,
37
+ dbPath,
38
+ config,
39
+ workerId,
40
+ mcpxClient,
41
+ callbacks,
42
+ tickNum = 1,
43
+ evalSchedules = true,
44
+ } = opts;
45
+
26
46
  const tickStart = Date.now();
27
47
  logger.phase("tick-start", `#${tickNum}`);
28
48
 
@@ -36,16 +56,9 @@ export async function tick(
36
56
  );
37
57
  }
38
58
 
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
- );
45
- if (enabledSchedules.length > 0) {
46
- logger.phase("evaluating-schedules", `${enabledSchedules.length} enabled`);
59
+ if (evalSchedules) {
47
60
  try {
48
- await processSchedules(dbPath, config);
61
+ await processSchedules(dbPath, config, workerId);
49
62
  } catch (err) {
50
63
  logger.error(`Schedule processing failed: ${err}`);
51
64
  }
@@ -53,7 +66,7 @@ export async function tick(
53
66
 
54
67
  // Claim a task
55
68
  logger.phase("claiming-task");
56
- const task = await withDb(dbPath, (conn) => claimNextTask(conn));
69
+ const task = await withDb(dbPath, (conn) => claimNextTask(conn, workerId));
57
70
  if (!task) {
58
71
  logger.info("No task claimed (queue empty or all blocked)");
59
72
  const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
@@ -61,23 +74,77 @@ export async function tick(
61
74
  return false;
62
75
  }
63
76
 
77
+ await runClaimedTask({
78
+ projectDir,
79
+ dbPath,
80
+ config,
81
+ mcpxClient,
82
+ callbacks,
83
+ task,
84
+ });
85
+
86
+ const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
87
+ logger.phase("tick-end", `#${tickNum} ${elapsed}s didWork=true`);
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Claim and run a single, explicitly-named task. Returns true if the task
93
+ * was claimed and processed, false if it wasn't eligible (already claimed,
94
+ * not pending, or doesn't exist).
95
+ */
96
+ export async function runSpecificTask(opts: {
97
+ projectDir: string;
98
+ dbPath: string;
99
+ config: Required<BotholomewConfig>;
100
+ workerId: string;
101
+ taskId: string;
102
+ mcpxClient?: McpxClient | null;
103
+ callbacks?: WorkerStreamCallbacks;
104
+ }): Promise<boolean> {
105
+ const task = await withDb(opts.dbPath, (conn) =>
106
+ claimSpecificTask(conn, opts.taskId, opts.workerId),
107
+ );
108
+ if (!task) {
109
+ logger.warn(
110
+ `Task ${opts.taskId} is not available (already claimed, not pending, or missing)`,
111
+ );
112
+ return false;
113
+ }
114
+ await runClaimedTask({
115
+ projectDir: opts.projectDir,
116
+ dbPath: opts.dbPath,
117
+ config: opts.config,
118
+ mcpxClient: opts.mcpxClient,
119
+ callbacks: opts.callbacks,
120
+ task,
121
+ });
122
+ return true;
123
+ }
124
+
125
+ async function runClaimedTask(opts: {
126
+ projectDir: string;
127
+ dbPath: string;
128
+ config: Required<BotholomewConfig>;
129
+ mcpxClient?: McpxClient | null;
130
+ callbacks?: WorkerStreamCallbacks;
131
+ task: Task;
132
+ }): Promise<void> {
133
+ const { projectDir, dbPath, config, mcpxClient, callbacks, task } = opts;
134
+
64
135
  logger.info(`Claimed task: ${task.name} (${task.id})`);
65
136
  callbacks?.onTaskStart(task);
66
137
 
67
- // Create a thread for this tick
68
138
  const threadId = await withDb(dbPath, (conn) =>
69
- createThread(conn, "daemon_tick", task.id, `Working: ${task.name}`),
139
+ createThread(conn, "worker_tick", task.id, `Working: ${task.name}`),
70
140
  );
71
141
 
72
- // Build system prompt (includes task-relevant context from embeddings)
73
142
  const systemPrompt = await buildSystemPrompt(
74
143
  projectDir,
75
144
  task,
76
145
  dbPath,
77
146
  config,
78
- {
79
- hasMcpTools: mcpxClient != null,
80
- },
147
+ { hasMcpTools: mcpxClient != null },
81
148
  );
82
149
 
83
150
  try {
@@ -92,8 +159,6 @@ export async function tick(
92
159
  callbacks,
93
160
  });
94
161
 
95
- // Update task status and store output. Only completed tasks have an
96
- // `output`; waiting/failed tasks put their reason in `waiting_reason`.
97
162
  const isComplete = result.status === "complete";
98
163
  await withDb(dbPath, (conn) =>
99
164
  updateTaskStatus(
@@ -105,7 +170,6 @@ export async function tick(
105
170
  ),
106
171
  );
107
172
 
108
- // Log the status change
109
173
  await withDb(dbPath, (conn) =>
110
174
  logInteraction(conn, threadId, {
111
175
  role: "system",
@@ -116,7 +180,6 @@ export async function tick(
116
180
 
117
181
  logger.info(`Task ${task.id} -> ${result.status}`);
118
182
 
119
- // Generate a descriptive title for the thread (fire-and-forget)
120
183
  void generateThreadTitle(
121
184
  config,
122
185
  dbPath,
@@ -140,9 +203,4 @@ export async function tick(
140
203
  } finally {
141
204
  await withDb(dbPath, (conn) => endThread(conn, threadId));
142
205
  }
143
-
144
- const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
145
- logger.phase("tick-end", `#${tickNum} ${elapsed}s didWork=true`);
146
-
147
- return true;
148
206
  }