botholomew 0.15.5 → 0.16.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.
@@ -1,3 +1,4 @@
1
+ import { reapOrphanContextLocks } from "../context/locks.ts";
1
2
  import { reapOrphanScheduleLocks } from "../schedules/store.ts";
2
3
  import { reapOrphanLocks as reapOrphanTaskLocks } from "../tasks/store.ts";
3
4
  import { logger } from "../utils/logger.ts";
@@ -81,6 +82,25 @@ export function startReaper(
81
82
  logger.warn(`schedule lock reap failed: ${err}`);
82
83
  }
83
84
 
85
+ try {
86
+ // Context locks store either a `workerId` (worker holders) or a
87
+ // free-form id like `chat` / `pid:<n>` (chat sessions, CLI). Only
88
+ // expire holders that look like worker ids; conservatively treat
89
+ // any other holder as alive — we don't manage the chat session's
90
+ // lifecycle here.
91
+ const released = await reapOrphanContextLocks(projectDir, async (id) => {
92
+ if (id.startsWith("pid:") || id.startsWith("chat")) return true;
93
+ return await isAlive(id);
94
+ });
95
+ if (released.length > 0) {
96
+ logger.warn(
97
+ `released ${released.length} orphan context lock(s): ${released.join(", ")}`,
98
+ );
99
+ }
100
+ } catch (err) {
101
+ logger.warn(`context lock reap failed: ${err}`);
102
+ }
103
+
84
104
  try {
85
105
  const pruned = await pruneStoppedWorkers(
86
106
  projectDir,
package/src/worker/llm.ts CHANGED
@@ -53,6 +53,7 @@ export async function runAgentLoop(input: {
53
53
  dbPath: string;
54
54
  threadId: string;
55
55
  projectDir: string;
56
+ workerId?: string;
56
57
  mcpxClient?: McpxClient | null;
57
58
  callbacks?: WorkerStreamCallbacks;
58
59
  }): Promise<AgentLoopResult> {
@@ -63,6 +64,7 @@ export async function runAgentLoop(input: {
63
64
  dbPath,
64
65
  threadId,
65
66
  projectDir,
67
+ workerId,
66
68
  callbacks,
67
69
  } = input;
68
70
 
@@ -207,6 +209,7 @@ export async function runAgentLoop(input: {
207
209
  projectDir,
208
210
  config,
209
211
  mcpxClient: input.mcpxClient ?? null,
212
+ workerId,
210
213
  });
211
214
  const elapsed = Date.now() - start;
212
215
  callbacks?.onToolEnd(
@@ -265,6 +268,7 @@ interface ToolCallCtx {
265
268
  projectDir: string;
266
269
  config: Required<BotholomewConfig>;
267
270
  mcpxClient: McpxClient | null;
271
+ workerId?: string;
268
272
  }
269
273
 
270
274
  async function executeToolCall(
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import type { BotholomewConfig } from "../config/schemas.ts";
4
4
  import { getPromptsDir } from "../constants.ts";
5
5
  import type { Task } from "../tasks/schema.ts";
6
- import { parseContextFile } from "../utils/frontmatter.ts";
6
+ import { parsePromptFile } from "../utils/frontmatter.ts";
7
7
 
8
8
  const pkg = await Bun.file(
9
9
  new URL("../../package.json", import.meta.url),
@@ -36,37 +36,43 @@ export function extractKeywords(text: string): Set<string> {
36
36
  * Load persistent context files from prompts/ as a single formatted
37
37
  * string. Includes "always" files unconditionally and "contextual" files
38
38
  * whose content overlaps the provided taskKeywords.
39
+ *
40
+ * Validation is strict: any *.md file under prompts/ that fails the prompt
41
+ * frontmatter schema throws PromptValidationError naming the offending file.
42
+ * The only swallowed error is a missing prompts/ directory (e.g. fresh
43
+ * working dir before `botholomew init`).
39
44
  */
40
45
  export async function loadPersistentContext(
41
46
  projectDir: string,
42
47
  taskKeywords?: Set<string> | null,
43
48
  ): Promise<string> {
44
49
  const dir = getPromptsDir(projectDir);
45
- let out = "";
46
-
50
+ let files: string[];
47
51
  try {
48
- const files = await readdir(dir);
49
- const mdFiles = files.filter((f) => f.endsWith(".md"));
50
-
51
- for (const filename of mdFiles) {
52
- const filePath = join(dir, filename);
53
- const raw = await Bun.file(filePath).text();
54
- const { meta, content } = parseContextFile(raw);
55
-
56
- if (meta.loading === "always") {
57
- out += `## ${filename}\n${content}\n\n`;
58
- } else if (meta.loading === "contextual" && taskKeywords) {
59
- const contentLower = content.toLowerCase();
60
- const hasOverlap = [...taskKeywords].some((kw) =>
61
- contentLower.includes(kw),
62
- );
63
- if (hasOverlap) {
64
- out += `## ${filename} (contextual)\n${content}\n\n`;
65
- }
52
+ files = await readdir(dir);
53
+ } catch (err) {
54
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return "";
55
+ throw err;
56
+ }
57
+ const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
58
+
59
+ let out = "";
60
+ for (const filename of mdFiles) {
61
+ const filePath = join(dir, filename);
62
+ const raw = await Bun.file(filePath).text();
63
+ const { meta, content } = parsePromptFile(filePath, raw);
64
+
65
+ if (meta.loading === "always") {
66
+ out += `## ${filename}\n${content}\n\n`;
67
+ } else if (meta.loading === "contextual" && taskKeywords) {
68
+ const contentLower = content.toLowerCase();
69
+ const hasOverlap = [...taskKeywords].some((kw) =>
70
+ contentLower.includes(kw),
71
+ );
72
+ if (hasOverlap) {
73
+ out += `## ${filename} (contextual)\n${content}\n\n`;
66
74
  }
67
75
  }
68
- } catch {
69
- // prompts/ might not have md files yet
70
76
  }
71
77
 
72
78
  return out;
@@ -77,6 +77,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
77
77
  projectDir,
78
78
  dbPath,
79
79
  config,
80
+ workerId,
80
81
  mcpxClient,
81
82
  callbacks,
82
83
  task,
@@ -115,6 +116,7 @@ export async function runSpecificTask(opts: {
115
116
  projectDir: opts.projectDir,
116
117
  dbPath: opts.dbPath,
117
118
  config: opts.config,
119
+ workerId: opts.workerId,
118
120
  mcpxClient: opts.mcpxClient,
119
121
  callbacks: opts.callbacks,
120
122
  task,
@@ -126,11 +128,13 @@ async function runClaimedTask(opts: {
126
128
  projectDir: string;
127
129
  dbPath: string;
128
130
  config: Required<BotholomewConfig>;
131
+ workerId: string;
129
132
  mcpxClient?: McpxClient | null;
130
133
  callbacks?: WorkerStreamCallbacks;
131
134
  task: Task;
132
135
  }): Promise<void> {
133
- const { projectDir, dbPath, config, mcpxClient, callbacks, task } = opts;
136
+ const { projectDir, dbPath, config, workerId, mcpxClient, callbacks, task } =
137
+ opts;
134
138
 
135
139
  logger.info(`Claimed task: ${task.name} (${task.id})`);
136
140
  if (!callbacks && task.description) {
@@ -145,13 +149,22 @@ async function runClaimedTask(opts: {
145
149
  `Working: ${task.name}`,
146
150
  );
147
151
 
148
- const systemPrompt = await buildSystemPrompt(
149
- projectDir,
150
- task,
151
- dbPath,
152
- config,
153
- { hasMcpTools: mcpxClient != null },
154
- );
152
+ let systemPrompt: string;
153
+ try {
154
+ systemPrompt = await buildSystemPrompt(projectDir, task, dbPath, config, {
155
+ hasMcpTools: mcpxClient != null,
156
+ });
157
+ } catch (err) {
158
+ const reason = err instanceof Error ? err.message : String(err);
159
+ await updateTaskStatus(projectDir, task.id, "failed", reason, null);
160
+ await logInteraction(projectDir, threadId, {
161
+ role: "system",
162
+ kind: "status_change",
163
+ content: `Task ${task.id} failed during prompt load: ${reason}`,
164
+ });
165
+ logger.error(`Task ${task.id} failed during prompt load: ${reason}`);
166
+ return;
167
+ }
155
168
 
156
169
  try {
157
170
  const result = await runAgentLoop({
@@ -161,6 +174,7 @@ async function runClaimedTask(opts: {
161
174
  dbPath,
162
175
  threadId,
163
176
  projectDir,
177
+ workerId,
164
178
  mcpxClient,
165
179
  callbacks,
166
180
  });