botholomew 0.7.12 → 0.8.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 (55) hide show
  1. package/README.md +37 -32
  2. package/package.json +1 -1
  3. package/src/chat/agent.ts +13 -11
  4. package/src/cli.ts +2 -2
  5. package/src/commands/chat.ts +29 -44
  6. package/src/commands/nuke.ts +11 -8
  7. package/src/commands/schedule.ts +1 -1
  8. package/src/commands/thread.ts +2 -2
  9. package/src/commands/with-db.ts +1 -1
  10. package/src/commands/worker.ts +231 -0
  11. package/src/config/schemas.ts +12 -0
  12. package/src/constants.ts +1 -27
  13. package/src/db/schedules.ts +66 -0
  14. package/src/db/schema.ts +5 -4
  15. package/src/db/sql/12-workers.sql +66 -0
  16. package/src/db/tasks.ts +25 -1
  17. package/src/db/threads.ts +1 -1
  18. package/src/db/workers.ts +207 -0
  19. package/src/init/index.ts +3 -1
  20. package/src/tools/context/read-large-result.ts +1 -1
  21. package/src/tools/mcp/exec.ts +1 -1
  22. package/src/tools/mcp/search.ts +1 -1
  23. package/src/tools/registry.ts +5 -0
  24. package/src/tools/thread/list.ts +2 -2
  25. package/src/tools/worker/spawn.ts +50 -0
  26. package/src/tui/App.tsx +15 -7
  27. package/src/tui/components/ContextPanel.tsx +5 -1
  28. package/src/tui/components/HelpPanel.tsx +5 -5
  29. package/src/tui/components/StatusBar.tsx +22 -18
  30. package/src/tui/components/TabBar.tsx +3 -2
  31. package/src/tui/components/ThreadPanel.tsx +7 -7
  32. package/src/tui/components/WorkerPanel.tsx +207 -0
  33. package/src/utils/title.ts +1 -1
  34. package/src/worker/heartbeat.ts +78 -0
  35. package/src/worker/index.ts +200 -0
  36. package/src/{daemon → worker}/llm.ts +5 -5
  37. package/src/{daemon → worker}/prompt.ts +2 -2
  38. package/src/worker/run.ts +26 -0
  39. package/src/{daemon → worker}/schedules.ts +30 -2
  40. package/src/worker/spawn.ts +48 -0
  41. package/src/{daemon → worker}/tick.ts +93 -35
  42. package/src/commands/daemon.ts +0 -152
  43. package/src/daemon/ensure-running.ts +0 -16
  44. package/src/daemon/healthcheck.ts +0 -47
  45. package/src/daemon/index.ts +0 -106
  46. package/src/daemon/run.ts +0 -14
  47. package/src/daemon/spawn.ts +0 -38
  48. package/src/daemon/watchdog.ts +0 -306
  49. package/src/utils/pid.ts +0 -55
  50. package/src/utils/project-registry.ts +0 -48
  51. /package/src/{daemon → worker}/context.ts +0 -0
  52. /package/src/{daemon → worker}/fake-llm.ts +0 -0
  53. /package/src/{daemon → worker}/fake-mcp.ts +0 -0
  54. /package/src/{daemon → worker}/large-results.ts +0 -0
  55. /package/src/{daemon → worker}/llm-client.ts +0 -0
package/src/tui/App.tsx CHANGED
@@ -6,7 +6,6 @@ import {
6
6
  sendMessage,
7
7
  startChatSession,
8
8
  } from "../chat/session.ts";
9
- import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
10
9
  import { withDb } from "../db/connection.ts";
11
10
  import type { Interaction } from "../db/threads.ts";
12
11
  import { getThread } from "../db/threads.ts";
@@ -15,6 +14,7 @@ import {
15
14
  handleSlashCommand,
16
15
  type SlashCommand,
17
16
  } from "../skills/commands.ts";
17
+ import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../worker/large-results.ts";
18
18
  import { ContextPanel } from "./components/ContextPanel.tsx";
