morpheus-cli 0.4.14 → 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 +210 -73
- 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 +139 -32
- 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 -133
- 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,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
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { tool } from "@langchain/core/tools";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { TaskRepository } from "../tasks/repository.js";
|
|
4
|
+
import { TaskRequestContext } from "../tasks/context.js";
|
|
5
|
+
import { compositeDelegationError, isLikelyCompositeDelegationTask } from "./delegation-guard.js";
|
|
6
|
+
import { DisplayManager } from "../display.js";
|
|
4
7
|
/**
|
|
5
8
|
* Tool that Oracle uses to delegate devtools tasks to Apoc.
|
|
6
9
|
* Oracle should call this whenever the user requests operations like:
|
|
@@ -14,16 +17,66 @@ import { Apoc } from "../apoc.js";
|
|
|
14
17
|
*/
|
|
15
18
|
export const ApocDelegateTool = tool(async ({ task, context }) => {
|
|
16
19
|
try {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const display = DisplayManager.getInstance();
|
|
21
|
+
if (isLikelyCompositeDelegationTask(task)) {
|
|
22
|
+
display.log(`Apoc delegation rejected (non-atomic task): ${task.slice(0, 140)}`, {
|
|
23
|
+
source: "ApocDelegateTool",
|
|
24
|
+
level: "warning",
|
|
25
|
+
});
|
|
26
|
+
return compositeDelegationError();
|
|
27
|
+
}
|
|
28
|
+
const existingAck = TaskRequestContext.findDuplicateDelegation("apoc", task);
|
|
29
|
+
if (existingAck) {
|
|
30
|
+
display.log(`Apoc delegation deduplicated. Reusing task ${existingAck.task_id}.`, {
|
|
31
|
+
source: "ApocDelegateTool",
|
|
32
|
+
level: "info",
|
|
33
|
+
});
|
|
34
|
+
return `Task ${existingAck.task_id} already queued for ${existingAck.agent} execution.`;
|
|
35
|
+
}
|
|
36
|
+
if (!TaskRequestContext.canEnqueueDelegation()) {
|
|
37
|
+
display.log(`Apoc delegation blocked by per-turn limit.`, {
|
|
38
|
+
source: "ApocDelegateTool",
|
|
39
|
+
level: "warning",
|
|
40
|
+
});
|
|
41
|
+
return "Delegation limit reached for this user turn. Split the request or wait for current tasks.";
|
|
42
|
+
}
|
|
43
|
+
const ctx = TaskRequestContext.get();
|
|
44
|
+
const repository = TaskRepository.getInstance();
|
|
45
|
+
const created = repository.createTask({
|
|
46
|
+
agent: "apoc",
|
|
47
|
+
input: task,
|
|
48
|
+
context: context ?? null,
|
|
49
|
+
origin_channel: ctx?.origin_channel ?? "api",
|
|
50
|
+
session_id: ctx?.session_id ?? "default",
|
|
51
|
+
origin_message_id: ctx?.origin_message_id ?? null,
|
|
52
|
+
origin_user_id: ctx?.origin_user_id ?? null,
|
|
53
|
+
max_attempts: 3,
|
|
54
|
+
});
|
|
55
|
+
TaskRequestContext.setDelegationAck({ task_id: created.id, agent: "apoc", task });
|
|
56
|
+
display.log(`Apoc task created: ${created.id}`, {
|
|
57
|
+
source: "ApocDelegateTool",
|
|
58
|
+
level: "info",
|
|
59
|
+
meta: {
|
|
60
|
+
agent: created.agent,
|
|
61
|
+
origin_channel: created.origin_channel,
|
|
62
|
+
session_id: created.session_id,
|
|
63
|
+
input: created.input,
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
return `Task ${created.id} queued for Apoc execution.`;
|
|
20
67
|
}
|
|
21
68
|
catch (err) {
|
|
22
|
-
|
|
69
|
+
const display = DisplayManager.getInstance();
|
|
70
|
+
display.log(`ApocDelegateTool error: ${err.message}`, { source: "ApocDelegateTool", level: "error" });
|
|
71
|
+
return `Apoc task enqueue failed: ${err.message}`;
|
|
23
72
|
}
|
|
24
73
|
}, {
|
|
25
74
|
name: "apoc_delegate",
|
|
26
|
-
description: `Delegate a devtools task to Apoc, the specialized development subagent.
|
|
75
|
+
description: `Delegate a devtools task to Apoc, the specialized development subagent, asynchronously.
|
|
76
|
+
|
|
77
|
+
This tool enqueues a background task and returns an acknowledgement with task id.
|
|
78
|
+
Do not expect final execution output in the same response.
|
|
79
|
+
Each task must contain a single atomic action with a clear expected result.
|
|
27
80
|
|
|
28
81
|
Use this tool when the user asks for ANY of the following:
|
|
29
82
|
- File operations: read, write, create, delete files or directories
|
|
@@ -39,7 +92,7 @@ Use this tool when the user asks for ANY of the following:
|
|
|
39
92
|
Provide a clear natural language task description. Optionally provide context
|
|
40
93
|
from the current conversation to help Apoc understand the broader goal.`,
|
|
41
94
|
schema: z.object({
|
|
42
|
-
task: z.string().describe("Clear description of the devtools task to execute"),
|
|
43
|
-
context: z.string().optional().describe("Optional context from the conversation to help Apoc understand the goal"),
|
|
95
|
+
task: z.string().describe("Clear description of the devtools task to execute **in the user's language**"),
|
|
96
|
+
context: z.string().optional().describe("Optional context from the conversation to help Apoc understand the goal **in the user's language**"),
|
|
44
97
|
}),
|
|
45
98
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function normalize(text) {
|
|
2
|
+
return text
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/\s+/g, " ")
|
|
5
|
+
.trim();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Heuristic guard to reject clearly multi-action delegations.
|
|
9
|
+
* Allows phrases like "do X and return/report Y".
|
|
10
|
+
*/
|
|
11
|
+
export function isLikelyCompositeDelegationTask(task) {
|
|
12
|
+
const t = normalize(task);
|
|
13
|
+
if (!t)
|
|
14
|
+
return false;
|
|
15
|
+
if (/[;\n]/.test(t))
|
|
16
|
+
return true;
|
|
17
|
+
const conjunction = /\b(and|then|also|e|depois|tambem|também|alem disso|além disso)\b/;
|
|
18
|
+
if (!conjunction.test(t))
|
|
19
|
+
return false;
|
|
20
|
+
// "and return/report" is usually part of a single atomic objective.
|
|
21
|
+
const allowedSecondStep = /\b(and|then|e|depois)\s+(return|report|summarize|retorne|informe|resuma|mostre|show)\b/;
|
|
22
|
+
if (allowedSecondStep.test(t))
|
|
23
|
+
return false;
|
|
24
|
+
const actionVerbAfterConjunction = /\b(and|then|also|e|depois|tambem|também)\s+(check|ping|run|execute|search|fetch|get|list|create|update|delete|open|verify|consult|verificar|fazer|executar|buscar|obter|listar|criar|atualizar|deletar|abrir)\b/;
|
|
25
|
+
return actionVerbAfterConjunction.test(t);
|
|
26
|
+
}
|
|
27
|
+
export function compositeDelegationError() {
|
|
28
|
+
return "Delegation rejected: task must be atomic (single action). Split this request into multiple delegations, one task per action/tool.";
|
|
29
|
+
}
|