morpheus-cli 0.4.15 → 0.5.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 (38) hide show
  1. package/README.md +275 -1116
  2. package/dist/channels/telegram.js +206 -74
  3. package/dist/cli/commands/doctor.js +34 -0
  4. package/dist/cli/commands/init.js +128 -0
  5. package/dist/cli/commands/restart.js +17 -0
  6. package/dist/cli/commands/start.js +15 -0
  7. package/dist/config/manager.js +51 -0
  8. package/dist/config/schemas.js +7 -0
  9. package/dist/devkit/tools/network.js +1 -1
  10. package/dist/http/api.js +177 -10
  11. package/dist/runtime/apoc.js +25 -17
  12. package/dist/runtime/memory/sati/repository.js +30 -2
  13. package/dist/runtime/memory/sati/service.js +46 -15
  14. package/dist/runtime/memory/sati/system-prompts.js +71 -29
  15. package/dist/runtime/memory/sqlite.js +24 -0
  16. package/dist/runtime/neo.js +134 -0
  17. package/dist/runtime/oracle.js +244 -205
  18. package/dist/runtime/providers/factory.js +1 -12
  19. package/dist/runtime/tasks/context.js +53 -0
  20. package/dist/runtime/tasks/dispatcher.js +70 -0
  21. package/dist/runtime/tasks/notifier.js +68 -0
  22. package/dist/runtime/tasks/repository.js +370 -0
  23. package/dist/runtime/tasks/types.js +1 -0
  24. package/dist/runtime/tasks/worker.js +96 -0
  25. package/dist/runtime/tools/apoc-tool.js +61 -8
  26. package/dist/runtime/tools/delegation-guard.js +29 -0
  27. package/dist/runtime/tools/index.js +1 -0
  28. package/dist/runtime/tools/neo-tool.js +99 -0
  29. package/dist/runtime/tools/task-query-tool.js +76 -0
  30. package/dist/runtime/webhooks/dispatcher.js +10 -19
  31. package/dist/types/config.js +10 -0
  32. package/dist/ui/assets/index-20lLB1sM.js +112 -0
  33. package/dist/ui/assets/index-BJ56bRfs.css +1 -0
  34. package/dist/ui/index.html +2 -2
  35. package/dist/ui/sw.js +1 -1
  36. package/package.json +1 -1
  37. package/dist/ui/assets/index-LemKVRjC.js +0 -112
  38. package/dist/ui/assets/index-TCQ7VNYO.css +0 -1
