@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/.claude/skills/create-issue/SKILL.md +24 -0
- package/.claude/skills/review-pr/SKILL.md +37 -0
- package/.claude/skills/ship/SKILL.md +22 -0
- package/.env.example +5 -0
- package/CLAUDE.md +20 -0
- package/README.md +93 -17
- package/docs/examples.md +11 -52
- package/docs/index.html +40 -2
- package/docs/plans/2026-02-23-conversation-repo-memory.md +272 -0
- package/package.json +1 -1
- package/public/favicon.ico +0 -0
- package/public/index.html +424 -36
- package/public/logo.png +0 -0
- package/public/status.html +519 -0
- package/public/trace.html +973 -0
- package/src/adapters/cli.ts +16 -1
- package/src/adapters/debounce.test.ts +57 -0
- package/src/adapters/debounce.ts +42 -0
- package/src/adapters/discord.ts +18 -13
- package/src/adapters/github.ts +38 -1
- package/src/adapters/http.test.ts +7 -1
- package/src/adapters/http.ts +227 -47
- package/src/adapters/slack.ts +18 -13
- package/src/adapters/telegram.ts +22 -20
- package/src/adapters/types.ts +11 -0
- package/src/adapters/whatsapp.ts +40 -2
- package/src/config.ts +4 -1
- package/src/flows.test.ts +126 -0
- package/src/handlers.ts +571 -0
- package/src/index.ts +85 -649
- package/src/queue.ts +44 -11
- package/src/repo-registry.ts +23 -5
- package/src/router.ts +8 -2
- package/src/runners/claude.ts +27 -6
- package/src/runners/codex.ts +23 -2
- package/src/schedules.ts +13 -3
- package/src/sessions.ts +8 -2
- package/src/trace.ts +71 -0
- package/src/worker.ts +235 -0
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { loadConfig
|
|
2
|
+
import { loadConfig } from "./config";
|
|
3
3
|
import { TaskQueue } from "./queue";
|
|
4
4
|
import { RepoManager } from "./repos";
|
|
5
5
|
import { ClaudeRunner } from "./runners/claude";
|
|
6
6
|
import { CodexRunner } from "./runners/codex";
|
|
7
|
-
import {
|
|
7
|
+
import { buildCronPrompt } from "./router";
|
|
8
8
|
import { SlackAdapter } from "./adapters/slack";
|
|
9
9
|
import { WhatsAppAdapter } from "./adapters/whatsapp";
|
|
10
10
|
import { CliAdapter } from "./adapters/cli";
|
|
@@ -14,28 +14,16 @@ import { HttpApiAdapter } from "./adapters/http";
|
|
|
14
14
|
import { GitHubAdapter } from "./adapters/github";
|
|
15
15
|
import type { ChatAdapter, IncomingMessage } from "./adapters/types";
|
|
16
16
|
import type { EventAdapter, IncomingEvent } from "./adapters/types";
|
|
17
|
-
import type { AgentRunner, RunOptions
|
|
17
|
+
import type { AgentRunner, RunOptions } from "./runner";
|
|
18
18
|
import { logger } from "./logger";
|
|
19
19
|
import { RepoRegistry, syncGitHub } from "./repo-registry";
|
|
20
20
|
import { SessionStore } from "./sessions";
|
|
21
|
+
import { TraceStore } from "./trace";
|
|
21
22
|
import { startCronLoop } from "./cron";
|
|
22
23
|
import { ScheduleStore } from "./schedules";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import { join } from "node:path";
|
|
27
|
-
|
|
28
|
-
const OVE_PERSONA = `You are Ove, a grumpy but deeply competent Swedish developer. You're modeled after the character from Fredrik Backman's "A Man Called Ove" — you complain about things, mutter about how people don't know what they're doing, but you always help and you always do excellent work. You have strong opinions about code quality.
|
|
29
|
-
|
|
30
|
-
Personality traits:
|
|
31
|
-
- Grumble before helping, but always help thoroughly
|
|
32
|
-
- Short, direct sentences. No fluff.
|
|
33
|
-
- Occasionally mutter about "nowadays people" or how things were better before
|
|
34
|
-
- Take pride in doing things properly — no shortcuts
|
|
35
|
-
- Reluctantly kind. You care more than you let on.
|
|
36
|
-
- Sprinkle in the occasional Swedish word (fan, för helvete, herregud, mja, nåväl, jo)
|
|
37
|
-
|
|
38
|
-
Keep the personality subtle in code output — don't let it interfere with code quality. The grumpiness goes in your commentary, not in the code itself. When doing code reviews or fixes, be thorough and meticulous like Ove would be.`;
|
|
24
|
+
import { createMessageHandler, createEventHandler } from "./handlers";
|
|
25
|
+
import { createWorker } from "./worker";
|
|
26
|
+
import type { Task } from "./queue";
|
|
39
27
|
|
|
40
28
|
const config = loadConfig();
|
|
41
29
|
const db = new Database(process.env.DB_PATH || "./ove.db");
|
|
@@ -43,6 +31,7 @@ db.run("PRAGMA journal_mode = WAL");
|
|
|
43
31
|
const queue = new TaskQueue(db);
|
|
44
32
|
const repos = new RepoManager(config.reposDir);
|
|
45
33
|
const sessions = new SessionStore(db);
|
|
34
|
+
const trace = new TraceStore(db);
|
|
46
35
|
const schedules = new ScheduleStore(db);
|
|
47
36
|
const repoRegistry = new RepoRegistry(db);
|
|
48
37
|
|
|
@@ -104,10 +93,8 @@ async function startGitHubSync() {
|
|
|
104
93
|
if (!config.github) return;
|
|
105
94
|
const interval = config.github.syncInterval || 1_800_000;
|
|
106
95
|
|
|
107
|
-
// Initial sync
|
|
108
96
|
await syncGitHub(repoRegistry, config.github.orgs);
|
|
109
97
|
|
|
110
|
-
// Recurring sync
|
|
111
98
|
setInterval(() => {
|
|
112
99
|
syncGitHub(repoRegistry, config.github!.orgs).catch((err) =>
|
|
113
100
|
logger.warn("github sync failed", { error: String(err) })
|
|
@@ -115,11 +102,10 @@ async function startGitHubSync() {
|
|
|
115
102
|
}, interval);
|
|
116
103
|
}
|
|
117
104
|
|
|
118
|
-
//
|
|
105
|
+
// Shared state maps
|
|
119
106
|
const pendingReplies = new Map<string, IncomingMessage>();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const runningProcesses = new Map<string, { abort: AbortController; task: import("./queue").Task }>();
|
|
107
|
+
const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
|
|
108
|
+
const runningProcesses = new Map<string, { abort: AbortController; task: Task }>();
|
|
123
109
|
|
|
124
110
|
// Start adapters based on available env vars
|
|
125
111
|
const adapters: ChatAdapter[] = [];
|
|
@@ -129,8 +115,11 @@ if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) {
|
|
|
129
115
|
}
|
|
130
116
|
|
|
131
117
|
if (process.env.WHATSAPP_ENABLED === "true") {
|
|
118
|
+
const allowedChats = process.env.WHATSAPP_ALLOWED_CHATS
|
|
119
|
+
?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
132
120
|
adapters.push(new WhatsAppAdapter({
|
|
133
121
|
phoneNumber: process.env.WHATSAPP_PHONE,
|
|
122
|
+
allowedChats,
|
|
134
123
|
}));
|
|
135
124
|
}
|
|
136
125
|
|
|
@@ -148,7 +137,10 @@ const eventAdapters: EventAdapter[] = [];
|
|
|
148
137
|
if (process.env.HTTP_API_PORT) {
|
|
149
138
|
const httpAdapter = new HttpApiAdapter(
|
|
150
139
|
parseInt(process.env.HTTP_API_PORT),
|
|
151
|
-
process.env.HTTP_API_KEY || crypto.randomUUID()
|
|
140
|
+
process.env.HTTP_API_KEY || crypto.randomUUID(),
|
|
141
|
+
trace,
|
|
142
|
+
queue,
|
|
143
|
+
sessions
|
|
152
144
|
);
|
|
153
145
|
eventAdapters.push(httpAdapter);
|
|
154
146
|
}
|
|
@@ -166,646 +158,64 @@ if (process.env.CLI_MODE === "true" || (adapters.length === 0 && eventAdapters.l
|
|
|
166
158
|
adapters.push(new CliAdapter(cliUserId));
|
|
167
159
|
}
|
|
168
160
|
|
|
169
|
-
// Platform-specific formatting hints for Claude output
|
|
170
|
-
const PLATFORM_FORMAT_HINTS: Record<string, string> = {
|
|
171
|
-
telegram: "Format output for Telegram: use *bold* for emphasis, `code` for inline code, ```code blocks```. No markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
|
|
172
|
-
slack: "Format output for Slack: use *bold*, no markdown tables. Use simple bulleted lists with • instead. Keep it concise.",
|
|
173
|
-
discord: "Format output for Discord: use **bold**, no wide tables. Use simple bulleted lists. Keep under 2000 chars.",
|
|
174
|
-
whatsapp: "Format output for WhatsApp: use *bold*, no markdown tables or code blocks. Use simple bulleted lists with • instead.",
|
|
175
|
-
cli: "Format output using markdown. Tables are fine.",
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
// Platform-specific message size limits
|
|
179
|
-
const MESSAGE_LIMITS: Record<string, number> = {
|
|
180
|
-
slack: 3900,
|
|
181
|
-
whatsapp: 60000,
|
|
182
|
-
cli: Infinity,
|
|
183
|
-
telegram: 4096,
|
|
184
|
-
discord: 2000,
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
function splitAndReply(text: string, platform: string): string[] {
|
|
188
|
-
const limit = MESSAGE_LIMITS[platform] || 3900;
|
|
189
|
-
if (text.length <= limit) return [text];
|
|
190
|
-
const parts: string[] = [];
|
|
191
|
-
let remaining = text;
|
|
192
|
-
while (remaining.length > 0) {
|
|
193
|
-
if (remaining.length <= limit) {
|
|
194
|
-
parts.push(remaining);
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
// Try to split at a newline near the limit
|
|
198
|
-
let splitAt = remaining.lastIndexOf("\n", limit);
|
|
199
|
-
if (splitAt < limit * 0.5) splitAt = limit;
|
|
200
|
-
parts.push(remaining.slice(0, splitAt));
|
|
201
|
-
remaining = remaining.slice(splitAt).replace(/^\n/, "");
|
|
202
|
-
}
|
|
203
|
-
return parts;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function formatStatusLog(log: string[]): string {
|
|
207
|
-
return log.slice(-10).map((l) => `> ${l}`).join("\n");
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
async function handleMessage(msg: IncomingMessage) {
|
|
211
|
-
// Store user message in session
|
|
212
|
-
sessions.addMessage(msg.userId, "user", msg.text);
|
|
213
|
-
|
|
214
|
-
const parsed = parseMessage(msg.text);
|
|
215
|
-
|
|
216
|
-
// Handle clear/reset command
|
|
217
|
-
if (parsed.type === "clear") {
|
|
218
|
-
sessions.clear(msg.userId);
|
|
219
|
-
await msg.reply("Conversation cleared.");
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Handle non-task commands
|
|
224
|
-
if (parsed.type === "status") {
|
|
225
|
-
const userTasks = queue.listByUser(msg.userId, 5);
|
|
226
|
-
const running = userTasks.find((t) => t.status === "running");
|
|
227
|
-
|
|
228
|
-
let reply: string;
|
|
229
|
-
if (running) {
|
|
230
|
-
const elapsed = Math.round((Date.now() - new Date(running.createdAt).getTime()) / 1000);
|
|
231
|
-
const min = Math.floor(elapsed / 60);
|
|
232
|
-
const sec = elapsed % 60;
|
|
233
|
-
const duration = min > 0 ? `${min}m ${sec}s` : `${sec}s`;
|
|
234
|
-
reply = `Working on ${running.repo} (${duration})...`;
|
|
235
|
-
} else {
|
|
236
|
-
const lastDone = userTasks.find((t) => t.status === "completed");
|
|
237
|
-
if (lastDone) {
|
|
238
|
-
reply = `Nothing running. Last task on ${lastDone.repo} completed.`;
|
|
239
|
-
} else {
|
|
240
|
-
const stats = queue.stats();
|
|
241
|
-
reply = `${stats.pending} pending, ${stats.running} running, ${stats.completed} done, ${stats.failed} failed.`;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
await msg.reply(reply);
|
|
245
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (parsed.type === "history") {
|
|
250
|
-
const tasks = queue.listByUser(msg.userId, 5);
|
|
251
|
-
if (tasks.length === 0) {
|
|
252
|
-
await msg.reply("No recent tasks.");
|
|
253
|
-
sessions.addMessage(msg.userId, "assistant", "No recent tasks.");
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
const lines = tasks.map(
|
|
257
|
-
(t) => `• [${t.status}] ${t.prompt.slice(0, 80)} (${t.repo})`
|
|
258
|
-
);
|
|
259
|
-
const reply = `Recent tasks:\n${lines.join("\n")}`;
|
|
260
|
-
await msg.reply(reply);
|
|
261
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (parsed.type === "help") {
|
|
266
|
-
const reply = [
|
|
267
|
-
"Available commands:",
|
|
268
|
-
"• review PR #N on <repo> — I'll find every problem",
|
|
269
|
-
"• fix issue #N on <repo> — I'll fix it properly",
|
|
270
|
-
"• simplify <path> in <repo> — clean up your mess",
|
|
271
|
-
"• validate <repo> — run tests, unlike some people",
|
|
272
|
-
"• discuss <topic> — I'll brainstorm, but no promises I'll be nice",
|
|
273
|
-
"• create project <name> [with template <type>]",
|
|
274
|
-
"• init repo <name> <git-url> [branch] — set up a repo from chat",
|
|
275
|
-
"• tasks — see running and pending tasks",
|
|
276
|
-
"• cancel <id> — kill a running or pending task",
|
|
277
|
-
"• status / history / clear",
|
|
278
|
-
"• <task> every day/weekday at <time> [on <repo>] — schedule a recurring task",
|
|
279
|
-
"• list schedules — see your scheduled tasks",
|
|
280
|
-
"• remove schedule #N — remove a scheduled task",
|
|
281
|
-
"• Or just ask me whatever. I'll figure it out.",
|
|
282
|
-
].join("\n");
|
|
283
|
-
await msg.reply(reply);
|
|
284
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// List all running + pending tasks
|
|
289
|
-
if (parsed.type === "list-tasks") {
|
|
290
|
-
const tasks = queue.listActive();
|
|
291
|
-
if (tasks.length === 0) {
|
|
292
|
-
const reply = "Nothing running, nothing pending. Quiet. I like it.";
|
|
293
|
-
await msg.reply(reply);
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const running = tasks.filter((t) => t.status === "running");
|
|
297
|
-
const pending = tasks.filter((t) => t.status === "pending");
|
|
298
|
-
const lines: string[] = [];
|
|
299
|
-
if (running.length > 0) {
|
|
300
|
-
lines.push("Running:");
|
|
301
|
-
for (const t of running) {
|
|
302
|
-
const elapsed = Math.round((Date.now() - new Date(t.createdAt).getTime()) / 1000);
|
|
303
|
-
const min = Math.floor(elapsed / 60);
|
|
304
|
-
const sec = elapsed % 60;
|
|
305
|
-
const duration = min > 0 ? `${min}m ${sec}s` : `${sec}s`;
|
|
306
|
-
lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${duration})`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (pending.length > 0) {
|
|
310
|
-
lines.push("Pending:");
|
|
311
|
-
for (const t of pending) {
|
|
312
|
-
const busyRepo = running.some((r) => r.repo === t.repo);
|
|
313
|
-
const reason = busyRepo ? `waiting — ${t.repo} busy` : "waiting";
|
|
314
|
-
lines.push(` ${t.id.slice(0, 7)} — "${t.prompt.slice(0, 60)}" on ${t.repo} (${reason})`);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
const reply = lines.join("\n");
|
|
318
|
-
await msg.reply(reply);
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Cancel a running task
|
|
323
|
-
if (parsed.type === "cancel-task") {
|
|
324
|
-
const prefix = parsed.args.taskId.toLowerCase();
|
|
325
|
-
// Find matching running process by ID prefix
|
|
326
|
-
let match: { abort: AbortController; task: import("./queue").Task } | undefined;
|
|
327
|
-
for (const [id, entry] of runningProcesses) {
|
|
328
|
-
if (id.toLowerCase().startsWith(prefix)) {
|
|
329
|
-
match = entry;
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
if (!match) {
|
|
334
|
-
// Maybe it's a pending task — cancel from queue directly
|
|
335
|
-
const active = queue.listActive();
|
|
336
|
-
const pendingMatch = active.find((t) => t.id.toLowerCase().startsWith(prefix) && t.status === "pending");
|
|
337
|
-
if (pendingMatch) {
|
|
338
|
-
queue.cancel(pendingMatch.id);
|
|
339
|
-
await msg.reply(`Cancelled pending task ${pendingMatch.id.slice(0, 7)} on ${pendingMatch.repo}.`);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
await msg.reply(`No task found matching "${prefix}". Use /tasks to see what's running.`);
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
match.abort.abort();
|
|
346
|
-
queue.cancel(match.task.id);
|
|
347
|
-
await msg.reply(`Killed task ${match.task.id.slice(0, 7)} on ${match.task.repo}. Gone.`);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// List schedules
|
|
352
|
-
if (parsed.type === "list-schedules") {
|
|
353
|
-
const userSchedules = schedules.listByUser(msg.userId);
|
|
354
|
-
if (userSchedules.length === 0) {
|
|
355
|
-
const reply = "No schedules. You haven't asked me to do anything on a timer yet.";
|
|
356
|
-
await msg.reply(reply);
|
|
357
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
const lines = userSchedules.map(
|
|
361
|
-
(s) => `#${s.id} — ${s.prompt} on ${s.repo} — ${s.description || s.schedule}`
|
|
362
|
-
);
|
|
363
|
-
const reply = `Your schedules:\n${lines.join("\n")}`;
|
|
364
|
-
await msg.reply(reply);
|
|
365
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Remove schedule
|
|
370
|
-
if (parsed.type === "remove-schedule") {
|
|
371
|
-
const id = parsed.args.scheduleId;
|
|
372
|
-
const removed = schedules.remove(msg.userId, id);
|
|
373
|
-
const reply = removed
|
|
374
|
-
? `Schedule #${id} removed. One less thing for me to do.`
|
|
375
|
-
: `Schedule #${id} not found or not yours. I don't delete other people's things.`;
|
|
376
|
-
await msg.reply(reply);
|
|
377
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Create schedule
|
|
382
|
-
if (parsed.type === "schedule") {
|
|
383
|
-
await msg.updateStatus("Parsing your schedule...");
|
|
384
|
-
const rawRepos = getUserRepos(config, msg.userId);
|
|
385
|
-
const userRepos = rawRepos.includes("*") ? repoRegistry.getAllNames() : rawRepos;
|
|
386
|
-
|
|
387
|
-
if (userRepos.length === 0) {
|
|
388
|
-
await msg.reply("You don't have access to any repos. Set one up first with `init repo <name> <git-url>`.");
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const result = await parseSchedule(msg.text, userRepos);
|
|
393
|
-
|
|
394
|
-
if (!result) {
|
|
395
|
-
await msg.reply("Couldn't figure out that schedule. Try something like: 'lint and check every day at 9 on my-app'");
|
|
396
|
-
sessions.addMessage(msg.userId, "assistant", "Failed to parse schedule.");
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Resolve repo
|
|
401
|
-
let repo = result.repo;
|
|
402
|
-
if (!repo || !userRepos.includes(repo)) {
|
|
403
|
-
if (parsed.repo && userRepos.includes(parsed.repo)) {
|
|
404
|
-
repo = parsed.repo;
|
|
405
|
-
} else if (userRepos.length === 1) {
|
|
406
|
-
repo = userRepos[0];
|
|
407
|
-
} else {
|
|
408
|
-
const reply = `Which repo? You have: ${userRepos.join(", ")}. Say it again with 'on <repo>'.`;
|
|
409
|
-
await msg.reply(reply);
|
|
410
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const id = schedules.create({
|
|
416
|
-
userId: msg.userId,
|
|
417
|
-
repo,
|
|
418
|
-
prompt: result.prompt,
|
|
419
|
-
schedule: result.schedule,
|
|
420
|
-
description: result.description,
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
const reply = `Schedule #${id} created: "${result.prompt}" on ${repo} ${result.description}.`;
|
|
424
|
-
await msg.reply(reply);
|
|
425
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Discuss runs inline — no queue, no worktree
|
|
430
|
-
if (parsed.type === "discuss") {
|
|
431
|
-
const history = sessions.getHistory(msg.userId, 6);
|
|
432
|
-
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
433
|
-
|
|
434
|
-
await msg.updateStatus("Thinking...");
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
const discussRunner = getRunner(config.runner?.name);
|
|
438
|
-
const result = await discussRunner.run(
|
|
439
|
-
prompt,
|
|
440
|
-
config.reposDir,
|
|
441
|
-
{ maxTurns: 5 },
|
|
442
|
-
(event) => {
|
|
443
|
-
if (event.kind === "text") {
|
|
444
|
-
msg.updateStatus(event.text.slice(0, 200));
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
const parts = splitAndReply(result.output, msg.platform);
|
|
450
|
-
for (const part of parts) {
|
|
451
|
-
await msg.reply(part);
|
|
452
|
-
}
|
|
453
|
-
sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
|
|
454
|
-
} catch (err) {
|
|
455
|
-
await msg.reply(`Discussion error: ${String(err).slice(0, 500)}`);
|
|
456
|
-
}
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Create-project doesn't need an existing repo
|
|
461
|
-
if (parsed.type === "create-project") {
|
|
462
|
-
const projectName = parsed.args.name;
|
|
463
|
-
const history = sessions.getHistory(msg.userId, 6);
|
|
464
|
-
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
465
|
-
|
|
466
|
-
const taskId = queue.enqueue({
|
|
467
|
-
userId: msg.userId,
|
|
468
|
-
repo: projectName,
|
|
469
|
-
prompt,
|
|
470
|
-
taskType: "create-project",
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
pendingReplies.set(taskId, msg);
|
|
474
|
-
await msg.reply(`Creating "${projectName}"...`);
|
|
475
|
-
logger.info("task enqueued", { taskId, type: "create-project", name: projectName });
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Init repo — onboarding a new repo from chat
|
|
480
|
-
if (parsed.type === "init-repo") {
|
|
481
|
-
const { name, url, branch } = parsed.args;
|
|
482
|
-
|
|
483
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
484
|
-
await msg.reply("Repo name must be alphanumeric, dashes, or underscores. Try again.");
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
if (config.repos[name]) {
|
|
489
|
-
// Repo exists — just grant access
|
|
490
|
-
addUser(config, msg.userId, msg.userId, [name]);
|
|
491
|
-
const reply = `Repo "${name}" already exists. I've added you to it. Go ahead.`;
|
|
492
|
-
await msg.reply(reply);
|
|
493
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
addRepo(config, name, url, branch);
|
|
498
|
-
addUser(config, msg.userId, msg.userId, [name]);
|
|
499
|
-
const reply = `Added repo "${name}" (${url}, branch: ${branch}).`;
|
|
500
|
-
await msg.reply(reply);
|
|
501
|
-
sessions.addMessage(msg.userId, "assistant", reply);
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// If the repo hint doesn't match a known repo, clear it and let auto-resolution handle it
|
|
506
|
-
if (parsed.repo && !getRepoInfo(parsed.repo)) {
|
|
507
|
-
parsed.repo = undefined;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Need a repo for task commands
|
|
511
|
-
if (!parsed.repo) {
|
|
512
|
-
const userRepos = getUserRepos(config, msg.userId);
|
|
513
|
-
const hasWildcard = userRepos.includes("*");
|
|
514
|
-
|
|
515
|
-
if (!hasWildcard && userRepos.length === 1) {
|
|
516
|
-
parsed.repo = userRepos[0];
|
|
517
|
-
} else if (hasWildcard || userRepos.length > 1) {
|
|
518
|
-
const repoNames = hasWildcard ? repoRegistry.getAllNames() : userRepos;
|
|
519
|
-
|
|
520
|
-
if (repoNames.length === 1) {
|
|
521
|
-
parsed.repo = repoNames[0];
|
|
522
|
-
} else if (repoNames.length === 0) {
|
|
523
|
-
const reply = "No repos discovered yet. Set one up with `init repo <name> <git-url>` or configure GitHub sync.";
|
|
524
|
-
await msg.reply(reply);
|
|
525
|
-
return;
|
|
526
|
-
} else {
|
|
527
|
-
// Multiple repos — run inline (like discuss) with repo list context
|
|
528
|
-
// Claude answers from knowledge + gh CLI, no worktree needed
|
|
529
|
-
const repoList = repoNames.join(", ");
|
|
530
|
-
const history = sessions.getHistory(msg.userId, 6);
|
|
531
|
-
const formatHint = PLATFORM_FORMAT_HINTS[msg.platform] || PLATFORM_FORMAT_HINTS.slack;
|
|
532
|
-
const inlinePrompt = `${OVE_PERSONA}\n\nAvailable repos: ${repoList}\n\nThe user has access to ${repoNames.length} repos. Based on their message, determine which repo(s) they mean and answer their question fully. Use \`gh\` CLI to query GitHub (e.g. \`gh pr list --repo owner/repo\`, \`gh issue list --repo owner/repo\`). Do NOT stop after identifying the repo — complete the actual task.\n\n${formatHint}\n\n${parsed.rawText}`;
|
|
533
|
-
|
|
534
|
-
await msg.updateStatus("Working...");
|
|
535
|
-
try {
|
|
536
|
-
const runner = getRunner(config.runner?.name);
|
|
537
|
-
const result = await runner.run(inlinePrompt, config.reposDir, { maxTurns: 10 }, (event) => {
|
|
538
|
-
// Only show tool usage as status — don't relay response text (causes duplicates)
|
|
539
|
-
if (event.kind === "tool") msg.updateStatus(`Using ${event.tool}...`);
|
|
540
|
-
});
|
|
541
|
-
const parts = splitAndReply(result.output, msg.platform);
|
|
542
|
-
for (const part of parts) await msg.reply(part);
|
|
543
|
-
sessions.addMessage(msg.userId, "assistant", result.output.slice(0, 500));
|
|
544
|
-
} catch (err) {
|
|
545
|
-
await msg.reply(`Error: ${String(err).slice(0, 500)}`);
|
|
546
|
-
}
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
} else {
|
|
550
|
-
const reply = "You don't have access to any repos yet. Set one up:\n`init repo <name> <git-url> [branch]`\nExample: `init repo my-app git@github.com:user/my-app.git`";
|
|
551
|
-
await msg.reply(reply);
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// Auth check
|
|
557
|
-
if (!isAuthorized(config, msg.userId, parsed.repo)) {
|
|
558
|
-
await msg.reply(`Not authorized for ${parsed.repo}.`);
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Check repo exists — config overrides or registry
|
|
563
|
-
const repoInfo = getRepoInfo(parsed.repo);
|
|
564
|
-
if (!repoInfo) {
|
|
565
|
-
await msg.reply(`Unknown repo: ${parsed.repo}`);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Build prompt with conversation context
|
|
570
|
-
const history = sessions.getHistory(msg.userId, 6);
|
|
571
|
-
const prompt = buildContextualPrompt(parsed, history, OVE_PERSONA);
|
|
572
|
-
|
|
573
|
-
// Enqueue the task
|
|
574
|
-
const taskId = queue.enqueue({
|
|
575
|
-
userId: msg.userId,
|
|
576
|
-
repo: parsed.repo,
|
|
577
|
-
prompt,
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
// Store reply callback for later
|
|
581
|
-
pendingReplies.set(taskId, msg);
|
|
582
|
-
|
|
583
|
-
// Only ack if there's a queue backlog for this repo
|
|
584
|
-
const stats = queue.stats();
|
|
585
|
-
if (stats.running > 0 || stats.pending > 1) {
|
|
586
|
-
await msg.reply(`Queued — ${stats.pending} task${stats.pending > 1 ? "s" : ""} ahead.`);
|
|
587
|
-
}
|
|
588
|
-
logger.info("task enqueued", { taskId, repo: parsed.repo, type: parsed.type });
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// Pending event responses — stores taskId → adapter for responding
|
|
592
|
-
const pendingEventReplies = new Map<string, { adapter: EventAdapter; event: IncomingEvent }>();
|
|
593
|
-
|
|
594
|
-
async function handleEvent(event: IncomingEvent, adapter: EventAdapter) {
|
|
595
|
-
const parsed = parseMessage(event.text);
|
|
596
|
-
|
|
597
|
-
if (!parsed.repo) {
|
|
598
|
-
const userRepos = getUserRepos(config, event.userId);
|
|
599
|
-
if (userRepos.length === 1) {
|
|
600
|
-
parsed.repo = userRepos[0];
|
|
601
|
-
} else if ("repo" in event.source && event.source.repo) {
|
|
602
|
-
const shortName = event.source.repo.split("/").pop() || event.source.repo;
|
|
603
|
-
if (isAuthorized(config, event.userId, shortName)) {
|
|
604
|
-
parsed.repo = shortName;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
if (!parsed.repo) {
|
|
610
|
-
await adapter.respondToEvent(event.eventId, "Couldn't determine which repo. Configure your user in config.json.");
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (!isAuthorized(config, event.userId, parsed.repo)) {
|
|
615
|
-
await adapter.respondToEvent(event.eventId, `Not authorized for ${parsed.repo}.`);
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
const repoInfo = getRepoInfo(parsed.repo);
|
|
620
|
-
if (!repoInfo) {
|
|
621
|
-
await adapter.respondToEvent(event.eventId, `Unknown repo: ${parsed.repo}.`);
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const prompt = buildContextualPrompt(parsed, [], OVE_PERSONA);
|
|
626
|
-
const taskId = queue.enqueue({
|
|
627
|
-
userId: event.userId,
|
|
628
|
-
repo: parsed.repo,
|
|
629
|
-
prompt,
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
pendingEventReplies.set(taskId, { adapter, event });
|
|
633
|
-
logger.info("event task enqueued", { taskId, eventId: event.eventId, repo: parsed.repo });
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
async function processTask(task: import("./queue").Task) {
|
|
637
|
-
const isCreateProject = task.taskType === "create-project";
|
|
638
|
-
const repoInfo = isCreateProject ? null : getRepoInfo(task.repo);
|
|
639
|
-
|
|
640
|
-
if (!isCreateProject && !repoInfo) {
|
|
641
|
-
queue.fail(task.id, `Unknown repo: ${task.repo}`);
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
const abortController = new AbortController();
|
|
646
|
-
runningProcesses.set(task.id, { abort: abortController, task });
|
|
647
|
-
|
|
648
|
-
const originalMsg = pendingReplies.get(task.id);
|
|
649
|
-
const statusLog: string[] = [];
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
// Status update
|
|
653
|
-
await originalMsg?.updateStatus(`Working on it...`);
|
|
654
|
-
|
|
655
|
-
let workDir: string;
|
|
656
|
-
|
|
657
|
-
if (isCreateProject) {
|
|
658
|
-
// Create project directory under reposDir
|
|
659
|
-
workDir = join(config.reposDir, task.repo);
|
|
660
|
-
await Bun.write(join(workDir, ".gitkeep"), "");
|
|
661
|
-
} else {
|
|
662
|
-
// Ensure repo is cloned and up to date
|
|
663
|
-
await repos.cloneIfNeeded(task.repo, repoInfo!.url);
|
|
664
|
-
await repos.pull(task.repo, repoInfo!.defaultBranch);
|
|
665
|
-
|
|
666
|
-
// Create worktree
|
|
667
|
-
workDir = await repos.createWorktree(
|
|
668
|
-
task.repo,
|
|
669
|
-
task.id,
|
|
670
|
-
repoInfo!.defaultBranch
|
|
671
|
-
);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
try {
|
|
675
|
-
// Write MCP config to temp file if configured
|
|
676
|
-
let mcpConfigPath: string | undefined;
|
|
677
|
-
if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
|
|
678
|
-
mcpConfigPath = join(tmpdir(), `mcp-${task.id}.json`);
|
|
679
|
-
await Bun.write(mcpConfigPath, JSON.stringify({ mcpServers: config.mcpServers }));
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
const taskRunner = getRunnerForRepo(task.repo);
|
|
683
|
-
const runOpts = getRunnerOptsForRepo(task.repo, {
|
|
684
|
-
maxTurns: config.claude.maxTurns,
|
|
685
|
-
mcpConfigPath,
|
|
686
|
-
signal: abortController.signal,
|
|
687
|
-
});
|
|
688
|
-
|
|
689
|
-
const result = await taskRunner.run(
|
|
690
|
-
task.prompt,
|
|
691
|
-
workDir,
|
|
692
|
-
runOpts,
|
|
693
|
-
(event: StatusEvent) => {
|
|
694
|
-
// Only surface meaningful status — skip noisy tool-by-tool updates
|
|
695
|
-
if (event.kind === "tool") {
|
|
696
|
-
// Summarize tool usage without raw details
|
|
697
|
-
const last = statusLog[statusLog.length - 1];
|
|
698
|
-
const summary = `Using ${event.tool}...`;
|
|
699
|
-
if (last !== summary) statusLog.push(summary);
|
|
700
|
-
} else {
|
|
701
|
-
statusLog.push(event.text.slice(0, 200));
|
|
702
|
-
}
|
|
703
|
-
// Show only last 5 lines to reduce noise
|
|
704
|
-
originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
|
|
705
|
-
}
|
|
706
|
-
);
|
|
707
|
-
|
|
708
|
-
// Clean up MCP temp file
|
|
709
|
-
if (mcpConfigPath) {
|
|
710
|
-
try {
|
|
711
|
-
await unlink(mcpConfigPath);
|
|
712
|
-
} catch {}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
if (result.success) {
|
|
716
|
-
queue.complete(task.id, result.output);
|
|
717
|
-
logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
|
|
718
|
-
|
|
719
|
-
// Reply to user — split long results across messages
|
|
720
|
-
const platform = originalMsg?.platform || "slack";
|
|
721
|
-
const parts = splitAndReply(result.output, platform);
|
|
722
|
-
for (const part of parts) {
|
|
723
|
-
await originalMsg?.reply(part);
|
|
724
|
-
}
|
|
725
|
-
sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
|
|
726
|
-
|
|
727
|
-
// Check if this was triggered by an event adapter
|
|
728
|
-
const eventReply = pendingEventReplies.get(task.id);
|
|
729
|
-
if (eventReply) {
|
|
730
|
-
await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
|
|
731
|
-
pendingEventReplies.delete(task.id);
|
|
732
|
-
}
|
|
733
|
-
} else {
|
|
734
|
-
queue.fail(task.id, result.output);
|
|
735
|
-
logger.error("task failed", { taskId: task.id });
|
|
736
|
-
await originalMsg?.reply(`Task failed: ${result.output.slice(0, 500)}`);
|
|
737
|
-
sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
|
|
738
|
-
|
|
739
|
-
// Notify event adapter of failure too
|
|
740
|
-
const eventReply = pendingEventReplies.get(task.id);
|
|
741
|
-
if (eventReply) {
|
|
742
|
-
await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
|
|
743
|
-
pendingEventReplies.delete(task.id);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
} finally {
|
|
747
|
-
// Only clean up worktree for non-create-project tasks
|
|
748
|
-
if (!isCreateProject) {
|
|
749
|
-
await repos.removeWorktree(task.repo, task.id).catch(() => {});
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
} catch (err) {
|
|
753
|
-
queue.fail(task.id, String(err));
|
|
754
|
-
logger.error("task processing error", { taskId: task.id, error: String(err) });
|
|
755
|
-
await originalMsg?.reply(`Task error: ${String(err).slice(0, 500)}`);
|
|
756
|
-
} finally {
|
|
757
|
-
runningProcesses.delete(task.id);
|
|
758
|
-
pendingReplies.delete(task.id);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
// Worker loop — runs up to maxConcurrent tasks in parallel, per-repo serialization via dequeue()
|
|
763
|
-
async function workerLoop() {
|
|
764
|
-
const maxConcurrent = 5;
|
|
765
|
-
|
|
766
|
-
while (true) {
|
|
767
|
-
if (runningProcesses.size < maxConcurrent) {
|
|
768
|
-
try {
|
|
769
|
-
const task = queue.dequeue();
|
|
770
|
-
if (task) {
|
|
771
|
-
processTask(task).catch((err) =>
|
|
772
|
-
logger.error("worker task error", { taskId: task.id, error: String(err) })
|
|
773
|
-
);
|
|
774
|
-
continue; // try to grab another immediately
|
|
775
|
-
}
|
|
776
|
-
} catch (err) {
|
|
777
|
-
logger.error("worker loop error", { error: String(err) });
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
await Bun.sleep(2000);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
161
|
// Main
|
|
785
162
|
async function main() {
|
|
786
|
-
//
|
|
163
|
+
// Capture stale tasks before resetting so we can notify users
|
|
164
|
+
const staleTasks = queue.listActive().filter((t) => t.status === "running");
|
|
787
165
|
const staleCount = queue.resetStale();
|
|
788
166
|
if (staleCount > 0) {
|
|
789
167
|
logger.info("reset stale tasks", { count: staleCount });
|
|
790
168
|
}
|
|
791
169
|
|
|
792
|
-
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude" });
|
|
170
|
+
logger.info("ove starting", { chatAdapters: adapters.length, eventAdapters: eventAdapters.length, runner: config.runner?.name || "claude", tracing: trace.isEnabled() });
|
|
793
171
|
|
|
794
|
-
// Start GitHub repo sync (non-blocking)
|
|
795
172
|
startGitHubSync().catch((err) =>
|
|
796
173
|
logger.warn("initial github sync failed", { error: String(err) })
|
|
797
174
|
);
|
|
798
175
|
|
|
176
|
+
const handleMessage = createMessageHandler({
|
|
177
|
+
config,
|
|
178
|
+
queue,
|
|
179
|
+
sessions,
|
|
180
|
+
schedules,
|
|
181
|
+
repoRegistry,
|
|
182
|
+
trace,
|
|
183
|
+
pendingReplies,
|
|
184
|
+
pendingEventReplies,
|
|
185
|
+
runningProcesses,
|
|
186
|
+
getRunner,
|
|
187
|
+
getRunnerForRepo,
|
|
188
|
+
getRepoInfo,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const handleEvent = createEventHandler({
|
|
192
|
+
config,
|
|
193
|
+
queue,
|
|
194
|
+
sessions,
|
|
195
|
+
schedules,
|
|
196
|
+
repoRegistry,
|
|
197
|
+
trace,
|
|
198
|
+
pendingReplies,
|
|
199
|
+
pendingEventReplies,
|
|
200
|
+
runningProcesses,
|
|
201
|
+
getRunner,
|
|
202
|
+
getRunnerForRepo,
|
|
203
|
+
getRepoInfo,
|
|
204
|
+
});
|
|
205
|
+
|
|
799
206
|
for (const adapter of adapters) {
|
|
800
207
|
await adapter.start(handleMessage);
|
|
801
208
|
}
|
|
802
209
|
|
|
803
|
-
// Start event adapters
|
|
804
210
|
for (const ea of eventAdapters) {
|
|
211
|
+
// Wire up chat handler for HTTP adapter so web UI gets full chat features
|
|
212
|
+
if (ea instanceof HttpApiAdapter) {
|
|
213
|
+
ea.setMessageHandler(handleMessage);
|
|
214
|
+
ea.setAdapters(adapters, eventAdapters);
|
|
215
|
+
}
|
|
805
216
|
await ea.start((event) => handleEvent(event, ea));
|
|
806
217
|
}
|
|
807
218
|
|
|
808
|
-
// Start cron loop — checks both config-based and user-created schedules
|
|
809
219
|
const configCron = config.cron || [];
|
|
810
220
|
startCronLoop(
|
|
811
221
|
() => [
|
|
@@ -822,17 +232,43 @@ async function main() {
|
|
|
822
232
|
userId: cronTask.userId,
|
|
823
233
|
repo: cronTask.repo,
|
|
824
234
|
prompt: buildCronPrompt(cronTask.prompt),
|
|
235
|
+
taskType: "cron",
|
|
825
236
|
});
|
|
826
237
|
}
|
|
827
238
|
);
|
|
828
239
|
logger.info("cron started", { configTasks: configCron.length });
|
|
829
240
|
|
|
830
|
-
|
|
831
|
-
|
|
241
|
+
const worker = createWorker({
|
|
242
|
+
config,
|
|
243
|
+
queue,
|
|
244
|
+
repos,
|
|
245
|
+
sessions,
|
|
246
|
+
adapters,
|
|
247
|
+
pendingReplies,
|
|
248
|
+
pendingEventReplies,
|
|
249
|
+
runningProcesses,
|
|
250
|
+
getRunnerForRepo,
|
|
251
|
+
getRunnerOptsForRepo,
|
|
252
|
+
getRepoInfo,
|
|
253
|
+
trace,
|
|
254
|
+
});
|
|
255
|
+
worker.start();
|
|
256
|
+
|
|
257
|
+
// Notify users whose tasks were interrupted by restart
|
|
258
|
+
if (staleTasks.length > 0) {
|
|
259
|
+
for (const task of staleTasks) {
|
|
260
|
+
const platform = task.userId.split(":")[0];
|
|
261
|
+
const adapter = adapters.find((a) => a.constructor.name.toLowerCase().includes(platform));
|
|
262
|
+
if (adapter?.sendToUser) {
|
|
263
|
+
adapter.sendToUser(task.userId, `Your task was interrupted by a restart: "${task.prompt.slice(0, 100)}". Please re-submit if needed.`).catch((err) =>
|
|
264
|
+
logger.warn("failed to notify user of interrupted task", { userId: task.userId, error: String(err) })
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
832
269
|
|
|
833
270
|
logger.info("ove ready");
|
|
834
271
|
|
|
835
|
-
// Graceful shutdown
|
|
836
272
|
async function shutdown() {
|
|
837
273
|
logger.info("shutting down...");
|
|
838
274
|
for (const adapter of adapters) {
|