botholomew 0.7.13 → 0.8.1
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 +16 -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
|
@@ -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
|
|
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?:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
19
|
-
projectDir: string
|
|
20
|
-
dbPath: string
|
|
21
|
-
config: Required<BotholomewConfig
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
}
|