@lovenyberg/ove 0.3.0 → 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 +36 -4
- package/src/adapters/discord.ts +17 -2
- package/src/adapters/github.ts +26 -1
- package/src/adapters/http.test.ts +7 -1
- package/src/adapters/http.ts +220 -48
- package/src/adapters/slack.ts +17 -2
- package/src/adapters/telegram.ts +21 -9
- package/src/adapters/types.ts +11 -0
- package/src/adapters/whatsapp.ts +40 -2
- package/src/flows.test.ts +126 -0
- package/src/handlers.ts +76 -17
- package/src/index.ts +35 -2
- package/src/queue.ts +22 -0
- package/src/router.ts +8 -2
- package/src/runners/claude.ts +24 -5
- package/src/runners/codex.ts +20 -1
- package/src/trace.ts +71 -0
- package/src/worker.ts +68 -6
package/src/router.ts
CHANGED
|
@@ -15,7 +15,8 @@ export type MessageType =
|
|
|
15
15
|
| "list-schedules"
|
|
16
16
|
| "remove-schedule"
|
|
17
17
|
| "list-tasks"
|
|
18
|
-
| "cancel-task"
|
|
18
|
+
| "cancel-task"
|
|
19
|
+
| "trace";
|
|
19
20
|
|
|
20
21
|
export interface ParsedMessage {
|
|
21
22
|
type: MessageType;
|
|
@@ -45,6 +46,9 @@ export function parseMessage(text: string): ParsedMessage {
|
|
|
45
46
|
const cancelMatch = trimmed.match(/^(?:\/)?cancel\s+(\S+)$/i);
|
|
46
47
|
if (cancelMatch) return { type: "cancel-task", args: { taskId: cancelMatch[1] }, rawText: trimmed };
|
|
47
48
|
|
|
49
|
+
const traceMatch = trimmed.match(/^(?:\/)?trace(?:\s+(\S+))?$/i);
|
|
50
|
+
if (traceMatch) return { type: "trace", args: { taskId: traceMatch[1] }, rawText: trimmed };
|
|
51
|
+
|
|
48
52
|
// Natural language status inquiries — short messages asking about progress
|
|
49
53
|
if (isStatusInquiry(lower)) return { type: "status", args: {}, rawText: trimmed };
|
|
50
54
|
|
|
@@ -142,6 +146,8 @@ export function buildCronPrompt(prompt: string): string {
|
|
|
142
146
|
return `This is an autonomous scheduled task. Do not ask questions — make your own decisions and proceed with the work. If there are multiple options, pick the best one and go.\n\n${prompt}`;
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
const CHAT_PIPELINE_HINT = "You are running in a chat pipeline — your text output is sent to the user via a messaging app. Do NOT use AskUserQuestion or other interactive CLI tools (they don't work here). If you need to ask the user something, include the question and numbered options directly in your text response — the user will reply in chat and you'll see their answer in the next message.";
|
|
150
|
+
|
|
145
151
|
export function buildContextualPrompt(
|
|
146
152
|
parsed: ParsedMessage,
|
|
147
153
|
history: { role: string; content: string }[],
|
|
@@ -152,7 +158,7 @@ export function buildContextualPrompt(
|
|
|
152
158
|
history.slice(0, -1).map((m) => `${m.role}: ${m.content}`).join("\n") +
|
|
153
159
|
"\n\nCurrent request:\n"
|
|
154
160
|
: "";
|
|
155
|
-
return persona + "\n\n" + contextPrefix + buildPrompt(parsed);
|
|
161
|
+
return persona + "\n\n" + CHAT_PIPELINE_HINT + "\n\n" + contextPrefix + buildPrompt(parsed);
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
export function buildPrompt(parsed: ParsedMessage): string {
|
package/src/runners/claude.ts
CHANGED
|
@@ -3,6 +3,20 @@ import { logger } from "../logger";
|
|
|
3
3
|
import { which } from "bun";
|
|
4
4
|
import { realpathSync } from "node:fs";
|
|
5
5
|
|
|
6
|
+
const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
7
|
+
|
|
8
|
+
function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
|
|
9
|
+
return Promise.race([
|
|
10
|
+
proc.exited,
|
|
11
|
+
new Promise<"timeout">((resolve) =>
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
proc.kill();
|
|
14
|
+
resolve("timeout");
|
|
15
|
+
}, TIMEOUT_MS)
|
|
16
|
+
),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export function summarizeToolInput(name: string, input: any): string {
|
|
7
21
|
if (!input) return "";
|
|
8
22
|
switch (name) {
|
|
@@ -36,7 +50,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
36
50
|
|
|
37
51
|
buildArgs(prompt: string, opts: RunOptions): string[] {
|
|
38
52
|
// stream-json requires --verbose in claude CLI
|
|
39
|
-
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions"];
|
|
53
|
+
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--max-turns", String(opts.maxTurns), "--dangerously-skip-permissions", "--disallowed-tools", "AskUserQuestion"];
|
|
40
54
|
if (opts.mcpConfigPath) args.push("--mcp-config", opts.mcpConfigPath);
|
|
41
55
|
return args;
|
|
42
56
|
}
|
|
@@ -58,7 +72,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
let resultText: string | null = null;
|
|
61
|
-
|
|
75
|
+
const textBlocks: string[] = [];
|
|
62
76
|
const decoder = new TextDecoder();
|
|
63
77
|
const reader = proc.stdout.getReader();
|
|
64
78
|
try {
|
|
@@ -76,7 +90,7 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
76
90
|
if (msg.type === "assistant" && msg.message?.content) {
|
|
77
91
|
for (const block of msg.message.content) {
|
|
78
92
|
if (block.type === "text") {
|
|
79
|
-
|
|
93
|
+
textBlocks.push(block.text);
|
|
80
94
|
if (onStatus) onStatus({ kind: "text", text: block.text });
|
|
81
95
|
}
|
|
82
96
|
if (block.type === "tool_use" && onStatus) {
|
|
@@ -97,16 +111,21 @@ export class ClaudeRunner implements AgentRunner {
|
|
|
97
111
|
reader.releaseLock();
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
const exitCode = await proc
|
|
114
|
+
const exitCode = await withTimeout(proc);
|
|
101
115
|
const durationMs = Date.now() - startTime;
|
|
102
116
|
|
|
117
|
+
if (exitCode === "timeout") {
|
|
118
|
+
logger.error("claude task timed out", { durationMs });
|
|
119
|
+
return { success: false, output: `Claude task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
|
|
120
|
+
}
|
|
121
|
+
|
|
103
122
|
if (exitCode !== 0) {
|
|
104
123
|
const stderr = await new Response(proc.stderr).text();
|
|
105
124
|
logger.error("claude task failed", { exitCode, stderr, durationMs });
|
|
106
125
|
return { success: false, output: stderr || "Claude task failed", durationMs };
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
const finalOutput = resultText ||
|
|
128
|
+
const finalOutput = resultText || textBlocks.join("\n\n") || "Task completed (no output)";
|
|
110
129
|
logger.info("claude task completed", { durationMs });
|
|
111
130
|
return { success: true, output: finalOutput, durationMs };
|
|
112
131
|
}
|
package/src/runners/codex.ts
CHANGED
|
@@ -8,6 +8,20 @@ import { logger } from "../logger";
|
|
|
8
8
|
import { which } from "bun";
|
|
9
9
|
import { realpathSync } from "node:fs";
|
|
10
10
|
|
|
11
|
+
const TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
12
|
+
|
|
13
|
+
function withTimeout(proc: { exited: Promise<number>; kill(): void }): Promise<number | "timeout"> {
|
|
14
|
+
return Promise.race([
|
|
15
|
+
proc.exited,
|
|
16
|
+
new Promise<"timeout">((resolve) =>
|
|
17
|
+
setTimeout(() => {
|
|
18
|
+
proc.kill();
|
|
19
|
+
resolve("timeout");
|
|
20
|
+
}, TIMEOUT_MS)
|
|
21
|
+
),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export function summarizeCodexItem(
|
|
12
26
|
item: any
|
|
13
27
|
): { tool: string; input: string } | null {
|
|
@@ -125,9 +139,14 @@ export class CodexRunner implements AgentRunner {
|
|
|
125
139
|
reader.releaseLock();
|
|
126
140
|
}
|
|
127
141
|
|
|
128
|
-
const exitCode = await proc
|
|
142
|
+
const exitCode = await withTimeout(proc);
|
|
129
143
|
const durationMs = Date.now() - startTime;
|
|
130
144
|
|
|
145
|
+
if (exitCode === "timeout") {
|
|
146
|
+
logger.error("codex task timed out", { durationMs });
|
|
147
|
+
return { success: false, output: `Codex task timed out after ${TIMEOUT_MS / 60000} minutes`, durationMs };
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
if (exitCode !== 0) {
|
|
132
151
|
const stderr = await new Response(proc.stderr).text();
|
|
133
152
|
const output = errorMessage || stderr || "Codex task failed";
|
package/src/trace.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export interface TraceEvent {
|
|
4
|
+
id: number;
|
|
5
|
+
taskId: string;
|
|
6
|
+
ts: string;
|
|
7
|
+
kind: "status" | "tool" | "lifecycle" | "output" | "error";
|
|
8
|
+
summary: string;
|
|
9
|
+
detail: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TraceRow {
|
|
13
|
+
id: number;
|
|
14
|
+
task_id: string;
|
|
15
|
+
ts: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
detail: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TraceStore {
|
|
22
|
+
private db: Database;
|
|
23
|
+
private enabled: boolean;
|
|
24
|
+
|
|
25
|
+
constructor(db: Database) {
|
|
26
|
+
this.db = db;
|
|
27
|
+
this.enabled = process.env.OVE_TRACE === "true";
|
|
28
|
+
this.db.run(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS task_traces (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
task_id TEXT NOT NULL,
|
|
32
|
+
ts TEXT NOT NULL,
|
|
33
|
+
kind TEXT NOT NULL,
|
|
34
|
+
summary TEXT NOT NULL,
|
|
35
|
+
detail TEXT
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_trace_task_id ON task_traces(task_id)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
isEnabled(): boolean {
|
|
42
|
+
return this.enabled;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
append(taskId: string, kind: TraceEvent["kind"], summary: string, detail?: string) {
|
|
46
|
+
if (!this.enabled) return;
|
|
47
|
+
this.db.run(
|
|
48
|
+
`INSERT INTO task_traces (task_id, ts, kind, summary, detail) VALUES (?, ?, ?, ?, ?)`,
|
|
49
|
+
[taskId, new Date().toISOString(), kind, summary, detail ?? null]
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
getByTask(taskId: string, limit: number = 100): TraceEvent[] {
|
|
54
|
+
const rows = this.db
|
|
55
|
+
.query(`SELECT * FROM task_traces WHERE task_id = ? ORDER BY id ASC LIMIT ?`)
|
|
56
|
+
.all(taskId, limit) as TraceRow[];
|
|
57
|
+
return rows.map((r) => ({
|
|
58
|
+
id: r.id,
|
|
59
|
+
taskId: r.task_id,
|
|
60
|
+
ts: r.ts,
|
|
61
|
+
kind: r.kind as TraceEvent["kind"],
|
|
62
|
+
summary: r.summary,
|
|
63
|
+
detail: r.detail,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
cleanup(olderThanDays: number = 7) {
|
|
68
|
+
const cutoff = new Date(Date.now() - olderThanDays * 86_400_000).toISOString();
|
|
69
|
+
this.db.run(`DELETE FROM task_traces WHERE ts < ?`, [cutoff]);
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -7,20 +7,53 @@ import type { Config } from "./config";
|
|
|
7
7
|
import type { TaskQueue, Task } from "./queue";
|
|
8
8
|
import type { RepoManager } from "./repos";
|
|
9
9
|
import type { SessionStore } from "./sessions";
|
|
10
|
-
import type { IncomingMessage, EventAdapter, IncomingEvent } from "./adapters/types";
|
|
10
|
+
import type { IncomingMessage, ChatAdapter, EventAdapter, IncomingEvent } from "./adapters/types";
|
|
11
11
|
import type { AgentRunner, RunOptions, StatusEvent } from "./runner";
|
|
12
|
+
import type { TraceStore } from "./trace";
|
|
13
|
+
import type { DebouncedFunction } from "./adapters/debounce";
|
|
12
14
|
|
|
13
15
|
export interface WorkerDeps {
|
|
14
16
|
config: Config;
|
|
15
17
|
queue: TaskQueue;
|
|
16
18
|
repos: RepoManager;
|
|
17
19
|
sessions: SessionStore;
|
|
20
|
+
adapters: ChatAdapter[];
|
|
18
21
|
pendingReplies: Map<string, IncomingMessage>;
|
|
19
22
|
pendingEventReplies: Map<string, { adapter: EventAdapter; event: IncomingEvent }>;
|
|
20
23
|
runningProcesses: Map<string, { abort: AbortController; task: Task }>;
|
|
21
24
|
getRunnerForRepo: (repo: string) => AgentRunner;
|
|
22
25
|
getRunnerOptsForRepo: (repo: string, baseOpts: RunOptions) => RunOptions;
|
|
23
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
|
+
}
|
|
24
57
|
}
|
|
25
58
|
|
|
26
59
|
async function processTask(task: Task, deps: WorkerDeps) {
|
|
@@ -37,6 +70,10 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
37
70
|
|
|
38
71
|
const originalMsg = deps.pendingReplies.get(task.id);
|
|
39
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);
|
|
40
77
|
|
|
41
78
|
try {
|
|
42
79
|
await originalMsg?.updateStatus(`Working on it...`);
|
|
@@ -65,8 +102,11 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
65
102
|
}
|
|
66
103
|
|
|
67
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;
|
|
68
108
|
const runOpts = deps.getRunnerOptsForRepo(task.repo, {
|
|
69
|
-
maxTurns
|
|
109
|
+
maxTurns,
|
|
70
110
|
mcpConfigPath,
|
|
71
111
|
signal: abortController.signal,
|
|
72
112
|
});
|
|
@@ -80,8 +120,10 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
80
120
|
const last = statusLog[statusLog.length - 1];
|
|
81
121
|
const summary = `Using ${event.tool}...`;
|
|
82
122
|
if (last !== summary) statusLog.push(summary);
|
|
123
|
+
trace.append(task.id, "tool", summary, event.input.slice(0, 2000));
|
|
83
124
|
} else {
|
|
84
125
|
statusLog.push(event.text.slice(0, 200));
|
|
126
|
+
trace.append(task.id, "status", event.text.slice(0, 200));
|
|
85
127
|
}
|
|
86
128
|
originalMsg?.updateStatus(statusLog.slice(-5).join("\n"));
|
|
87
129
|
}
|
|
@@ -95,15 +137,26 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
95
137
|
}
|
|
96
138
|
}
|
|
97
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
|
+
|
|
98
146
|
if (result.success) {
|
|
99
147
|
deps.queue.complete(task.id, result.output);
|
|
100
148
|
logger.info("task completed", { taskId: task.id, durationMs: result.durationMs });
|
|
101
149
|
|
|
102
150
|
const platform = originalMsg?.platform || "slack";
|
|
103
|
-
const
|
|
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);
|
|
104
156
|
for (const part of parts) {
|
|
105
|
-
await
|
|
157
|
+
await replyWithFallback(part, originalMsg, task.userId, deps.adapters);
|
|
106
158
|
}
|
|
159
|
+
trace.append(task.id, "lifecycle", "Reply sent");
|
|
107
160
|
deps.sessions.addMessage(task.userId, "assistant", result.output.slice(0, 500));
|
|
108
161
|
|
|
109
162
|
const eventReply = deps.pendingEventReplies.get(task.id);
|
|
@@ -111,10 +164,13 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
111
164
|
await eventReply.adapter.respondToEvent(eventReply.event.eventId, result.output);
|
|
112
165
|
deps.pendingEventReplies.delete(task.id);
|
|
113
166
|
}
|
|
167
|
+
|
|
168
|
+
const elapsed = Date.now() - startTime;
|
|
169
|
+
trace.append(task.id, "lifecycle", `Task completed in ${elapsed}ms`);
|
|
114
170
|
} else {
|
|
115
171
|
deps.queue.fail(task.id, result.output);
|
|
116
172
|
logger.error("task failed", { taskId: task.id });
|
|
117
|
-
await
|
|
173
|
+
await replyWithFallback(`Task failed: ${result.output.slice(0, 500)}`, originalMsg, task.userId, deps.adapters);
|
|
118
174
|
deps.sessions.addMessage(task.userId, "assistant", `Task failed: ${result.output.slice(0, 200)}`);
|
|
119
175
|
|
|
120
176
|
const eventReply = deps.pendingEventReplies.get(task.id);
|
|
@@ -122,6 +178,9 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
122
178
|
await eventReply.adapter.respondToEvent(eventReply.event.eventId, `Task failed: ${result.output.slice(0, 500)}`);
|
|
123
179
|
deps.pendingEventReplies.delete(task.id);
|
|
124
180
|
}
|
|
181
|
+
|
|
182
|
+
const elapsed = Date.now() - startTime;
|
|
183
|
+
trace.append(task.id, "lifecycle", `Task failed in ${elapsed}ms`, result.output.slice(0, 2000));
|
|
125
184
|
}
|
|
126
185
|
} finally {
|
|
127
186
|
if (!isCreateProject) {
|
|
@@ -131,7 +190,10 @@ async function processTask(task: Task, deps: WorkerDeps) {
|
|
|
131
190
|
} catch (err) {
|
|
132
191
|
deps.queue.fail(task.id, String(err));
|
|
133
192
|
logger.error("task processing error", { taskId: task.id, error: String(err) });
|
|
134
|
-
|
|
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);
|
|
135
197
|
} finally {
|
|
136
198
|
deps.runningProcesses.delete(task.id);
|
|
137
199
|
deps.pendingReplies.delete(task.id);
|