@lovenyberg/ove 0.2.2 → 0.4.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.
package/src/worker.ts ADDED
@@ -0,0 +1,235 @@
1
+ import { join } from "node:path";
2
+ import { tmpdir } from "node:os";
3
+ import { unlink } from "node:fs/promises";
4
+ import { logger } from "./logger";
5
+ import { splitAndReply } from "./handlers";
6
+ import type { Config } from "./config";
7
+ import type { TaskQueue, Task } from "./queue";
8
+ import type { RepoManager } from "./repos";
9
+ import type { SessionStore } from "./sessions";
10
+ import type { IncomingMessage, ChatAdapter, EventAdapter, IncomingEvent } from "./adapters/types";
11
+ import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
12
+ import type { TraceStore } from "./trace";
13
+ import type { DebouncedFunction } from "./adapters/debounce";
14
+
15
+ export interface WorkerDeps {
16
+ config: Config;
17
+ queue: TaskQueue;
18
+ repos: RepoManager;
19
+ sessions: SessionStore;
20
+ adapters: ChatAdapter[];
21
+ pendingReplies: Map<string, IncomingMessage>;
22
+ pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
23
+ runningProcesses: Map<string, { abort: AbortController; task: Task }>;
24
+ getRunnerForRepo: (repo: string) => AgentRunner;
25
+ getRunnerOptsForRepo: (repo: string, baseOpts: RunOptions) => RunOptions;
26
+ getRepoInfo: (repoName: string) => { url: string; defaultBranch: string } | null;
27
+ trace: TraceStore;
28
+ }
29
+
30
+ function findAdapterForUser(userId: string, adapters: ChatAdapter[]): ChatAdapter | undefined {
31
+ const platform = userId.split(":")[0]; // e.g. "telegram", "slack", "discord"
32
+ return adapters.find((a) => a.constructor.name.toLowerCase().includes(platform));
33
+ }
34
+
35
+ async function replyWithFallback(
36
+ text: string,
37
+ originalMsg: IncomingMessage | undefined,
38
+ userId: string,
39
+ adapters: ChatAdapter[],
40
+ ) {
41
+ if (originalMsg) {
42
+ try {
43
+ await originalMsg.reply(text);
44
+ return;
45
+ } catch (err) {
46
+ logger.warn("original reply failed, trying fallback", { userId, error: String(err) });
47
+ }
48
+ }
49
+ const adapter = findAdapterForUser(userId, adapters);
50
+ if (adapter?.sendToUser) {
51
+ try {
52
+ await adapter.sendToUser(userId, text);
53
+ } catch (err) {
54
+ logger.warn("fallback sendToUser failed", { userId, error: String(err) });
55
+ }
56
+ }
57
+ }
58
+
59
+ async function processTask(task: Task, deps: WorkerDeps) {
60
+ const isCreateProject = task.taskType === "create-project";
61
+ const repoInfo = isCreateProject ? null : deps.getRepoInfo(task.repo);
62
+
63
+ if (!isCreateProject && !repoInfo) {
64
+ deps.queue.fail(task.id, `Unknown repo: ${task.repo}`);
65
+ return;
66
+ }
67
+
68
+ const abortController = new AbortController();
69
+ deps.runningProcesses.set(task.id, { abort: abortController, task });
70
+
71
+ const originalMsg = deps.pendingReplies.get(task.id);
72
+ const statusLog: string[] = [];
73
+ const trace = deps.trace;
74
+ const startTime = Date.now();
75
+
76
+ trace.append(task.id, "lifecycle", "Task started", task.prompt);
77
+
78
+ try {
79
+ await originalMsg?.updateStatus(`Working on it...`);
80
+
81
+ let workDir: string;
82
+
83
+ if (isCreateProject) {
84
+ workDir = join(deps.config.reposDir, task.repo);
85
+ await Bun.write(join(workDir, ".gitkeep"), "");
86
+ } else {
87
+ await deps.repos.cloneIfNeeded(task.repo, repoInfo!.url);
88
+ await deps.repos.pull(task.repo, repoInfo!.defaultBranch);
89
+
90
+ workDir = await deps.repos.createWorktree(
91
+ task.repo,
92
+ task.id,
93
+ repoInfo!.defaultBranch
94
+ );
95
+ }
96
+
97
+ try {
98
+ let mcpConfigPath: string | undefined;
99
+ if (deps.config.mcpServers && Object.keys(deps.config.mcpServers).length > 0) {
100
+ mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
101
+ await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: deps.config.mcpServers }));
102
+ }
103
+
104
+ const taskRunner = deps.getRunnerForRepo(task.repo);
105
+ // Cron tasks are autonomous — give them plenty of room to finish
106
+ const baseTurns = deps.config.claude.maxTurns;
107
+ const maxTurns = task.taskType === "cron" ? Math.max(baseTurns, 100) : baseTurns;
108
+ const runOpts = deps.getRunnerOptsForRepo(task.repo, {
109
+ maxTurns,
110
+ mcpConfigPath,
111
+ signal: abortController.signal,
112
+ });
113
+
114
+ const result = await taskRunner.run(
115
+ task.prompt,
116
+ workDir,
117
+ runOpts,
118
+ (event: StatusEvent) => {
119
+ if (event.kind === "tool") {
120
+ const last = statusLog[statusLog.length - 1];
121
+ const summary = `Using ${event.tool}...`;
122
+ if (last !== summary) statusLog.push(summary);
123
+ trace.append(task.id, "tool", summary, event.input.slice(0, 2000));
124
+ } else {
125
+ statusLog.push(event.text.slice(0, 200));
126
+ trace.append(task.id, "status", event.text.slice(0, 200));
127
+ }
128
+ originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
129
+ }
130
+ );
131
+
132
+ if (mcpConfigPath) {
133
+ try {
134
+ await unlink(mcpConfigPath);
135
+ } catch (err) {
136
+ logger.debug("failed to clean up mcp config", { error: String(err) });
137
+ }
138
+ }
139
+
140
+ // Cancel any pending debounced status update before sending final reply
141
+ const updateFn = originalMsg?.updateStatus as DebouncedFunction<any> | undefined;
142
+ if (updateFn?.cancel) updateFn.cancel();
143
+
144
+ trace.append(task.id, "output", "Runner output", result.output.slice(0, 10_000));
145
+
146
+ if (result.success) {
147
+ deps.queue.complete(task.id, result.output);
148
+ logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
149
+
150
+ const platform = originalMsg?.platform || "slack";
151
+ const isCron = task.taskType === "cron";
152
+ const replyText = isCron
153
+ ? `[Scheduled: ${task.repo}]\n${result.output}`
154
+ : result.output;
155
+ const parts = splitAndReply(replyText, platform);
156
+ for (const part of parts) {
157
+ await replyWithFallback(part, originalMsg, task.userId, deps.adapters);
158
+ }
159
+ trace.append(task.id, "lifecycle", "Reply sent");
160
+ deps.sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
161
+
162
+ const eventReply = deps.pendingEventReplies.get(task.id);
163
+ if (eventReply) {
164
+ await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
165
+ deps.pendingEventReplies.delete(task.id);
166
+ }
167
+
168
+ const elapsed = Date.now() - startTime;
169
+ trace.append(task.id, "lifecycle", `Task completed in ${elapsed}ms`);
170
+ } else {
171
+ deps.queue.fail(task.id, result.output);
172
+ logger.error("task failed", { taskId: task.id });
173
+ await replyWithFallback(`Task failed: ${result.output.slice(0, 500)}`, originalMsg, task.userId, deps.adapters);
174
+ deps.sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
175
+
176
+ const eventReply = deps.pendingEventReplies.get(task.id);
177
+ if (eventReply) {
178
+ await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
179
+ deps.pendingEventReplies.delete(task.id);
180
+ }
181
+
182
+ const elapsed = Date.now() - startTime;
183
+ trace.append(task.id, "lifecycle", `Task failed in ${elapsed}ms`, result.output.slice(0, 2000));
184
+ }
185
+ } finally {
186
+ if (!isCreateProject) {
187
+ await deps.repos.removeWorktree(task.repo, task.id).catch(() => {});
188
+ }
189
+ }
190
+ } catch (err) {
191
+ deps.queue.fail(task.id, String(err));
192
+ logger.error("task processing error", { taskId: task.id, error: String(err) });
193
+ trace.append(task.id, "error", "Task processing error", String(err).slice(0, 2000));
194
+ const updateFn = originalMsg?.updateStatus as DebouncedFunction<any> | undefined;
195
+ if (updateFn?.cancel) updateFn.cancel();
196
+ await replyWithFallback(`Task error: ${String(err).slice(0, 500)}`, originalMsg, task.userId, deps.adapters);
197
+ } finally {
198
+ deps.runningProcesses.delete(task.id);
199
+ deps.pendingReplies.delete(task.id);
200
+ }
201
+ }
202
+
203
+ export function createWorker(deps: WorkerDeps): { start: () => void; cancel: (id: string) => boolean } {
204
+ async function workerLoop() {
205
+ const maxConcurrent = 5;
206
+
207
+ while (true) {
208
+ if (deps.runningProcesses.size < maxConcurrent) {
209
+ try {
210
+ const task = deps.queue.dequeue();
211
+ if (task) {
212
+ processTask(task, deps).catch((err) =>
213
+ logger.error("worker task error", { taskId: task.id, error: String(err) })
214
+ );
215
+ continue;
216
+ }
217
+ } catch (err) {
218
+ logger.error("worker loop error", { error: String(err) });
219
+ }
220
+ }
221
+ await Bun.sleep(2000);
222
+ }
223
+ }
224
+
225
+ return {
226
+ start: () => { workerLoop(); },
227
+ cancel: (id: string) => {
228
+ const entry = deps.runningProcesses.get(id);
229
+ if (!entry) return false;
230
+ entry.abort.abort();
231
+ deps.queue.cancel(entry.task.id);
232
+ return true;
233
+ },
234
+ };
235
+ }