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.
Files changed (107) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +59 -86
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +178 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +803 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +293 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +74 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +53 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +27 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +8 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/tool.ts +5 -0
  71. package/src/tools/util/sleep.ts +77 -0
  72. package/src/tools/worker/spawn.ts +28 -14
  73. package/src/tui/App.tsx +12 -19
  74. package/src/tui/components/ContextPanel.tsx +83 -316
  75. package/src/tui/components/SchedulePanel.tsx +34 -48
  76. package/src/tui/components/SleepProgress.tsx +70 -0
  77. package/src/tui/components/StatusBar.tsx +15 -15
  78. package/src/tui/components/TaskPanel.tsx +34 -38
  79. package/src/tui/components/ThreadPanel.tsx +29 -38
  80. package/src/tui/components/ToolCall.tsx +10 -0
  81. package/src/tui/components/WorkerPanel.tsx +21 -19
  82. package/src/tui/markdown.ts +2 -8
  83. package/src/utils/title.ts +5 -7
  84. package/src/utils/v7-date.ts +47 -0
  85. package/src/worker/heartbeat.ts +46 -24
  86. package/src/worker/index.ts +13 -15
  87. package/src/worker/llm.ts +30 -37
  88. package/src/worker/prompt.ts +19 -41
  89. package/src/worker/schedules.ts +48 -69
  90. package/src/worker/spawn.ts +11 -11
  91. package/src/worker/tick.ts +39 -43
  92. package/src/workers/store.ts +247 -0
  93. package/src/commands/tools.ts +0 -367
  94. package/src/context/describer.ts +0 -140
  95. package/src/context/drives.ts +0 -110
  96. package/src/context/ingest.ts +0 -162
  97. package/src/context/refresh.ts +0 -183
  98. package/src/db/context.ts +0 -637
  99. package/src/db/daemon-state.ts +0 -6
  100. package/src/db/reembed.ts +0 -113
  101. package/src/db/schedules.ts +0 -213
  102. package/src/db/tasks.ts +0 -347
  103. package/src/db/threads.ts +0 -276
  104. package/src/db/workers.ts +0 -212
  105. package/src/tools/context/list-drives.ts +0 -36
  106. package/src/tools/context/refresh.ts +0 -165
  107. package/src/tools/context/search.ts +0 -54
@@ -1,14 +1,12 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import { withDb } from "../db/connection.ts";
3
+ import type { Schedule } from "../schedules/schema.ts";
4
4
  import {
5
- claimSchedule,
6
5
  listSchedules,
7
6
  markScheduleRun,
8
- releaseSchedule,
9
- type Schedule,
10
- } from "../db/schedules.ts";
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?.toISOString() ?? "never"}
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
- dbPath: string,
107
+ projectDir: string,
111
108
  config: Required<BotholomewConfig>,
112
109
  workerId: string,
113
110
  ): Promise<void> {
114
- const schedules = await withDb(dbPath, (conn) =>
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
- // 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
- }),
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
  }
@@ -1,12 +1,9 @@
1
1
  import { mkdir } from "node:fs/promises";
2
- import { join } from "node:path";
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 dotDir = getBotholomewDir(projectDir);
30
- const dirExists = await Bun.file(join(dotDir, "config.json")).exists();
31
- if (!dirExists) {
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
- await mkdir(getWorkerLogsDir(projectDir), { recursive: true });
38
- const logPath = getWorkerLogPath(projectDir, workerId);
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;
@@ -1,14 +1,14 @@
1
1
  import type { McpxClient } from "@evantahler/mcpx";
2
2
  import type { BotholomewConfig } from "../config/schemas.ts";
3
- import { withDb } from "../db/connection.ts";
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 "../db/tasks.ts";
11
- import { createThread, endThread, logInteraction } from "../db/threads.ts";
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
- // Reset stale tasks stuck in in_progress
50
- const resetIds = await withDb(dbPath, (conn) =>
51
- resetStaleTasks(conn, config.max_tick_duration_seconds * 3),
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(dbPath, config, workerId);
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 withDb(dbPath, (conn) => claimNextTask(conn, workerId));
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 (already claimed,
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 withDb(opts.dbPath, (conn) =>
106
- claimSpecificTask(conn, opts.taskId, opts.workerId),
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 withDb(dbPath, (conn) =>
142
- createThread(conn, "worker_tick", task.id, `Working: ${task.name}`),
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 withDb(dbPath, (conn) =>
167
- updateTaskStatus(
168
- conn,
169
- task.id,
170
- result.status,
171
- isComplete ? null : result.reason,
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 withDb(dbPath, (conn) =>
177
- logInteraction(conn, threadId, {
178
- role: "system",
179
- kind: "status_change",
180
- content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
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
- dbPath,
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 withDb(dbPath, (conn) =>
194
- updateTaskStatus(conn, task.id, "failed", String(err), null),
195
- );
192
+ await updateTaskStatus(projectDir, task.id, "failed", String(err), null);
196
193
 
197
- await withDb(dbPath, (conn) =>
198
- logInteraction(conn, threadId, {
199
- role: "system",
200
- kind: "status_change",
201
- content: `Task ${task.id} failed: ${err}`,
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 withDb(dbPath, (conn) => endThread(conn, threadId));
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
+ }