@@ -0,0 +1,70 @@
1
+ import { DisplayManager } from '../display.js';
2
+ import { WebhookRepository } from '../webhooks/repository.js';
3
+ import { AIMessage } from '@langchain/core/messages';
4
+ import { SQLiteChatMessageHistory } from '../memory/sqlite.js';
5
+ export class TaskDispatcher {
6
+ static telegramAdapter = null;
7
+ static display = DisplayManager.getInstance();
8
+ static setTelegramAdapter(adapter) {
9
+ TaskDispatcher.telegramAdapter = adapter;
10
+ }
11
+ static async notifyTaskResult(task) {
12
+ if (task.origin_channel === 'webhook') {
13
+ if (!task.origin_message_id) {
14
+ throw new Error('Webhook-origin task has no origin_message_id');
15
+ }
16
+ const repo = WebhookRepository.getInstance();
17
+ const status = task.status === 'completed' ? 'completed' : 'failed';
18
+ const result = task.status === 'completed'
19
+ ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
20
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
21
+ repo.updateNotificationResult(task.origin_message_id, status, result);
22
+ return;
23
+ }
24
+ if (task.origin_channel === 'ui') {
25
+ const statusIcon = task.status === 'completed' ? '✅' : '❌';
26
+ const body = task.status === 'completed'
27
+ ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
28
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
29
+ const content = `${statusIcon}\ Task \`${task.id.toUpperCase()}\`\n` +
30
+ `Agent: \`${task.agent.toUpperCase()}\`\n` +
31
+ `Status: \`${task.status.toUpperCase()}\`\n\n${body}`;
32
+ TaskDispatcher.display.log(`Writing UI task result to session "${task.session_id}" (task ${task.id})`, { source: 'TaskDispatcher', level: 'info' });
33
+ const history = new SQLiteChatMessageHistory({ sessionId: task.session_id });
34
+ try {
35
+ const msg = new AIMessage(content);
36
+ msg.provider_metadata = { provider: task.agent, model: 'task-result' };
37
+ await history.addMessage(msg);
38
+ TaskDispatcher.display.log(`UI task result written successfully to session "${task.session_id}"`, { source: 'TaskDispatcher' });
39
+ }
40
+ finally {
41
+ history.close();
42
+ }
43
+ return;
44
+ }
45
+ if (task.origin_channel !== 'telegram') {
46
+ return;
47
+ }
48
+ const adapter = TaskDispatcher.telegramAdapter;
49
+ if (!adapter) {
50
+ throw new Error('Telegram adapter not connected');
51
+ }
52
+ const statusIcon = task.status === 'completed' ? '✅' : '❌';
53
+ const body = task.status === 'completed'
54
+ ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
55
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
56
+ const header = `${statusIcon}\ Task \`${task.id.toUpperCase()}\`\n` +
57
+ `Agent: \`${task.agent.toUpperCase()}\`\n` +
58
+ `Status: \`${task.status.toUpperCase()}\``;
59
+ const message = `${header}\n\n${body}`;
60
+ if (task.origin_user_id) {
61
+ await adapter.sendMessageToUser(task.origin_user_id, message);
62
+ return;
63
+ }
64
+ TaskDispatcher.display.log(`Task ${task.id} has telegram origin but no origin_user_id; broadcasting to allowed users.`, { source: 'TaskDispatcher', level: 'warning' });
65
+ await adapter.sendMessage(message);
66
+ }
67
+ static async onTaskFinished(task) {
68
+ await TaskDispatcher.notifyTaskResult(task);
69
+ }
70
+ }
@@ -0,0 +1,68 @@
1
+ import { DisplayManager } from '../display.js';
2
+ import { TaskDispatcher } from './dispatcher.js';
3
+ import { TaskRepository } from './repository.js';
4
+ export class TaskNotifier {
5
+ pollIntervalMs;
6
+ maxAttempts;
7
+ staleSendingMs;
8
+ notificationGraceMs;
9
+ repository = TaskRepository.getInstance();
10
+ display = DisplayManager.getInstance();
11
+ timer = null;
12
+ running = false;
13
+ constructor(opts) {
14
+ this.pollIntervalMs = opts?.pollIntervalMs ?? 1200;
15
+ this.maxAttempts = opts?.maxAttempts ?? 5;
16
+ this.staleSendingMs = opts?.staleSendingMs ?? 30_000;
17
+ this.notificationGraceMs = opts?.notificationGraceMs ?? 2000;
18
+ }
19
+ start() {
20
+ if (this.timer)
21
+ return;
22
+ const recovered = this.repository.recoverNotificationQueue(this.maxAttempts, this.staleSendingMs);
23
+ if (recovered > 0) {
24
+ this.display.log(`Recovered ${recovered} task notification(s) back to pending.`, {
25
+ source: 'TaskNotifier',
26
+ level: 'warning',
27
+ });
28
+ }
29
+ this.timer = setInterval(() => {
30
+ void this.tick();
31
+ }, this.pollIntervalMs);
32
+ this.display.log('Task notifier started.', { source: 'TaskNotifier' });
33
+ }
34
+ stop() {
35
+ if (this.timer) {
36
+ clearInterval(this.timer);
37
+ this.timer = null;
38
+ this.display.log('Task notifier stopped.', { source: 'TaskNotifier' });
39
+ }
40
+ }
41
+ async tick() {
42
+ if (this.running)
43
+ return;
44
+ this.running = true;
45
+ try {
46
+ const task = this.repository.claimNextNotificationCandidate(this.notificationGraceMs);
47
+ if (!task)
48
+ return;
49
+ try {
50
+ await TaskDispatcher.onTaskFinished(task);
51
+ this.repository.markNotificationSent(task.id);
52
+ }
53
+ catch (err) {
54
+ const latest = this.repository.getTaskById(task.id);
55
+ const attempts = (latest?.notify_attempts ?? 0) + 1;
56
+ const retry = attempts < this.maxAttempts;
57
+ this.repository.markNotificationFailed(task.id, err?.message ?? String(err), retry);
58
+ this.display.log(`Task notification failed (${task.id}): ${err?.message ?? err}`, {
59
+ source: 'TaskNotifier',
60
+ level: retry ? 'warning' : 'error',
61
+ });
62
+ }
63
+ }
64
+ finally {
65
+ this.running = false;
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,370 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { homedir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+ export class TaskRepository {
7
+ static instance = null;
8
+ db;
9
+ constructor() {
10
+ const dbPath = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
11
+ fs.ensureDirSync(path.dirname(dbPath));
12
+ this.db = new Database(dbPath, { timeout: 5000 });
13
+ this.db.pragma('journal_mode = WAL');
14
+ this.db.pragma('foreign_keys = ON');
15
+ this.ensureTables();
16
+ }
17
+ static getInstance() {
18
+ if (!TaskRepository.instance) {
19
+ TaskRepository.instance = new TaskRepository();
20
+ }
21
+ return TaskRepository.instance;
22
+ }
23
+ ensureTables() {
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS tasks (
26
+ id TEXT PRIMARY KEY
27
+ );
28
+ `);
29
+ this.migrateTasksTable();
30
+ this.ensureIndexes();
31
+ }
32
+ migrateTasksTable() {
33
+ const tableInfo = this.db.pragma('table_info(tasks)');
34
+ const columns = new Set(tableInfo.map((c) => c.name));
35
+ const addColumn = (sql, column) => {
36
+ if (columns.has(column))
37
+ return;
38
+ this.db.exec(sql);
39
+ columns.add(column);
40
+ };
41
+ addColumn(`ALTER TABLE tasks ADD COLUMN agent TEXT NOT NULL DEFAULT 'apoc'`, 'agent');
42
+ addColumn(`ALTER TABLE tasks ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`, 'status');
43
+ addColumn(`ALTER TABLE tasks ADD COLUMN input TEXT NOT NULL DEFAULT ''`, 'input');
44
+ addColumn(`ALTER TABLE tasks ADD COLUMN context TEXT`, 'context');
45
+ addColumn(`ALTER TABLE tasks ADD COLUMN output TEXT`, 'output');
46
+ addColumn(`ALTER TABLE tasks ADD COLUMN error TEXT`, 'error');
47
+ addColumn(`ALTER TABLE tasks ADD COLUMN origin_channel TEXT NOT NULL DEFAULT 'api'`, 'origin_channel');
48
+ addColumn(`ALTER TABLE tasks ADD COLUMN session_id TEXT NOT NULL DEFAULT 'default'`, 'session_id');
49
+ addColumn(`ALTER TABLE tasks ADD COLUMN origin_message_id TEXT`, 'origin_message_id');
50
+ addColumn(`ALTER TABLE tasks ADD COLUMN origin_user_id TEXT`, 'origin_user_id');
51
+ addColumn(`ALTER TABLE tasks ADD COLUMN attempt_count INTEGER NOT NULL DEFAULT 0`, 'attempt_count');
52
+ addColumn(`ALTER TABLE tasks ADD COLUMN max_attempts INTEGER NOT NULL DEFAULT 3`, 'max_attempts');
53
+ addColumn(`ALTER TABLE tasks ADD COLUMN available_at INTEGER NOT NULL DEFAULT 0`, 'available_at');
54
+ addColumn(`ALTER TABLE tasks ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0`, 'created_at');
55
+ addColumn(`ALTER TABLE tasks ADD COLUMN started_at INTEGER`, 'started_at');
56
+ addColumn(`ALTER TABLE tasks ADD COLUMN finished_at INTEGER`, 'finished_at');
57
+ addColumn(`ALTER TABLE tasks ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0`, 'updated_at');
58
+ addColumn(`ALTER TABLE tasks ADD COLUMN worker_id TEXT`, 'worker_id');
59
+ addColumn(`ALTER TABLE tasks ADD COLUMN notify_status TEXT NOT NULL DEFAULT 'pending'`, 'notify_status');
60
+ addColumn(`ALTER TABLE tasks ADD COLUMN notify_attempts INTEGER NOT NULL DEFAULT 0`, 'notify_attempts');
61
+ addColumn(`ALTER TABLE tasks ADD COLUMN notify_last_error TEXT`, 'notify_last_error');
62
+ addColumn(`ALTER TABLE tasks ADD COLUMN notified_at INTEGER`, 'notified_at');
63
+ addColumn(`ALTER TABLE tasks ADD COLUMN notify_after_at INTEGER`, 'notify_after_at');
64
+ this.db.exec(`
65
+ UPDATE tasks
66
+ SET
67
+ created_at = CASE WHEN created_at = 0 THEN strftime('%s','now') * 1000 ELSE created_at END,
68
+ updated_at = CASE WHEN updated_at = 0 THEN strftime('%s','now') * 1000 ELSE updated_at END,
69
+ available_at = CASE WHEN available_at = 0 THEN created_at ELSE available_at END
70
+ WHERE created_at = 0 OR updated_at = 0 OR available_at = 0
71
+ `);
72
+ }
73
+ ensureIndexes() {
74
+ this.db.exec(`
75
+ CREATE INDEX IF NOT EXISTS idx_tasks_status_available_at
76
+ ON tasks(status, available_at, created_at);
77
+ CREATE INDEX IF NOT EXISTS idx_tasks_origin
78
+ ON tasks(origin_channel, session_id);
79
+ CREATE INDEX IF NOT EXISTS idx_tasks_created_at
80
+ ON tasks(created_at DESC);
81
+ CREATE INDEX IF NOT EXISTS idx_tasks_notify
82
+ ON tasks(status, notify_status, finished_at);
83
+ `);
84
+ }
85
+ deserializeTask(row) {
86
+ return {
87
+ id: row.id,
88
+ agent: row.agent,
89
+ status: row.status,
90
+ input: row.input,
91
+ context: row.context ?? null,
92
+ output: row.output ?? null,
93
+ error: row.error ?? null,
94
+ origin_channel: row.origin_channel,
95
+ session_id: row.session_id,
96
+ origin_message_id: row.origin_message_id ?? null,
97
+ origin_user_id: row.origin_user_id ?? null,
98
+ attempt_count: row.attempt_count ?? 0,
99
+ max_attempts: row.max_attempts ?? 3,
100
+ available_at: row.available_at,
101
+ created_at: row.created_at,
102
+ started_at: row.started_at ?? null,
103
+ finished_at: row.finished_at ?? null,
104
+ updated_at: row.updated_at,
105
+ worker_id: row.worker_id ?? null,
106
+ notify_status: row.notify_status,
107
+ notify_attempts: row.notify_attempts ?? 0,
108
+ notify_last_error: row.notify_last_error ?? null,
109
+ notified_at: row.notified_at ?? null,
110
+ notify_after_at: row.notify_after_at ?? null,
111
+ };
112
+ }
113
+ /**
114
+ * Grace period (ms) added to created_at for channels where the Oracle's
115
+ * acknowledgement and the task result share the same delivery path (e.g. Telegram).
116
+ * Channels with a synchronous ack (ui, api, cli, webhook) don't need this delay.
117
+ */
118
+ static DEFAULT_NOTIFY_AFTER_MS = 10_000;
119
+ static CHANNELS_NEEDING_ACK_GRACE = new Set(['telegram', 'discord']);
120
+ createTask(input) {
121
+ const now = Date.now();
122
+ const id = randomUUID();
123
+ const notify_after_at = input.notify_after_at !== undefined
124
+ ? input.notify_after_at
125
+ : TaskRepository.CHANNELS_NEEDING_ACK_GRACE.has(input.origin_channel)
126
+ ? now + TaskRepository.DEFAULT_NOTIFY_AFTER_MS
127
+ : null;
128
+ this.db.prepare(`
129
+ INSERT INTO tasks (
130
+ id, agent, status, input, context, output, error,
131
+ origin_channel, session_id, origin_message_id, origin_user_id,
132
+ attempt_count, max_attempts, available_at,
133
+ created_at, started_at, finished_at, updated_at, worker_id,
134
+ notify_status, notify_attempts, notify_last_error, notified_at,
135
+ notify_after_at
136
+ ) VALUES (
137
+ ?, ?, 'pending', ?, ?, NULL, NULL,
138
+ ?, ?, ?, ?,
139
+ 0, ?, ?,
140
+ ?, NULL, NULL, ?, NULL,
141
+ 'pending', 0, NULL, NULL,
142
+ ?
143
+ )
144
+ `).run(id, input.agent, input.input, input.context ?? null, input.origin_channel, input.session_id, input.origin_message_id ?? null, input.origin_user_id ?? null, input.max_attempts ?? 3, now, now, now, notify_after_at);
145
+ return this.getTaskById(id);
146
+ }
147
+ getTaskById(id) {
148
+ const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
149
+ return row ? this.deserializeTask(row) : null;
150
+ }
151
+ listTasks(filters) {
152
+ const params = [];
153
+ let query = 'SELECT * FROM tasks WHERE 1=1';
154
+ if (filters?.status) {
155
+ query += ' AND status = ?';
156
+ params.push(filters.status);
157
+ }
158
+ if (filters?.agent) {
159
+ query += ' AND agent = ?';
160
+ params.push(filters.agent);
161
+ }
162
+ if (filters?.origin_channel) {
163
+ query += ' AND origin_channel = ?';
164
+ params.push(filters.origin_channel);
165
+ }
166
+ if (filters?.session_id) {
167
+ query += ' AND session_id = ?';
168
+ params.push(filters.session_id);
169
+ }
170
+ query += ' ORDER BY created_at DESC LIMIT ?';
171
+ params.push(filters?.limit ?? 200);
172
+ const rows = this.db.prepare(query).all(...params);
173
+ return rows.map((row) => this.deserializeTask(row));
174
+ }
175
+ getStats() {
176
+ const rows = this.db.prepare(`
177
+ SELECT status, COUNT(*) as cnt
178
+ FROM tasks
179
+ GROUP BY status
180
+ `).all();
181
+ const stats = {
182
+ pending: 0,
183
+ running: 0,
184
+ completed: 0,
185
+ failed: 0,
186
+ cancelled: 0,
187
+ total: 0,
188
+ };
189
+ for (const row of rows) {
190
+ const status = row.status;
191
+ if (status in stats) {
192
+ stats[status] = row.cnt;
193
+ }
194
+ stats.total += row.cnt;
195
+ }
196
+ return stats;
197
+ }
198
+ claimNextPending(workerId) {
199
+ const now = Date.now();
200
+ const tx = this.db.transaction(() => {
201
+ const row = this.db.prepare(`
202
+ SELECT id
203
+ FROM tasks
204
+ WHERE status = 'pending' AND available_at <= ?
205
+ ORDER BY created_at ASC
206
+ LIMIT 1
207
+ `).get(now);
208
+ if (!row)
209
+ return null;
210
+ const result = this.db.prepare(`
211
+ UPDATE tasks
212
+ SET status = 'running',
213
+ started_at = COALESCE(started_at, ?),
214
+ updated_at = ?,
215
+ worker_id = ?,
216
+ attempt_count = attempt_count + 1
217
+ WHERE id = ? AND status = 'pending'
218
+ `).run(now, now, workerId, row.id);
219
+ if (result.changes === 0) {
220
+ return null;
221
+ }
222
+ return this.getTaskById(row.id);
223
+ });
224
+ return tx();
225
+ }
226
+ markCompleted(id, output) {
227
+ const now = Date.now();
228
+ const normalizedOutput = (output ?? '').trim();
229
+ this.db.prepare(`
230
+ UPDATE tasks
231
+ SET status = 'completed',
232
+ output = ?,
233
+ error = NULL,
234
+ finished_at = ?,
235
+ updated_at = ?,
236
+ notify_status = 'pending',
237
+ notify_last_error = NULL,
238
+ notified_at = NULL
239
+ WHERE id = ?
240
+ `).run(normalizedOutput.length > 0 ? normalizedOutput : 'Task completed without output.', now, now, id);
241
+ }
242
+ markFailed(id, error) {
243
+ const now = Date.now();
244
+ this.db.prepare(`
245
+ UPDATE tasks
246
+ SET status = 'failed',
247
+ error = ?,
248
+ finished_at = ?,
249
+ updated_at = ?,
250
+ notify_status = 'pending',
251
+ notified_at = NULL
252
+ WHERE id = ?
253
+ `).run(error, now, now, id);
254
+ }
255
+ retryTask(id) {
256
+ const now = Date.now();
257
+ const result = this.db.prepare(`
258
+ UPDATE tasks
259
+ SET status = 'pending',
260
+ output = NULL,
261
+ error = NULL,
262
+ finished_at = NULL,
263
+ started_at = NULL,
264
+ updated_at = ?,
265
+ available_at = ?,
266
+ worker_id = NULL
267
+ WHERE id = ? AND status = 'failed'
268
+ `).run(now, now, id);
269
+ return result.changes > 0;
270
+ }
271
+ requeueForRetry(id, error, delayMs) {
272
+ const now = Date.now();
273
+ this.db.prepare(`
274
+ UPDATE tasks
275
+ SET status = 'pending',
276
+ error = ?,
277
+ output = NULL,
278
+ updated_at = ?,
279
+ available_at = ?,
280
+ worker_id = NULL
281
+ WHERE id = ?
282
+ `).run(error, now, now + Math.max(0, delayMs), id);
283
+ }
284
+ recoverStaleRunning(staleMs) {
285
+ const now = Date.now();
286
+ const result = this.db.prepare(`
287
+ UPDATE tasks
288
+ SET status = 'pending',
289
+ updated_at = ?,
290
+ available_at = ?,
291
+ worker_id = NULL,
292
+ error = COALESCE(error, 'Recovered from stale running state')
293
+ WHERE status = 'running'
294
+ AND started_at IS NOT NULL
295
+ AND started_at <= ?
296
+ `).run(now, now, now - staleMs);
297
+ return result.changes;
298
+ }
299
+ claimNextNotificationCandidate(minFinishedAgeMs = 0) {
300
+ const now = Date.now();
301
+ const tx = this.db.transaction(() => {
302
+ const row = this.db.prepare(`
303
+ SELECT id
304
+ FROM tasks
305
+ WHERE status IN ('completed', 'failed')
306
+ AND notify_status = 'pending'
307
+ AND finished_at IS NOT NULL
308
+ AND finished_at <= ?
309
+ AND (notify_after_at IS NULL OR notify_after_at <= ?)
310
+ ORDER BY finished_at ASC
311
+ LIMIT 1
312
+ `).get(now - Math.max(0, minFinishedAgeMs), now);
313
+ if (!row)
314
+ return null;
315
+ const changed = this.db.prepare(`
316
+ UPDATE tasks
317
+ SET notify_status = 'sending',
318
+ notify_last_error = NULL,
319
+ updated_at = ?
320
+ WHERE id = ? AND notify_status = 'pending'
321
+ `).run(Date.now(), row.id);
322
+ if (changed.changes === 0) {
323
+ return null;
324
+ }
325
+ return this.getTaskById(row.id);
326
+ });
327
+ return tx();
328
+ }
329
+ recoverNotificationQueue(maxAttempts, staleSendingMs) {
330
+ const now = Date.now();
331
+ const staleThreshold = now - Math.max(0, staleSendingMs);
332
+ const result = this.db.prepare(`
333
+ UPDATE tasks
334
+ SET notify_status = 'pending',
335
+ notify_last_error = COALESCE(notify_last_error, 'Recovered notification queue state'),
336
+ updated_at = ?
337
+ WHERE status IN ('completed', 'failed')
338
+ AND (
339
+ (notify_status = 'sending' AND updated_at <= ?)
340
+ OR
341
+ (notify_status = 'failed' AND notify_attempts < ?)
342
+ )
343
+ `).run(now, staleThreshold, Math.max(1, maxAttempts));
344
+ return result.changes;
345
+ }
346
+ markNotificationSent(taskId) {
347
+ const now = Date.now();
348
+ this.db.prepare(`
349
+ UPDATE tasks
350
+ SET notify_status = 'sent',
351
+ notified_at = ?,
352
+ updated_at = ?
353
+ WHERE id = ?
354
+ `).run(now, now, taskId);
355
+ }
356
+ markNotificationFailed(taskId, error, retry) {
357
+ const now = Date.now();
358
+ this.db.prepare(`
359
+ UPDATE tasks
360
+ SET notify_status = ?,
361
+ notify_attempts = notify_attempts + 1,
362
+ notify_last_error = ?,
363
+ updated_at = ?
364
+ WHERE id = ?
365
+ `).run(retry ? 'pending' : 'failed', error, now, taskId);
366
+ }
367
+ close() {
368
+ this.db.close();
369
+ }
370
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { DisplayManager } from '../display.js';
3
+ import { Apoc } from '../apoc.js';
4
+ import { Neo } from '../neo.js';
5
+ import { TaskRepository } from './repository.js';
6
+ export class TaskWorker {
7
+ workerId;
8
+ pollIntervalMs;
9
+ staleRunningMs;
10
+ repository = TaskRepository.getInstance();
11
+ display = DisplayManager.getInstance();
12
+ timer = null;
13
+ running = false;
14
+ constructor(opts) {
15
+ this.workerId = `task-worker-${randomUUID().slice(0, 8)}`;
16
+ this.pollIntervalMs = opts?.pollIntervalMs ?? 1000;
17
+ this.staleRunningMs = opts?.staleRunningMs ?? 5 * 60 * 1000;
18
+ }
19
+ start() {
20
+ if (this.timer)
21
+ return;
22
+ const recovered = this.repository.recoverStaleRunning(this.staleRunningMs);
23
+ if (recovered > 0) {
24
+ this.display.log(`Recovered ${recovered} stale running task(s).`, { source: 'TaskWorker', level: 'warning' });
25
+ }
26
+ this.timer = setInterval(() => {
27
+ void this.tick();
28
+ }, this.pollIntervalMs);
29
+ this.display.log(`Task worker started (${this.workerId}).`, { source: 'TaskWorker' });
30
+ }
31
+ stop() {
32
+ if (this.timer) {
33
+ clearInterval(this.timer);
34
+ this.timer = null;
35
+ this.display.log(`Task worker stopped (${this.workerId}).`, { source: 'TaskWorker' });
36
+ }
37
+ }
38
+ async tick() {
39
+ if (this.running)
40
+ return;
41
+ this.running = true;
42
+ try {
43
+ const task = this.repository.claimNextPending(this.workerId);
44
+ if (!task)
45
+ return;
46
+ await this.executeTask(task);
47
+ }
48
+ finally {
49
+ this.running = false;
50
+ }
51
+ }
52
+ async executeTask(task) {
53
+ try {
54
+ let output;
55
+ switch (task.agent) {
56
+ case 'apoc': {
57
+ const apoc = Apoc.getInstance();
58
+ output = await apoc.execute(task.input, task.context ?? undefined, task.session_id);
59
+ break;
60
+ }
61
+ case 'neo': {
62
+ const neo = Neo.getInstance();
63
+ output = await neo.execute(task.input, task.context ?? undefined, task.session_id, {
64
+ origin_channel: task.origin_channel,
65
+ session_id: task.session_id,
66
+ origin_message_id: task.origin_message_id ?? undefined,
67
+ origin_user_id: task.origin_user_id ?? undefined,
68
+ });
69
+ break;
70
+ }
71
+ case 'trinit': {
72
+ throw new Error('Trinit executor is not implemented yet.');
73
+ }
74
+ default: {
75
+ throw new Error(`Unknown task agent: ${task.agent}`);
76
+ }
77
+ }
78
+ this.repository.markCompleted(task.id, output);
79
+ this.display.log(`Task completed: ${task.id}`, { source: 'TaskWorker', level: 'success' });
80
+ }
81
+ catch (err) {
82
+ const latest = this.repository.getTaskById(task.id);
83
+ const attempt = latest?.attempt_count ?? task.attempt_count;
84
+ const maxAttempts = latest?.max_attempts ?? task.max_attempts;
85
+ const errorMessage = err?.message ? String(err.message) : String(err);
86
+ if (attempt < maxAttempts) {
87
+ const backoffMs = Math.min(30_000, 1000 * Math.pow(2, Math.max(0, attempt - 1)));
88
+ this.repository.requeueForRetry(task.id, `Attempt ${attempt}/${maxAttempts} failed: ${errorMessage}`, backoffMs);
89
+ this.display.log(`Task retry scheduled: ${task.id} in ${backoffMs}ms`, { source: 'TaskWorker', level: 'warning' });
90
+ return;
91
+ }
92
+ this.repository.markFailed(task.id, errorMessage);
93
+ this.display.log(`Task failed: ${task.id} (${errorMessage})`, { source: 'TaskWorker', level: 'error' });
94
+ }
95
+ }
96
+ }