botholomew 0.7.13 → 0.8.1
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 +37 -32
- package/package.json +1 -1
- package/src/chat/agent.ts +13 -11
- package/src/cli.ts +2 -2
- package/src/commands/chat.ts +29 -44
- package/src/commands/nuke.ts +11 -8
- package/src/commands/schedule.ts +1 -1
- package/src/commands/thread.ts +2 -2
- package/src/commands/with-db.ts +1 -1
- package/src/commands/worker.ts +231 -0
- package/src/config/schemas.ts +12 -0
- package/src/constants.ts +1 -27
- package/src/db/schedules.ts +66 -0
- package/src/db/schema.ts +16 -4
- package/src/db/sql/12-workers.sql +66 -0
- package/src/db/tasks.ts +25 -1
- package/src/db/threads.ts +1 -1
- package/src/db/workers.ts +207 -0
- package/src/init/index.ts +3 -1
- package/src/tools/context/read-large-result.ts +1 -1
- package/src/tools/mcp/exec.ts +1 -1
- package/src/tools/mcp/search.ts +1 -1
- package/src/tools/registry.ts +5 -0
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/worker/spawn.ts +50 -0
- package/src/tui/App.tsx +15 -7
- package/src/tui/components/HelpPanel.tsx +5 -5
- package/src/tui/components/StatusBar.tsx +22 -18
- package/src/tui/components/TabBar.tsx +3 -2
- package/src/tui/components/ThreadPanel.tsx +7 -7
- package/src/tui/components/WorkerPanel.tsx +207 -0
- package/src/utils/title.ts +1 -1
- package/src/worker/heartbeat.ts +78 -0
- package/src/worker/index.ts +200 -0
- package/src/{daemon → worker}/llm.ts +5 -5
- package/src/{daemon → worker}/prompt.ts +2 -2
- package/src/worker/run.ts +26 -0
- package/src/{daemon → worker}/schedules.ts +30 -2
- package/src/worker/spawn.ts +48 -0
- package/src/{daemon → worker}/tick.ts +93 -35
- package/src/commands/daemon.ts +0 -152
- package/src/daemon/ensure-running.ts +0 -16
- package/src/daemon/healthcheck.ts +0 -47
- package/src/daemon/index.ts +0 -106
- package/src/daemon/run.ts +0 -14
- package/src/daemon/spawn.ts +0 -38
- package/src/daemon/watchdog.ts +0 -306
- package/src/utils/pid.ts +0 -55
- package/src/utils/project-registry.ts +0 -48
- /package/src/{daemon → worker}/context.ts +0 -0
- /package/src/{daemon → worker}/fake-llm.ts +0 -0
- /package/src/{daemon → worker}/fake-mcp.ts +0 -0
- /package/src/{daemon → worker}/large-results.ts +0 -0
- /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 [
|
|
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 %
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
|
|
715
|
+
workerRunning={workerRunning}
|
|
708
716
|
/>
|
|
709
717
|
</Box>
|
|
710
718
|
|
|
@@ -4,13 +4,13 @@ import { memo } from "react";
|
|
|
4
4
|
interface HelpPanelProps {
|
|
5
5
|
projectDir: string;
|
|
6
6
|
threadId: string;
|
|
7
|
-
|
|
7
|
+
workerRunning: boolean;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export const HelpPanel = memo(function HelpPanel({
|
|
11
11
|
projectDir,
|
|
12
12
|
threadId,
|
|
13
|
-
|
|
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
|
-
{" "}
|
|
157
|
-
{
|
|
156
|
+
{" "}Workers{" "}
|
|
157
|
+
{workerRunning ? (
|
|
158
158
|
<Text color="green">running</Text>
|
|
159
159
|
) : (
|
|
160
|
-
<Text color="
|
|
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 {
|
|
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
|
-
|
|
12
|
+
onWorkerStatusChange?: (running: boolean) => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
interface Status {
|
|
16
|
-
|
|
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
|
-
|
|
25
|
+
onWorkerStatusChange,
|
|
26
26
|
}: StatusBarProps) {
|
|
27
27
|
const [status, setStatus] = useState<Status>({
|
|
28
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
47
|
+
workerCount: workers.length,
|
|
46
48
|
pendingCount: pending.length,
|
|
47
49
|
inProgressCount: inProgress.length,
|
|
48
50
|
});
|
|
49
|
-
|
|
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
|
-
}, [
|
|
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.
|
|
77
|
-
<Text color="green">
|
|
78
|
+
{status.workerCount > 0 ? (
|
|
79
|
+
<Text color="green">
|
|
80
|
+
{status.workerCount} worker{status.workerCount === 1 ? "" : "s"}
|
|
81
|
+
</Text>
|
|
78
82
|
) : (
|
|
79
|
-
<Text color="
|
|
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: "
|
|
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
|
-
"
|
|
25
|
+
"worker_tick",
|
|
26
26
|
"chat_session",
|
|
27
27
|
] as const;
|
|
28
28
|
|
|
29
29
|
const TYPE_LABELS: Record<Thread["type"], string> = {
|
|
30
|
-
|
|
31
|
-
chat_session: "
|
|
30
|
+
worker_tick: "worker",
|
|
31
|
+
chat_session: "chat",
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
const TYPE_ICONS: Record<Thread["type"], string> = {
|
|
35
|
-
|
|
35
|
+
worker_tick: "⚙",
|
|
36
36
|
chat_session: "💬",
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
const TYPE_COLORS: Record<Thread["type"], string> = {
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/utils/title.ts
CHANGED
|
@@ -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
|
+
}
|