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.
- package/README.md +275 -1116
- package/dist/channels/telegram.js +206 -74
- package/dist/cli/commands/doctor.js +34 -0
- package/dist/cli/commands/init.js +128 -0
- package/dist/cli/commands/restart.js +17 -0
- package/dist/cli/commands/start.js +15 -0
- package/dist/config/manager.js +51 -0
- package/dist/config/schemas.js +7 -0
- package/dist/devkit/tools/network.js +1 -1
- package/dist/http/api.js +177 -10
- package/dist/runtime/apoc.js +25 -17
- package/dist/runtime/memory/sati/repository.js +30 -2
- package/dist/runtime/memory/sati/service.js +46 -15
- package/dist/runtime/memory/sati/system-prompts.js +71 -29
- package/dist/runtime/memory/sqlite.js +24 -0
- package/dist/runtime/neo.js +134 -0
- package/dist/runtime/oracle.js +244 -205
- package/dist/runtime/providers/factory.js +1 -12
- package/dist/runtime/tasks/context.js +53 -0
- package/dist/runtime/tasks/dispatcher.js +70 -0
- package/dist/runtime/tasks/notifier.js +68 -0
- package/dist/runtime/tasks/repository.js +370 -0
- package/dist/runtime/tasks/types.js +1 -0
- package/dist/runtime/tasks/worker.js +96 -0
- package/dist/runtime/tools/apoc-tool.js +61 -8
- package/dist/runtime/tools/delegation-guard.js +29 -0
- package/dist/runtime/tools/index.js +1 -0
- package/dist/runtime/tools/neo-tool.js +99 -0
- package/dist/runtime/tools/task-query-tool.js +76 -0
- package/dist/runtime/webhooks/dispatcher.js +10 -19
- package/dist/types/config.js +10 -0
- package/dist/ui/assets/index-20lLB1sM.js +112 -0
- package/dist/ui/assets/index-BJ56bRfs.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-LemKVRjC.js +0 -112
- 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
|
+
}
|