botholomew 0.9.11 → 0.9.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "An autonomous AI agent for knowledge work — works your task queue while you sleep.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,11 +3,12 @@ import type { Command } from "commander";
3
3
  import { getDbPath } from "../constants.ts";
4
4
  import { withDb as coreWithDb } from "../db/connection.ts";
5
5
  import {
6
+ isPidAlive,
6
7
  type ProbeResult,
7
8
  probeAllTables,
8
9
  repairDatabase,
9
10
  } from "../db/doctor.ts";
10
- import { listWorkers } from "../db/workers.ts";
11
+ import { listWorkers, type Worker } from "../db/workers.ts";
11
12
  import { logger } from "../utils/logger.ts";
12
13
 
13
14
  function statusBadge(status: ProbeResult["status"]): string {
@@ -78,28 +79,38 @@ async function doctor(program: Command, repair: boolean): Promise<void> {
78
79
  process.exit(1);
79
80
  }
80
81
 
81
- // Repair requires exclusive access — refuse if any worker is registered
82
- // as running, otherwise the EXPORT would race with the worker's writes.
82
+ // Repair requires exclusive access — refuse if any worker is actually
83
+ // running, otherwise the EXPORT would race with the worker's writes.
84
+ // Stale `status='running'` rows whose PID is dead (the exact case that
85
+ // tends to coexist with workers-table corruption) are reported but do
86
+ // not block repair: trying to flip them to `stopped` would just trip
87
+ // the same corruption we're about to fix.
83
88
  const running = await coreWithDb(dbPath, async (conn) => {
84
89
  try {
85
90
  return await listWorkers(conn, { status: "running" });
86
91
  } catch {
87
- // If listWorkers itself trips the corruption we're about to fix,
88
- // fall through and let repair proceed; the user is on their own
89
- // for confirming no live workers, which `worker reap` would also
90
- // be unable to do anyway.
91
- return [];
92
+ return [] as Worker[];
92
93
  }
93
94
  });
94
- if (running.length > 0) {
95
+ const live = running.filter((w) => isPidAlive(w.pid));
96
+ const stale = running.filter((w) => !isPidAlive(w.pid));
97
+ if (live.length > 0) {
95
98
  logger.error(
96
- `${running.length} worker(s) registered as running. Stop them first: botholomew worker stop <id>`,
99
+ `${live.length} worker(s) actually running. Stop them first: botholomew worker stop <id>`,
97
100
  );
98
- for (const w of running) {
101
+ for (const w of live) {
99
102
  logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
100
103
  }
101
104
  process.exit(1);
102
105
  }
106
+ if (stale.length > 0) {
107
+ logger.warn(
108
+ `${stale.length} worker row(s) marked 'running' but PID is dead — proceeding (rows will be carried through repair, then reapable):`,
109
+ );
110
+ for (const w of stale) {
111
+ logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
112
+ }
113
+ }
103
114
 
104
115
  logger.phase("repair", "EXPORT DATABASE → swap files → IMPORT DATABASE");
105
116
  const result = await repairDatabase(dbPath);
package/src/db/doctor.ts CHANGED
@@ -85,12 +85,15 @@ export async function probeTable(
85
85
  }
86
86
  `;
87
87
 
88
+ // Discard the child's stderr. When the probe panics, Bun writes a multi-
89
+ // line crash banner there which would otherwise spill into our table
90
+ // output via the fallback message. The exit code alone tells us what we
91
+ // need to know.
88
92
  const proc = Bun.spawn(["bun", "-e", script], {
89
- stdio: ["ignore", "pipe", "pipe"],
93
+ stdio: ["ignore", "pipe", "ignore"],
90
94
  });
91
- const [stdout, stderr, exitCode] = await Promise.all([
95
+ const [stdout, exitCode] = await Promise.all([
92
96
  new Response(proc.stdout).text(),
93
- new Response(proc.stderr).text(),
94
97
  proc.exited,
95
98
  ]);
96
99
 
@@ -103,20 +106,21 @@ export async function probeTable(
103
106
  return {
104
107
  table,
105
108
  status: "missing",
106
- message: stdout.slice("MISSING:".length),
109
+ message: firstLine(stdout.slice("MISSING:".length)),
107
110
  };
108
111
  }
109
112
  if (stdout.startsWith("CORRUPT:")) {
110
113
  return {
111
114
  table,
112
115
  status: "corrupt",
113
- message: stdout.slice("CORRUPT:".length),
116
+ message: firstLine(stdout.slice("CORRUPT:".length)),
114
117
  };
115
118
  }
116
- const reason =
117
- stderr.trim() ||
118
- `child exited with code ${exitCode} and no verdict (likely native panic)`;
119
- return { table, status: "corrupt", message: reason };
119
+ return {
120
+ table,
121
+ status: "corrupt",
122
+ message: `child exited with code ${exitCode} (likely native panic)`,
123
+ };
120
124
  }
121
125
 
122
126
  /**
@@ -212,3 +216,27 @@ async function pathExists(p: string): Promise<boolean> {
212
216
  return false;
213
217
  }
214
218
  }
219
+
220
+ function firstLine(s: string): string {
221
+ const trimmed = s.trim();
222
+ const nl = trimmed.indexOf("\n");
223
+ return nl === -1 ? trimmed : trimmed.slice(0, nl);
224
+ }
225
+
226
+ /**
227
+ * Send signal 0 to test whether `pid` corresponds to a live process. Returns
228
+ * false on ESRCH (no such process) and on any other error (including EPERM,
229
+ * which we conservatively treat as "not ours, not relevant"). Used by the
230
+ * doctor's safety gate to distinguish workers actually running from rows
231
+ * that say `status = 'running'` because the worker crashed before flipping
232
+ * its row to `stopped` or `dead`.
233
+ */
234
+ export function isPidAlive(pid: number): boolean {
235
+ if (!pid || pid < 1) return false;
236
+ try {
237
+ process.kill(pid, 0);
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
package/src/worker/llm.ts CHANGED
@@ -11,12 +11,17 @@ import { getTask, type Task } from "../db/tasks.ts";
11
11
  import { logInteraction } from "../db/threads.ts";
12
12
  import { registerAllTools } from "../tools/registry.ts";
13
13
  import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
14
+ import { logger } from "../utils/logger.ts";
14
15
  import { fitToContextWindow, getMaxInputTokens } from "./context.ts";
15
16
  import { clearLargeResults, maybeStoreResult } from "./large-results.ts";
16
17
  import { createLlmClient } from "./llm-client.ts";
17
18
 
18
19
  registerAllTools();
19
20
 
21
+ function truncate(s: string, max: number): string {
22
+ return s.length > max ? `${s.slice(0, max)}…` : s;
23
+ }
24
+
20
25
  export interface WorkerStreamCallbacks {
21
26
  onToken: (text: string) => void;
22
27
  onToolStart: (name: string, input: string) => void;
@@ -153,6 +158,9 @@ export async function runAgentLoop(input: {
153
158
  tokenCount,
154
159
  }),
155
160
  );
161
+ if (!callbacks) {
162
+ logger.phase("assistant", block.text);
163
+ }
156
164
  }
157
165
  }
158
166
 
@@ -175,6 +183,12 @@ export async function runAgentLoop(input: {
175
183
  for (const toolUse of toolUseBlocks) {
176
184
  const toolInput = JSON.stringify(toolUse.input);
177
185
  callbacks?.onToolStart(toolUse.name, toolInput);
186
+ if (!callbacks) {
187
+ logger.phase(
188
+ "tool-call",
189
+ `${toolUse.name} ${truncate(toolInput, 200)}`,
190
+ );
191
+ }
178
192
  await withDb(dbPath, (conn) =>
179
193
  logInteraction(conn, threadId, {
180
194
  role: "assistant",
@@ -222,6 +236,11 @@ export async function runAgentLoop(input: {
222
236
  durationMs,
223
237
  }),
224
238
  );
239
+ if (!callbacks) {
240
+ const seconds = (durationMs / 1000).toFixed(1);
241
+ const status = result.isError ? "err" : "ok";
242
+ logger.phase("tool-result", `${toolUse.name} ${status} in ${seconds}s`);
243
+ }
225
244
 
226
245
  if (result.terminal && result.agentResult) {
227
246
  return result.agentResult;
@@ -133,6 +133,9 @@ async function runClaimedTask(opts: {
133
133
  const { projectDir, dbPath, config, mcpxClient, callbacks, task } = opts;
134
134
 
135
135
  logger.info(`Claimed task: ${task.name} (${task.id})`);
136
+ if (!callbacks && task.description) {
137
+ logger.dim(task.description);
138
+ }
136
139
  callbacks?.onTaskStart(task);
137
140
 
138
141
  const threadId = await withDb(dbPath, (conn) =>