19
19
  import { HelpPanel } from "./components/HelpPanel.tsx";
20
20
  import { InputBar } from "./components/InputBar.tsx";
@@ -32,6 +32,7 @@ import { TaskPanel } from "./components/TaskPanel.tsx";
32
32
  import { ThreadPanel } from "./components/ThreadPanel.tsx";
33
33
  import type { ToolCallData } from "./components/ToolCall.tsx";
34
34
  import { ToolPanel } from "./components/ToolPanel.tsx";
35
+ import { WorkerPanel } from "./components/WorkerPanel.tsx";
35
36
  import { buildSlashCommands, getSlashMatches } from "./slashCompletion.ts";
36
37
  import { ansi } from "./theme.ts";
37
38
 
@@ -136,7 +137,7 @@ export function App({
136
137
  const [error, setError] = useState<string | null>(null);
137
138
  const sessionRef = useRef<ChatSession | null>(null);
138
139
  const [activeTab, setActiveTab] = useState<TabId>(1);
139
- const [daemonRunning, setDaemonRunning] = useState(false);
140
+ const [workerRunning, setWorkerRunning] = useState(false);
140
141
  const [chatTitle, setChatTitle] = useState<string | undefined>(undefined);
141
142
  const queueRef = useRef<string[]>([]);
142
143
  const processingRef = useRef(false);
@@ -222,7 +223,7 @@ export function App({
222
223
  const [dwellRaw, delayRaw] = spec.split(":");
223
224
  const dwellMs = Number.parseInt(dwellRaw ?? "", 10) || 2500;
224
225
  const startDelayMs = Number.parseInt(delayRaw ?? "", 10) || 0;
225
- const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 1];
226
+ const sequence: TabId[] = [2, 3, 4, 5, 6, 7, 8, 1];
226
227
  const timers = sequence.map((tab, i) =>
227
228
  setTimeout(() => setActiveTab(tab), startDelayMs + dwellMs * (i + 1)),
228
229
  );
@@ -262,7 +263,7 @@ export function App({
262
263
  );
263
264
  if (popupOpen) return;
264
265
  }
265
- setActiveTab((t) => ((t % 7) + 1) as TabId);
266
+ setActiveTab((t) => ((t % 8) + 1) as TabId);
266
267
  return;
267
268
  }
268
269
 
@@ -299,7 +300,7 @@ export function App({
299
300
  if (tab !== 1) {
300
301
  // Number keys jump to tab on non-chat tabs
301
302
  const num = Number.parseInt(input, 10);
302
- if (num >= 1 && num <= 7) {
303
+ if (num >= 1 && num <= 8) {
303
304
  setActiveTab(num as TabId);
304
305
  return;
305
306
  }
@@ -582,7 +583,7 @@ export function App({
582
583
  projectDir={projectDir}
583
584
  dbPath={sessionDbPath}
584
585
  chatTitle={chatTitle}
585
- onDaemonStatusChange={setDaemonRunning}
586
+ onWorkerStatusChange={setWorkerRunning}
586
587
  />
587
588
  ) : null,
588
589
  [projectDir, sessionDbPath, chatTitle],
@@ -700,11 +701,18 @@ export function App({
700
701
  display={activeTab === 7 ? "flex" : "none"}
701
702
  flexDirection="column"
702
703
  flexGrow={1}
704
+ >
705
+ <WorkerPanel dbPath={dbPath} isActive={activeTab === 7} />
706
+ </Box>
707
+ <Box
708
+ display={activeTab === 8 ? "flex" : "none"}
709
+ flexDirection="column"
710
+ flexGrow={1}
703
711
  >
704
712
  <HelpPanel
705
713
  projectDir={projectDir}
706
714
  threadId={threadId}
707
- daemonRunning={daemonRunning}
715
+ workerRunning={workerRunning}
708
716
  />
709
717
  </Box>
710
718
 
@@ -272,13 +272,17 @@ export const ContextPanel = memo(function ContextPanel({
272
272
  {visibleItems.map((item, vi) => {
273
273
  const i = vi + scrollOffset;
274
274
  const ci = item as ContextItem;
275
+ const slashIdx = ci.context_path.lastIndexOf("/");
276
+ const dir =
277
+ slashIdx >= 0 ? ci.context_path.slice(0, slashIdx + 1) : "";
275
278
  return (
276
279
  <Box key={ci.id}>
277
280
  <Text
278
281
  backgroundColor={i === cursor ? "#333" : undefined}
279
282
  color={i === cursor ? "cyan" : undefined}
280
283
  >
281
- {" "}📄 {ci.context_path}
284
+ {" "}📄 <Text dimColor>{dir}</Text>
285
+ {ci.title}
282
286
  <Text dimColor> ({ci.mime_type})</Text>
283
287
  </Text>
284
288
  </Box>
@@ -4,13 +4,13 @@ import { memo } from "react";
4
4
  interface HelpPanelProps {
5
5
  projectDir: string;
6
6
  threadId: string;
7
- daemonRunning: boolean;
7
+ workerRunning: boolean;
8
8
  }
9
9
 
10
10
  export const HelpPanel = memo(function HelpPanel({
11
11
  projectDir,
12
12
  threadId,
13
- daemonRunning,
13
+ workerRunning,
14
14
  }: HelpPanelProps) {
15
15
  return (
16
16
  <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
@@ -153,11 +153,11 @@ export const HelpPanel = memo(function HelpPanel({
153
153
  {threadId}
154
154
  </Text>
155
155
  <Text>
156
- {" "}Daemon{" "}
157
- {daemonRunning ? (
156
+ {" "}Workers{" "}
157
+ {workerRunning ? (
158
158
  <Text color="green">running</Text>
159
159
  ) : (
160
- <Text color="red">off</Text>
160
+ <Text color="yellow">none</Text>
161
161
  )}
162
162
  </Text>
163
163
  </Box>
@@ -2,30 +2,30 @@ import { Box, Text } from "ink";
2
2
  import { useEffect, useState } from "react";
3
3
  import { withDb } from "../../db/connection.ts";
4
4
  import { listTasks } from "../../db/tasks.ts";
5
- import { getDaemonStatus } from "../../utils/pid.ts";
5
+ import { listWorkers } from "../../db/workers.ts";
6
6
  import { LogoChar } from "./Logo.tsx";
7
7
 
8
8
  interface StatusBarProps {
9
9
  projectDir: string;
10
10
  dbPath: string;
11
11
  chatTitle?: string;
12
- onDaemonStatusChange?: (running: boolean) => void;
12
+ onWorkerStatusChange?: (running: boolean) => void;
13
13
  }
14
14
 
15
15
  interface Status {
16
- daemonRunning: boolean;
16
+ workerCount: number;
17
17
  pendingCount: number;
18
18
  inProgressCount: number;
19
19
  }
20
20
 
21
21
  export function StatusBar({
22
- projectDir,
22
+ projectDir: _projectDir,
23
23
  dbPath,
24
24
  chatTitle,
25
- onDaemonStatusChange,
25
+ onWorkerStatusChange,
26
26
  }: StatusBarProps) {
27
27
  const [status, setStatus] = useState<Status>({
28
- daemonRunning: false,
28
+ workerCount: 0,
29
29
  pendingCount: 0,
30
30
  inProgressCount: 0,
31
31
  });
@@ -34,19 +34,21 @@ export function StatusBar({
34
34
  let mounted = true;
35
35
 
36
36
  const refresh = async () => {
37
- const daemon = await getDaemonStatus(projectDir);
38
- const [pending, inProgress] = await withDb(dbPath, async (conn) => [
39
- await listTasks(conn, { status: "pending" }),
40
- await listTasks(conn, { status: "in_progress" }),
41
- ]);
37
+ const [pending, inProgress, workers] = await withDb(
38
+ dbPath,
39
+ async (conn) => [
40
+ await listTasks(conn, { status: "pending" }),
41
+ await listTasks(conn, { status: "in_progress" }),
42
+ await listWorkers(conn, { status: "running" }),
43
+ ],
44
+ );
42
45
  if (mounted) {
43
- const daemonRunning = daemon !== null;
44
46
  setStatus({
45
- daemonRunning,
47
+ workerCount: workers.length,
46
48
  pendingCount: pending.length,
47
49
  inProgressCount: inProgress.length,
48
50
  });
49
- onDaemonStatusChange?.(daemonRunning);
51
+ onWorkerStatusChange?.(workers.length > 0);
50
52
  }
51
53
  };
52
54
 
@@ -56,7 +58,7 @@ export function StatusBar({
56
58
  mounted = false;
57
59
  clearInterval(interval);
58
60
  };
59
- }, [projectDir, dbPath, onDaemonStatusChange]);
61
+ }, [dbPath, onWorkerStatusChange]);
60
62
 
61
63
  return (
62
64
  <Box paddingX={0}>
@@ -73,10 +75,12 @@ export function StatusBar({
73
75
  </>
74
76
  )}
75
77
  <Text dimColor> | </Text>
76
- {status.daemonRunning ? (
77
- <Text color="green">Daemon</Text>
78
+ {status.workerCount > 0 ? (
79
+ <Text color="green">
80
+ {status.workerCount} worker{status.workerCount === 1 ? "" : "s"}
81
+ </Text>
78
82
  ) : (
79
- <Text color="red">Daemon (off)</Text>
83
+ <Text color="yellow">no workers</Text>
80
84
  )}
81
85
  <Text dimColor> | </Text>
82
86
  <Text>
@@ -1,6 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
 
3
- export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7;
3
+ export type TabId = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
4
4
 
5
5
  const TABS: { id: TabId; label: string }[] = [
6
6
  { id: 1, label: "Chat" },
@@ -9,7 +9,8 @@ const TABS: { id: TabId; label: string }[] = [
9
9
  { id: 4, label: "Tasks" },
10
10
  { id: 5, label: "Threads" },
11
11
  { id: 6, label: "Schedules" },
12
- { id: 7, label: "Help" },
12
+ { id: 7, label: "Workers" },
13
+ { id: 8, label: "Help" },
13
14
  ];
14
15
 
15
16
  interface TabBarProps {
@@ -22,27 +22,27 @@ const SIDEBAR_WIDTH = 42;
22
22
  const PAGE_SCROLL_LINES = 10;
23
23
 
24
24
  const THREAD_TYPES: readonly Thread["type"][] = [
25
- "daemon_tick",
25
+ "worker_tick",
26
26
  "chat_session",
27
27
  ] as const;
28
28
 
29
29
  const TYPE_LABELS: Record<Thread["type"], string> = {
30
- daemon_tick: "daemon",
31
- chat_session: "agent",
30
+ worker_tick: "worker",
31
+ chat_session: "chat",
32
32
  };
33
33
 
34
34
  const TYPE_ICONS: Record<Thread["type"], string> = {
35
- daemon_tick: "⚙",
35
+ worker_tick: "⚙",
36
36
  chat_session: "💬",
37
37
  };
38
38
 
39
39
  const TYPE_COLORS: Record<Thread["type"], string> = {
40
- daemon_tick: theme.accent,
40
+ worker_tick: theme.accent,
41
41
  chat_session: theme.info,
42
42
  };
43
43
 
44
44
  const TYPE_ANSI: Record<Thread["type"], string> = {
45
- daemon_tick: ansi.accent,
45
+ worker_tick: ansi.accent,
46
46
  chat_session: ansi.info,
47
47
  };
48
48
 
@@ -481,7 +481,7 @@ export const ThreadPanel = memo(function ThreadPanel({
481
481
  ? `No threads match "${searchQuery}". Press Escape to clear search.`
482
482
  : typeFilter
483
483
  ? "No threads match the current filter. Press f to change filter."
484
- : "No threads found. Threads will appear as chat sessions and daemon ticks occur."}
484
+ : "No threads found. Threads will appear as chat sessions and worker ticks occur."}
485
485
  </Text>
486
486
  {typeFilter && (
487
487
  <Box marginTop={1}>
@@ -0,0 +1,207 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import { memo, useEffect, useState } from "react";
3
+ import { withDb } from "../../db/connection.ts";
4
+ import { listWorkers, type Worker } from "../../db/workers.ts";
5
+
6
+ interface WorkerPanelProps {
7
+ dbPath: string;
8
+ isActive: boolean;
9
+ }
10
+
11
+ const STATUS_FILTERS: readonly (Worker["status"] | null)[] = [
12
+ null,
13
+ "running",
14
+ "stopped",
15
+ "dead",
16
+ ];
17
+
18
+ function statusColor(status: Worker["status"]): string {
19
+ switch (status) {
20
+ case "running":
21
+ return "green";
22
+ case "stopped":
23
+ return "gray";
24
+ case "dead":
25
+ return "red";
26
+ }
27
+ }
28
+
29
+ function formatAge(from: Date, now: Date): string {
30
+ const secs = Math.max(0, Math.floor((now.getTime() - from.getTime()) / 1000));
31
+ if (secs < 60) return `${secs}s`;
32
+ const mins = Math.floor(secs / 60);
33
+ if (mins < 60) return `${mins}m`;
34
+ const hours = Math.floor(mins / 60);
35
+ if (hours < 24) return `${hours}h`;
36
+ return `${Math.floor(hours / 24)}d`;
37
+ }
38
+
39
+ export const WorkerPanel = memo(function WorkerPanel({
40
+ dbPath,
41
+ isActive,
42
+ }: WorkerPanelProps) {
43
+ const { stdout } = useStdout();
44
+ const termRows = stdout?.rows ?? 24;
45
+ const [workers, setWorkers] = useState<Worker[]>([]);
46
+ const [selectedIndex, setSelectedIndex] = useState(0);
47
+ const [filterIdx, setFilterIdx] = useState(0);
48
+ const [now, setNow] = useState(() => new Date());
49
+
50
+ useEffect(() => {
51
+ let mounted = true;
52
+
53
+ const refresh = async () => {
54
+ const status = STATUS_FILTERS[filterIdx] ?? undefined;
55
+ const result = await withDb(dbPath, (conn) =>
56
+ listWorkers(conn, status ? { status } : {}),
57
+ );
58
+ if (mounted) {
59
+ setWorkers(result);
60
+ setNow(new Date());
61
+ setSelectedIndex((prev) =>
62
+ Math.min(prev, Math.max(0, result.length - 1)),
63
+ );
64
+ }
65
+ };
66
+
67
+ refresh();
68
+ const interval = setInterval(refresh, 3000);
69
+ return () => {
70
+ mounted = false;
71
+ clearInterval(interval);
72
+ };
73
+ }, [dbPath, filterIdx]);
74
+
75
+ useInput(
76
+ (input, key) => {
77
+ if (!isActive) return;
78
+ if (key.upArrow) {
79
+ setSelectedIndex((i) => Math.max(0, i - 1));
80
+ return;
81
+ }
82
+ if (key.downArrow) {
83
+ setSelectedIndex((i) => Math.min(workers.length - 1, i + 1));
84
+ return;
85
+ }
86
+ if (input === "f") {
87
+ setFilterIdx((i) => (i + 1) % STATUS_FILTERS.length);
88
+ return;
89
+ }
90
+ },
91
+ { isActive },
92
+ );
93
+
94
+ const selected = workers[selectedIndex];
95
+ const filterLabel = STATUS_FILTERS[filterIdx] ?? "all";
96
+ const visibleRows = Math.max(4, termRows - 10);
97
+
98
+ return (
99
+ <Box flexDirection="column" flexGrow={1} paddingX={1}>
100
+ <Box marginBottom={1}>
101
+ <Text bold color="cyan">
102
+ Workers
103
+ </Text>
104
+ <Text dimColor> · filter: </Text>
105
+ <Text color="yellow">{filterLabel}</Text>
106
+ <Text dimColor>{" · [f] cycle filter [↑↓] select"}</Text>
107
+ </Box>
108
+
109
+ {workers.length === 0 ? (
110
+ <Text dimColor>
111
+ No workers
112
+ {filterLabel !== "all" ? ` with status "${filterLabel}"` : ""}.{"\n"}
113
+ Start one with{" "}
114
+ <Text color="green">botholomew worker start --persist</Text>.
115
+ </Text>
116
+ ) : (
117
+ <Box flexDirection="row" flexGrow={1}>
118
+ <Box
119
+ flexDirection="column"
120
+ width={44}
121
+ marginRight={2}
122
+ overflow="hidden"
123
+ >
124
+ {workers.slice(0, visibleRows).map((w, i) => {
125
+ const active = i === selectedIndex;
126
+ const short = w.id.slice(0, 8);
127
+ return (
128
+ <Box key={w.id}>
129
+ <Text
130
+ color={active ? "cyan" : undefined}
131
+ bold={active}
132
+ backgroundColor={active ? "#1a3a5c" : undefined}
133
+ >
134
+ {active ? "›" : " "}{" "}
135
+ </Text>
136
+ <Text
137
+ color={statusColor(w.status)}
138
+ dimColor={!active && w.status !== "running"}
139
+ >
140
+ {w.status.padEnd(8)}
141
+ </Text>
142
+ <Text dimColor> </Text>
143
+ <Text>{short}</Text>
144
+ <Text dimColor>{` ${w.mode.padEnd(7)}`}</Text>
145
+ <Text dimColor>{formatAge(w.last_heartbeat_at, now)}</Text>
146
+ </Box>
147
+ );
148
+ })}
149
+ </Box>
150
+ <Box flexDirection="column" flexGrow={1}>
151
+ {selected ? <WorkerDetail worker={selected} now={now} /> : null}
152
+ </Box>
153
+ </Box>
154
+ )}
155
+ </Box>
156
+ );
157
+ });
158
+
159
+ function WorkerDetail({ worker, now }: { worker: Worker; now: Date }) {
160
+ return (
161
+ <Box flexDirection="column">
162
+ <Text bold color="blue">
163
+ {worker.id}
164
+ </Text>
165
+ <Box marginTop={1} flexDirection="column">
166
+ <Text>
167
+ <Text dimColor>Status </Text>
168
+ <Text color={statusColor(worker.status)}>{worker.status}</Text>
169
+ </Text>
170
+ <Text>
171
+ <Text dimColor>Mode </Text>
172
+ {worker.mode}
173
+ </Text>
174
+ <Text>
175
+ <Text dimColor>PID </Text>
176
+ {worker.pid}
177
+ </Text>
178
+ <Text>
179
+ <Text dimColor>Host </Text>
180
+ {worker.hostname}
181
+ </Text>
182
+ <Text>
183
+ <Text dimColor>Started </Text>
184
+ {worker.started_at.toISOString()}{" "}
185
+ <Text dimColor>({formatAge(worker.started_at, now)} ago)</Text>
186
+ </Text>
187
+ <Text>
188
+ <Text dimColor>Heartbeat</Text>{" "}
189
+ {worker.last_heartbeat_at.toISOString()}{" "}
190
+ <Text dimColor>({formatAge(worker.last_heartbeat_at, now)} ago)</Text>
191
+ </Text>
192
+ {worker.stopped_at && (
193
+ <Text>
194
+ <Text dimColor>Stopped </Text>
195
+ {worker.stopped_at.toISOString()}
196
+ </Text>
197
+ )}
198
+ {worker.task_id && (
199
+ <Text>
200
+ <Text dimColor>Task </Text>
201
+ {worker.task_id}
202
+ </Text>
203
+ )}
204
+ </Box>
205
+ </Box>
206
+ );
207
+ }
@@ -1,7 +1,7 @@
1
1
  import type { BotholomewConfig } from "../config/schemas.ts";
2
- import { createLlmClient } from "../daemon/llm-client.ts";
3
2
  import { withDb } from "../db/connection.ts";
4
3
  import { updateThreadTitle } from "../db/threads.ts";
4
+ import { createLlmClient } from "../worker/llm-client.ts";
5
5
  import { logger } from "./logger.ts";
6
6
 
7
7
  /**
@@ -0,0 +1,78 @@
1
+ import { withDb } from "../db/connection.ts";
2
+ import {
3
+ heartbeat,
4
+ pruneStoppedWorkers,
5
+ reapDeadWorkers,
6
+ } from "../db/workers.ts";
7
+ import { logger } from "../utils/logger.ts";
8
+
9
+ /**
10
+ * Start a non-blocking heartbeat interval for a running worker.
11
+ *
12
+ * The heartbeat runs on its own `setInterval` timer so it stays live even
13
+ * while the worker is blocked inside a long LLM call. We `unref` the timer
14
+ * so it doesn't keep the Bun event loop alive on its own — the main tick
15
+ * loop (or the awaited one-shot task) is what keeps the process running.
16
+ *
17
+ * Errors are swallowed with a warning: a transient DB lock shouldn't crash
18
+ * a worker that's otherwise doing useful work. If every heartbeat fails the
19
+ * worker will eventually be reaped, which is the correct outcome.
20
+ */
21
+ export function startHeartbeat(
22
+ dbPath: string,
23
+ workerId: string,
24
+ intervalSeconds: number,
25
+ ): () => void {
26
+ const ms = Math.max(1_000, intervalSeconds * 1_000);
27
+ const handle = setInterval(async () => {
28
+ try {
29
+ await withDb(dbPath, (conn) => heartbeat(conn, workerId));
30
+ } catch (err) {
31
+ logger.warn(`worker heartbeat failed: ${err}`);
32
+ }
33
+ }, ms);
34
+ handle.unref?.();
35
+ return () => clearInterval(handle);
36
+ }
37
+
38
+ /**
39
+ * Start a periodic reaper that marks stale workers dead and releases any
40
+ * tasks / schedule claims they held. Only persist workers need this — a
41
+ * one-shot worker does a single reap pass before claiming its task.
42
+ */
43
+ export function startReaper(
44
+ dbPath: string,
45
+ intervalSeconds: number,
46
+ staleAfterSeconds: number,
47
+ stoppedRetentionSeconds: number,
48
+ ): () => void {
49
+ const ms = Math.max(1_000, intervalSeconds * 1_000);
50
+ const handle = setInterval(async () => {
51
+ try {
52
+ const reaped = await withDb(dbPath, (conn) =>
53
+ reapDeadWorkers(conn, staleAfterSeconds),
54
+ );
55
+ if (reaped.length > 0) {
56
+ logger.warn(
57
+ `reaped ${reaped.length} stale worker(s): ${reaped.join(", ")}`,
58
+ );
59
+ }
60
+ } catch (err) {
61
+ logger.warn(`worker reap failed: ${err}`);
62
+ }
63
+ try {
64
+ const pruned = await withDb(dbPath, (conn) =>
65
+ pruneStoppedWorkers(conn, stoppedRetentionSeconds),
66
+ );
67
+ if (pruned.length > 0) {
68
+ logger.debug(
69
+ `pruned ${pruned.length} old stopped worker(s): ${pruned.join(", ")}`,
70
+ );
71
+ }
72
+ } catch (err) {
73
+ logger.warn(`worker prune failed: ${err}`);
74
+ }
75
+ }, ms);
76
+ handle.unref?.();
77
+ return () => clearInterval(handle);
78
+ }