botholomew 0.12.5 → 0.14.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 +91 -68
- package/package.json +2 -2
- package/src/chat/agent.ts +59 -86
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +178 -926
- package/src/commands/db.ts +9 -13
- package/src/commands/init.ts +4 -1
- package/src/commands/nuke.ts +57 -90
- package/src/commands/schedule.ts +103 -124
- package/src/commands/skill.ts +2 -2
- package/src/commands/task.ts +86 -95
- package/src/commands/thread.ts +107 -112
- package/src/commands/worker.ts +88 -88
- package/src/constants.ts +93 -16
- package/src/context/capabilities.ts +10 -10
- package/src/context/fetcher.ts +9 -10
- package/src/context/reindex.ts +189 -0
- package/src/context/store.ts +803 -0
- package/src/db/doctor.ts +1 -8
- package/src/db/embeddings.ts +227 -175
- package/src/db/sql/19-disk_backed_index.sql +36 -0
- package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
- package/src/fs/atomic.ts +217 -0
- package/src/fs/compat.ts +86 -0
- package/src/fs/sandbox.ts +293 -0
- package/src/init/index.ts +69 -52
- package/src/init/templates.ts +1 -1
- package/src/mcpx/client.ts +1 -1
- package/src/schedules/schema.ts +19 -0
- package/src/schedules/store.ts +296 -0
- package/src/skills/commands.ts +1 -3
- package/src/tasks/schema.ts +47 -0
- package/src/tasks/store.ts +486 -0
- package/src/threads/store.ts +559 -0
- package/src/tools/capabilities/refresh.ts +42 -21
- package/src/tools/context/pipe.ts +15 -71
- package/src/tools/context/update-beliefs.ts +3 -3
- package/src/tools/context/update-goals.ts +3 -3
- package/src/tools/dir/create.ts +26 -23
- package/src/tools/dir/size.ts +46 -17
- package/src/tools/dir/tree.ts +74 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +53 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +27 -85
- package/src/tools/file/move.ts +39 -24
- package/src/tools/file/read.ts +32 -80
- package/src/tools/file/write.ts +14 -91
- package/src/tools/registry.ts +8 -7
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/list.ts +7 -3
- package/src/tools/search/fuse.ts +12 -33
- package/src/tools/search/index.ts +36 -43
- package/src/tools/search/regexp.ts +29 -17
- package/src/tools/search/semantic.ts +137 -51
- package/src/tools/skill/delete.ts +1 -1
- package/src/tools/skill/list.ts +1 -1
- package/src/tools/skill/write.ts +1 -1
- package/src/tools/task/create.ts +41 -16
- package/src/tools/task/delete.ts +3 -3
- package/src/tools/task/list.ts +6 -3
- package/src/tools/task/update.ts +31 -9
- package/src/tools/task/view.ts +6 -6
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/thread/search.ts +208 -0
- package/src/tools/thread/view.ts +50 -5
- package/src/tools/tool.ts +5 -0
- package/src/tools/util/sleep.ts +77 -0
- package/src/tools/worker/spawn.ts +28 -14
- package/src/tui/App.tsx +12 -19
- package/src/tui/components/ContextPanel.tsx +83 -316
- package/src/tui/components/SchedulePanel.tsx +34 -48
- package/src/tui/components/SleepProgress.tsx +70 -0
- package/src/tui/components/StatusBar.tsx +15 -15
- package/src/tui/components/TaskPanel.tsx +34 -38
- package/src/tui/components/ThreadPanel.tsx +29 -38
- package/src/tui/components/ToolCall.tsx +10 -0
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- package/src/utils/title.ts +5 -7
- package/src/utils/v7-date.ts +47 -0
- package/src/worker/heartbeat.ts +46 -24
- package/src/worker/index.ts +13 -15
- package/src/worker/llm.ts +30 -37
- package/src/worker/prompt.ts +19 -41
- package/src/worker/schedules.ts +48 -69
- package/src/worker/spawn.ts +11 -11
- package/src/worker/tick.ts +39 -43
- package/src/workers/store.ts +247 -0
- package/src/commands/tools.ts +0 -367
- package/src/context/describer.ts +0 -140
- package/src/context/drives.ts +0 -110
- package/src/context/ingest.ts +0 -162
- package/src/context/refresh.ts +0 -183
- package/src/db/context.ts +0 -637
- package/src/db/daemon-state.ts +0 -6
- package/src/db/reembed.ts +0 -113
- package/src/db/schedules.ts +0 -213
- package/src/db/tasks.ts +0 -347
- package/src/db/threads.ts +0 -276
- package/src/db/workers.ts +0 -212
- package/src/tools/context/list-drives.ts +0 -36
- package/src/tools/context/refresh.ts +0 -165
- package/src/tools/context/search.ts +0 -54
package/src/worker/schedules.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { Schedule } from "../schedules/schema.ts";
|
|
4
4
|
import {
|
|
5
|
-
claimSchedule,
|
|
6
5
|
listSchedules,
|
|
7
6
|
markScheduleRun,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from "../
|
|
11
|
-
import { createTask } from "../db/tasks.ts";
|
|
7
|
+
withScheduleLock,
|
|
8
|
+
} from "../schedules/store.ts";
|
|
9
|
+
import { createTask } from "../tasks/store.ts";
|
|
12
10
|
import { logger } from "../utils/logger.ts";
|
|
13
11
|
|
|
14
12
|
interface ScheduleTaskDef {
|
|
@@ -55,7 +53,7 @@ The "depends_on" array contains indices of other tasks in the array that must co
|
|
|
55
53
|
const userMessage = `Schedule: "${schedule.name}"
|
|
56
54
|
Description: ${schedule.description || "(none)"}
|
|
57
55
|
Frequency: ${schedule.frequency}
|
|
58
|
-
Last run: ${schedule.last_run_at
|
|
56
|
+
Last run: ${schedule.last_run_at ?? "never"}
|
|
59
57
|
Current time: ${new Date().toISOString()}
|
|
60
58
|
|
|
61
59
|
Is this schedule due to run? If yes, what tasks should be created?`;
|
|
@@ -73,7 +71,6 @@ Is this schedule due to run? If yes, what tasks should be created?`;
|
|
|
73
71
|
.map((b) => b.text)
|
|
74
72
|
.join("");
|
|
75
73
|
|
|
76
|
-
// Strip markdown code fences the LLM may wrap around JSON
|
|
77
74
|
text = text
|
|
78
75
|
.replace(/^```(?:json)?\s*\n?/, "")
|
|
79
76
|
.replace(/\n?```\s*$/, "")
|
|
@@ -107,75 +104,57 @@ Is this schedule due to run? If yes, what tasks should be created?`;
|
|
|
107
104
|
}
|
|
108
105
|
|
|
109
106
|
export async function processSchedules(
|
|
110
|
-
|
|
107
|
+
projectDir: string,
|
|
111
108
|
config: Required<BotholomewConfig>,
|
|
112
109
|
workerId: string,
|
|
113
110
|
): Promise<void> {
|
|
114
|
-
const schedules = await
|
|
115
|
-
listSchedules(conn, { enabled: true }),
|
|
116
|
-
);
|
|
111
|
+
const schedules = await listSchedules(projectDir, { enabled: true });
|
|
117
112
|
if (schedules.length === 0) return;
|
|
118
113
|
|
|
119
114
|
logger.phase("evaluating-schedules", `${schedules.length} enabled`);
|
|
120
115
|
|
|
121
116
|
for (const schedule of schedules) {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
// Lockfile + min-interval guard prevent two workers (or two ticks) from
|
|
118
|
+
// evaluating the same schedule too closely.
|
|
119
|
+
await withScheduleLock(
|
|
120
|
+
projectDir,
|
|
121
|
+
schedule.id,
|
|
122
|
+
workerId,
|
|
123
|
+
{ minIntervalSeconds: config.schedule_min_interval_seconds },
|
|
124
|
+
async (claimed) => {
|
|
125
|
+
try {
|
|
126
|
+
const evaluation = await evaluateSchedule(config, claimed);
|
|
127
|
+
|
|
128
|
+
if (!evaluation.isDue) {
|
|
129
|
+
logger.debug(
|
|
130
|
+
`Schedule "${claimed.name}" not due: ${evaluation.reasoning}`,
|
|
131
|
+
);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const createdIds: string[] = [];
|
|
136
|
+
for (const taskDef of evaluation.tasksToCreate) {
|
|
137
|
+
const blockedBy = (taskDef.depends_on ?? [])
|
|
138
|
+
.map((i: number) => createdIds[i])
|
|
139
|
+
.filter(Boolean) as string[];
|
|
140
|
+
|
|
141
|
+
const task = await createTask(projectDir, {
|
|
142
|
+
name: taskDef.name,
|
|
143
|
+
description: taskDef.description,
|
|
144
|
+
priority: taskDef.priority,
|
|
145
|
+
blocked_by: blockedBy,
|
|
146
|
+
});
|
|
147
|
+
createdIds.push(task.id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await markScheduleRun(projectDir, claimed.id);
|
|
151
|
+
logger.info(
|
|
152
|
+
`Schedule "${claimed.name}" fired, created ${createdIds.length} task(s)`,
|
|
153
|
+
);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logger.error(`Error processing schedule "${claimed.name}": ${err}`);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
131
158
|
);
|
|
132
|
-
if (!claimed) {
|
|
133
|
-
logger.debug(
|
|
134
|
-
`Schedule "${schedule.name}" skipped: claimed by another worker or too recent`,
|
|
135
|
-
);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
const evaluation = await evaluateSchedule(config, claimed);
|
|
141
|
-
|
|
142
|
-
if (!evaluation.isDue) {
|
|
143
|
-
logger.debug(
|
|
144
|
-
`Schedule "${schedule.name}" not due: ${evaluation.reasoning}`,
|
|
145
|
-
);
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const createdIds: string[] = [];
|
|
150
|
-
for (const taskDef of evaluation.tasksToCreate) {
|
|
151
|
-
const blockedBy = (taskDef.depends_on ?? [])
|
|
152
|
-
.map((i: number) => createdIds[i])
|
|
153
|
-
.filter(Boolean) as string[];
|
|
154
|
-
|
|
155
|
-
const task = await withDb(dbPath, (conn) =>
|
|
156
|
-
createTask(conn, {
|
|
157
|
-
name: taskDef.name,
|
|
158
|
-
description: taskDef.description,
|
|
159
|
-
priority: taskDef.priority,
|
|
160
|
-
blocked_by: blockedBy,
|
|
161
|
-
}),
|
|
162
|
-
);
|
|
163
|
-
createdIds.push(task.id);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
await withDb(dbPath, (conn) => markScheduleRun(conn, schedule.id));
|
|
167
|
-
logger.info(
|
|
168
|
-
`Schedule "${schedule.name}" fired, created ${createdIds.length} task(s)`,
|
|
169
|
-
);
|
|
170
|
-
} catch (err) {
|
|
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
|
-
);
|
|
179
|
-
}
|
|
180
159
|
}
|
|
181
160
|
}
|
package/src/worker/spawn.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
getBotholomewDir,
|
|
5
|
-
getWorkerLogPath,
|
|
6
|
-
getWorkerLogsDir,
|
|
7
|
-
} from "../constants.ts";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getConfigPath, getWorkerLogPath } from "../constants.ts";
|
|
8
4
|
import { uuidv7 } from "../db/uuid.ts";
|
|
9
5
|
import { logger } from "../utils/logger.ts";
|
|
6
|
+
import { dateForId } from "../utils/v7-date.ts";
|
|
10
7
|
import type { WorkerMode } from "./index.ts";
|
|
11
8
|
|
|
12
9
|
export interface SpawnWorkerOptions {
|
|
@@ -26,16 +23,19 @@ export async function spawnWorker(
|
|
|
26
23
|
projectDir: string,
|
|
27
24
|
options: SpawnWorkerOptions = {},
|
|
28
25
|
): Promise<{ pid: number; workerId: string; logPath: string }> {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
26
|
+
const configPath = getConfigPath(projectDir);
|
|
27
|
+
const initialized = await Bun.file(configPath).exists();
|
|
28
|
+
if (!initialized) {
|
|
32
29
|
logger.error("Project not initialized. Run 'botholomew init' first.");
|
|
33
30
|
process.exit(1);
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
const workerId = uuidv7();
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// Per-worker log path is derived from the worker id's UTC date so the
|
|
35
|
+
// logs/ tree stays browsable as workers accumulate. Mirrors the threads
|
|
36
|
+
// layout under <projectDir>/threads/.
|
|
37
|
+
const logPath = getWorkerLogPath(projectDir, workerId, dateForId(workerId));
|
|
38
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
39
39
|
const logFile = Bun.file(logPath);
|
|
40
40
|
|
|
41
41
|
const workerScript = new URL("./run.ts", import.meta.url).pathname;
|
package/src/worker/tick.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { Task } from "../tasks/schema.ts";
|
|
4
4
|
import {
|
|
5
5
|
claimNextTask,
|
|
6
6
|
claimSpecificTask,
|
|
7
|
+
releaseTaskLock,
|
|
7
8
|
resetStaleTasks,
|
|
8
|
-
type Task,
|
|
9
9
|
updateTaskStatus,
|
|
10
|
-
} from "../
|
|
11
|
-
import { createThread, endThread, logInteraction } from "../
|
|
10
|
+
} from "../tasks/store.ts";
|
|
11
|
+
import { createThread, endThread, logInteraction } from "../threads/store.ts";
|
|
12
12
|
import { logger } from "../utils/logger.ts";
|
|
13
13
|
import { generateThreadTitle } from "../utils/title.ts";
|
|
14
14
|
import type { WorkerStreamCallbacks } from "./llm.ts";
|
|
@@ -46,9 +46,9 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
46
46
|
const tickStart = Date.now();
|
|
47
47
|
logger.phase("tick-start", `#${tickNum}`);
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const resetIds = await resetStaleTasks(
|
|
50
|
+
projectDir,
|
|
51
|
+
config.max_tick_duration_seconds * 3,
|
|
52
52
|
);
|
|
53
53
|
if (resetIds.length > 0) {
|
|
54
54
|
logger.warn(
|
|
@@ -58,15 +58,14 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
58
58
|
|
|
59
59
|
if (evalSchedules) {
|
|
60
60
|
try {
|
|
61
|
-
await processSchedules(
|
|
61
|
+
await processSchedules(projectDir, config, workerId);
|
|
62
62
|
} catch (err) {
|
|
63
63
|
logger.error(`Schedule processing failed: ${err}`);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Claim a task
|
|
68
67
|
logger.phase("claiming-task");
|
|
69
|
-
const task = await
|
|
68
|
+
const task = await claimNextTask(projectDir, workerId);
|
|
70
69
|
if (!task) {
|
|
71
70
|
logger.info("No task claimed (queue empty or all blocked)");
|
|
72
71
|
const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
|
|
@@ -90,8 +89,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
90
89
|
|
|
91
90
|
/**
|
|
92
91
|
* Claim and run a single, explicitly-named task. Returns true if the task
|
|
93
|
-
* was claimed and processed, false if it wasn't eligible
|
|
94
|
-
* not pending, or doesn't exist).
|
|
92
|
+
* was claimed and processed, false if it wasn't eligible.
|
|
95
93
|
*/
|
|
96
94
|
export async function runSpecificTask(opts: {
|
|
97
95
|
projectDir: string;
|
|
@@ -102,8 +100,10 @@ export async function runSpecificTask(opts: {
|
|
|
102
100
|
mcpxClient?: McpxClient | null;
|
|
103
101
|
callbacks?: WorkerStreamCallbacks;
|
|
104
102
|
}): Promise<boolean> {
|
|
105
|
-
const task = await
|
|
106
|
-
|
|
103
|
+
const task = await claimSpecificTask(
|
|
104
|
+
opts.projectDir,
|
|
105
|
+
opts.taskId,
|
|
106
|
+
opts.workerId,
|
|
107
107
|
);
|
|
108
108
|
if (!task) {
|
|
109
109
|
logger.warn(
|
|
@@ -138,8 +138,11 @@ async function runClaimedTask(opts: {
|
|
|
138
138
|
}
|
|
139
139
|
callbacks?.onTaskStart(task);
|
|
140
140
|
|
|
141
|
-
const threadId = await
|
|
142
|
-
|
|
141
|
+
const threadId = await createThread(
|
|
142
|
+
projectDir,
|
|
143
|
+
"worker_tick",
|
|
144
|
+
task.id,
|
|
145
|
+
`Working: ${task.name}`,
|
|
143
146
|
);
|
|
144
147
|
|
|
145
148
|
const systemPrompt = await buildSystemPrompt(
|
|
@@ -163,47 +166,40 @@ async function runClaimedTask(opts: {
|
|
|
163
166
|
});
|
|
164
167
|
|
|
165
168
|
const isComplete = result.status === "complete";
|
|
166
|
-
await
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
isComplete ? result.reason : null,
|
|
173
|
-
),
|
|
169
|
+
await updateTaskStatus(
|
|
170
|
+
projectDir,
|
|
171
|
+
task.id,
|
|
172
|
+
result.status,
|
|
173
|
+
isComplete ? null : result.reason,
|
|
174
|
+
isComplete ? result.reason : null,
|
|
174
175
|
);
|
|
175
176
|
|
|
176
|
-
await
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
177
|
+
await logInteraction(projectDir, threadId, {
|
|
178
|
+
role: "system",
|
|
179
|
+
kind: "status_change",
|
|
180
|
+
content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
|
|
181
|
+
});
|
|
183
182
|
|
|
184
183
|
logger.info(`Task ${task.id} -> ${result.status}`);
|
|
185
184
|
|
|
186
185
|
void generateThreadTitle(
|
|
187
186
|
config,
|
|
188
|
-
|
|
187
|
+
projectDir,
|
|
189
188
|
threadId,
|
|
190
189
|
`Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
|
|
191
190
|
);
|
|
192
191
|
} catch (err) {
|
|
193
|
-
await
|
|
194
|
-
updateTaskStatus(conn, task.id, "failed", String(err), null),
|
|
195
|
-
);
|
|
192
|
+
await updateTaskStatus(projectDir, task.id, "failed", String(err), null);
|
|
196
193
|
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}),
|
|
203
|
-
);
|
|
194
|
+
await logInteraction(projectDir, threadId, {
|
|
195
|
+
role: "system",
|
|
196
|
+
kind: "status_change",
|
|
197
|
+
content: `Task ${task.id} failed: ${err}`,
|
|
198
|
+
});
|
|
204
199
|
|
|
205
200
|
logger.error(`Task ${task.id} failed: ${err}`);
|
|
206
201
|
} finally {
|
|
207
|
-
await
|
|
202
|
+
await releaseTaskLock(projectDir, task.id);
|
|
203
|
+
await endThread(projectDir, threadId);
|
|
208
204
|
}
|
|
209
205
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getWorkersDir } from "../constants.ts";
|
|
4
|
+
import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
|
|
5
|
+
|
|
6
|
+
export const WORKER_MODES = ["persist", "once"] as const;
|
|
7
|
+
export const WORKER_STATUSES = ["running", "stopped", "dead"] as const;
|
|
8
|
+
|
|
9
|
+
export type WorkerMode = (typeof WORKER_MODES)[number];
|
|
10
|
+
export type WorkerStatus = (typeof WORKER_STATUSES)[number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Persistent worker record. One JSON file per worker at
|
|
14
|
+
* `<projectDir>/workers/<id>.json`. Heartbeats rewrite the file
|
|
15
|
+
* atomically (write-to-tmp + rename); the file's existence is the pidfile,
|
|
16
|
+
* `last_heartbeat_at` inside is the liveness signal.
|
|
17
|
+
*/
|
|
18
|
+
export interface Worker {
|
|
19
|
+
id: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
hostname: string;
|
|
22
|
+
mode: WorkerMode;
|
|
23
|
+
task_id: string | null;
|
|
24
|
+
status: WorkerStatus;
|
|
25
|
+
started_at: string;
|
|
26
|
+
last_heartbeat_at: string;
|
|
27
|
+
stopped_at: string | null;
|
|
28
|
+
log_path: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function workerFilePath(projectDir: string, id: string): string {
|
|
32
|
+
return join(getWorkersDir(projectDir), `${id}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readWorker(
|
|
36
|
+
projectDir: string,
|
|
37
|
+
id: string,
|
|
38
|
+
): Promise<Worker | null> {
|
|
39
|
+
const file = await readWithMtime(workerFilePath(projectDir, id));
|
|
40
|
+
if (!file) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(file.content) as Worker;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function writeWorker(projectDir: string, worker: Worker): Promise<void> {
|
|
49
|
+
await atomicWrite(
|
|
50
|
+
workerFilePath(projectDir, worker.id),
|
|
51
|
+
`${JSON.stringify(worker, null, 2)}\n`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function registerWorker(
|
|
56
|
+
projectDir: string,
|
|
57
|
+
params: {
|
|
58
|
+
id: string;
|
|
59
|
+
pid: number;
|
|
60
|
+
hostname: string;
|
|
61
|
+
mode: WorkerMode;
|
|
62
|
+
taskId?: string | null;
|
|
63
|
+
logPath?: string | null;
|
|
64
|
+
},
|
|
65
|
+
): Promise<Worker> {
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const worker: Worker = {
|
|
68
|
+
id: params.id,
|
|
69
|
+
pid: params.pid,
|
|
70
|
+
hostname: params.hostname,
|
|
71
|
+
mode: params.mode,
|
|
72
|
+
task_id: params.taskId ?? null,
|
|
73
|
+
status: "running",
|
|
74
|
+
started_at: now,
|
|
75
|
+
last_heartbeat_at: now,
|
|
76
|
+
stopped_at: null,
|
|
77
|
+
log_path: params.logPath ?? null,
|
|
78
|
+
};
|
|
79
|
+
await writeWorker(projectDir, worker);
|
|
80
|
+
return worker;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Update last_heartbeat_at on a running worker. No-op for stopped/dead
|
|
85
|
+
* workers so a misbehaving heartbeat doesn't resurrect a worker the reaper
|
|
86
|
+
* has retired.
|
|
87
|
+
*/
|
|
88
|
+
export async function heartbeat(projectDir: string, id: string): Promise<void> {
|
|
89
|
+
const worker = await readWorker(projectDir, id);
|
|
90
|
+
if (!worker || worker.status !== "running") return;
|
|
91
|
+
worker.last_heartbeat_at = new Date().toISOString();
|
|
92
|
+
await writeWorker(projectDir, worker);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function markWorkerStopped(
|
|
96
|
+
projectDir: string,
|
|
97
|
+
id: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const worker = await readWorker(projectDir, id);
|
|
100
|
+
if (!worker || worker.status !== "running") return;
|
|
101
|
+
worker.status = "stopped";
|
|
102
|
+
worker.stopped_at = new Date().toISOString();
|
|
103
|
+
await writeWorker(projectDir, worker);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function markWorkerDead(
|
|
107
|
+
projectDir: string,
|
|
108
|
+
id: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const worker = await readWorker(projectDir, id);
|
|
111
|
+
if (!worker) return;
|
|
112
|
+
if (worker.status === "stopped") return; // don't overwrite a clean stop
|
|
113
|
+
worker.status = "dead";
|
|
114
|
+
worker.stopped_at = new Date().toISOString();
|
|
115
|
+
await writeWorker(projectDir, worker);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Walk `workers/`, mark any running worker as dead if its
|
|
120
|
+
* `last_heartbeat_at` is older than `staleAfterSeconds`. Tasks/schedules
|
|
121
|
+
* they held are reclaimed via the lockfile reapers driven by
|
|
122
|
+
* `isWorkerRunning`. Returns the ids that were just marked dead.
|
|
123
|
+
*/
|
|
124
|
+
export async function reapDeadWorkers(
|
|
125
|
+
projectDir: string,
|
|
126
|
+
staleAfterSeconds: number,
|
|
127
|
+
): Promise<string[]> {
|
|
128
|
+
const ids = await listWorkerIds(projectDir);
|
|
129
|
+
const cutoff = Date.now() - staleAfterSeconds * 1000;
|
|
130
|
+
const reaped: string[] = [];
|
|
131
|
+
for (const id of ids) {
|
|
132
|
+
const w = await readWorker(projectDir, id);
|
|
133
|
+
if (!w || w.status !== "running") continue;
|
|
134
|
+
const heartbeatMs = Date.parse(w.last_heartbeat_at);
|
|
135
|
+
if (Number.isFinite(heartbeatMs) && heartbeatMs >= cutoff) continue;
|
|
136
|
+
w.status = "dead";
|
|
137
|
+
w.stopped_at = new Date().toISOString();
|
|
138
|
+
await writeWorker(projectDir, w);
|
|
139
|
+
reaped.push(id);
|
|
140
|
+
}
|
|
141
|
+
return reaped;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function isWorkerRunning(
|
|
145
|
+
projectDir: string,
|
|
146
|
+
id: string,
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
const w = await readWorker(projectDir, id);
|
|
149
|
+
return w?.status === "running";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Delete cleanly-stopped worker JSON files whose `stopped_at` is older
|
|
154
|
+
* than `afterSeconds`. Dead workers are preserved as forensic evidence.
|
|
155
|
+
*/
|
|
156
|
+
export async function pruneStoppedWorkers(
|
|
157
|
+
projectDir: string,
|
|
158
|
+
afterSeconds: number,
|
|
159
|
+
): Promise<string[]> {
|
|
160
|
+
const ids = await listWorkerIds(projectDir);
|
|
161
|
+
const cutoff = Date.now() - afterSeconds * 1000;
|
|
162
|
+
const pruned: string[] = [];
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const w = await readWorker(projectDir, id);
|
|
165
|
+
if (!w || w.status !== "stopped" || !w.stopped_at) continue;
|
|
166
|
+
const stoppedMs = Date.parse(w.stopped_at);
|
|
167
|
+
if (Number.isFinite(stoppedMs) && stoppedMs >= cutoff) continue;
|
|
168
|
+
try {
|
|
169
|
+
await unlink(workerFilePath(projectDir, id));
|
|
170
|
+
pruned.push(id);
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore — concurrent delete is fine
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return pruned;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function listWorkers(
|
|
179
|
+
projectDir: string,
|
|
180
|
+
filters?: {
|
|
181
|
+
status?: WorkerStatus;
|
|
182
|
+
limit?: number;
|
|
183
|
+
offset?: number;
|
|
184
|
+
},
|
|
185
|
+
): Promise<Worker[]> {
|
|
186
|
+
const ids = await listWorkerIds(projectDir);
|
|
187
|
+
const out: Worker[] = [];
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const w = await readWorker(projectDir, id);
|
|
190
|
+
if (!w) continue;
|
|
191
|
+
if (filters?.status && w.status !== filters.status) continue;
|
|
192
|
+
out.push(w);
|
|
193
|
+
}
|
|
194
|
+
out.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
|
195
|
+
const offset = filters?.offset ?? 0;
|
|
196
|
+
const limit = filters?.limit ?? out.length;
|
|
197
|
+
return out.slice(offset, offset + limit);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function getWorker(
|
|
201
|
+
projectDir: string,
|
|
202
|
+
id: string,
|
|
203
|
+
): Promise<Worker | null> {
|
|
204
|
+
return readWorker(projectDir, id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function deleteWorker(
|
|
208
|
+
projectDir: string,
|
|
209
|
+
id: string,
|
|
210
|
+
): Promise<boolean> {
|
|
211
|
+
try {
|
|
212
|
+
await unlink(workerFilePath(projectDir, id));
|
|
213
|
+
return true;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function listWorkerIds(projectDir: string): Promise<string[]> {
|
|
221
|
+
const dir = getWorkersDir(projectDir);
|
|
222
|
+
try {
|
|
223
|
+
const names = await readdir(dir);
|
|
224
|
+
return names
|
|
225
|
+
.filter((n) => n.endsWith(".json"))
|
|
226
|
+
.map((n) => n.slice(0, -".json".length));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* For tests / doctor: confirm a worker JSON file exists at the expected path.
|
|
235
|
+
*/
|
|
236
|
+
export async function workerFileExists(
|
|
237
|
+
projectDir: string,
|
|
238
|
+
id: string,
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
try {
|
|
241
|
+
await stat(workerFilePath(projectDir, id));
|
|
242
|
+
return true;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|