arisa 5.1.49 → 5.1.64

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 (56) hide show
  1. package/AGENTS.md +0 -2
  2. package/README.md +9 -0
  3. package/package.json +1 -1
  4. package/src/core/agent/agent-manager.js +49 -489
  5. package/src/core/agent/agent-session-lifecycle.js +181 -0
  6. package/src/core/agent/pi-capability-tools.js +183 -0
  7. package/src/core/artifacts/artifact-store.js +73 -17
  8. package/src/core/capabilities/capability-service.js +340 -0
  9. package/src/core/config/config-defaults.js +28 -1
  10. package/src/core/tasks/task-routing.js +7 -0
  11. package/src/core/tasks/task-runner.js +68 -0
  12. package/src/core/tasks/task-store.js +382 -92
  13. package/src/core/tools/tool-output-materializer.js +5 -5
  14. package/src/core/tools/tool-registry.js +20 -5
  15. package/src/core/tools/weighted-resource-governor.js +153 -0
  16. package/src/index.js +20 -0
  17. package/src/official-tools.lock.json +62 -45
  18. package/src/runtime/arisa-capabilities.js +51 -242
  19. package/src/runtime/create-app.js +11 -2
  20. package/src/runtime/create-headless-app.js +7 -4
  21. package/src/runtime/paths.js +4 -0
  22. package/src/runtime/service-manager.js +3 -1
  23. package/src/runtime/service-supervisor.js +98 -0
  24. package/src/transport/telegram/bot.js +186 -374
  25. package/src/transport/telegram/chat-queue.js +83 -6
  26. package/src/transport/telegram/prompt-builders.js +9 -0
  27. package/src/transport/telegram/reply-topic-routing.js +111 -0
  28. package/src/transport/telegram/task-dispatcher.js +96 -36
  29. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  30. package/src/transport/telegram/telegram-session-bridge.js +177 -0
  31. package/src/transport/telegram/telegram-tools-command.js +28 -0
  32. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  33. package/src/transport/telegram/workspace-topic-store.js +228 -0
  34. package/test/agent-session-lifecycle.test.js +58 -0
  35. package/test/artifact-store.test.js +38 -2
  36. package/test/capabilities-security.test.js +58 -0
  37. package/test/chat-queue.test.js +32 -0
  38. package/test/context-and-task-bounds.test.js +76 -1
  39. package/test/device-code-message.test.js +9 -0
  40. package/test/media-caption.test.js +1 -1
  41. package/test/model-selection.test.js +9 -1
  42. package/test/official-tool-dependencies.test.js +1 -1
  43. package/test/paths.test.js +8 -0
  44. package/test/pi-capability-tools.test.js +65 -0
  45. package/test/service-manager.test.js +48 -0
  46. package/test/session-start-operational-notes.test.js +1 -1
  47. package/test/task-idempotency.test.js +40 -0
  48. package/test/task-routing.test.js +62 -0
  49. package/test/task-store.test.js +231 -7
  50. package/test/telegram-reply-topic-routing.test.js +94 -0
  51. package/test/telegram-task-dispatcher.test.js +150 -23
  52. package/test/telegram-text-artifact.test.js +13 -2
  53. package/test/telegram-tools-command.test.js +47 -0
  54. package/test/telegram-workspace-topic-store.test.js +124 -0
  55. package/test/tool-registry-run.test.js +41 -0
  56. package/test/weighted-resource-governor.test.js +95 -0
@@ -1,11 +1,39 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { tasksFile } from "../../runtime/paths.js";
5
5
 
6
+ const DEFAULT_RETRY = Object.freeze({
7
+ maxAttempts: 3,
8
+ baseDelaySeconds: 30,
9
+ maxDelaySeconds: 900,
10
+ multiplier: 2
11
+ });
12
+
13
+ const TERMINAL_STATUSES = new Set(["done", "failed", "outcome_uncertain"]);
14
+ const TERMINAL_PAYLOAD_KEYS = ["chatId", "toolName", "resourceId", "artifactId"];
15
+
16
+ const taskFileOperations = new Map();
17
+
18
+ async function serializeTaskFileOperation(operation) {
19
+ const previous = taskFileOperations.get(tasksFile) || Promise.resolve();
20
+ const current = previous.catch(() => {}).then(operation);
21
+ taskFileOperations.set(tasksFile, current);
22
+ try {
23
+ return await current;
24
+ } finally {
25
+ if (taskFileOperations.get(tasksFile) === current) taskFileOperations.delete(tasksFile);
26
+ }
27
+ }
28
+
29
+ async function waitForTaskFileOperations() {
30
+ await (taskFileOperations.get(tasksFile) || Promise.resolve()).catch(() => {});
31
+ }
32
+
6
33
  async function loadTasksFile() {
7
34
  try {
8
- return JSON.parse(await readFile(tasksFile, "utf8"));
35
+ const parsed = JSON.parse(await readFile(tasksFile, "utf8"));
36
+ return Array.isArray(parsed) ? parsed.map(migrateTask) : [];
9
37
  } catch {
10
38
  return [];
11
39
  }
@@ -13,173 +41,435 @@ async function loadTasksFile() {
13
41
 
14
42
  async function saveTasksFile(tasks) {
15
43
  await mkdir(path.dirname(tasksFile), { recursive: true });
16
- await writeFile(tasksFile, `${JSON.stringify(tasks, null, 2)}\n`, "utf8");
44
+ const temporaryFile = `${tasksFile}.${process.pid}.${crypto.randomUUID()}.tmp`;
45
+ try {
46
+ await writeFile(temporaryFile, `${JSON.stringify(tasks, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
47
+ await rename(temporaryFile, tasksFile);
48
+ } finally {
49
+ await rm(temporaryFile, { force: true }).catch(() => {});
50
+ }
17
51
  }
18
52
 
19
53
  function taskId() {
20
54
  return crypto.randomUUID();
21
55
  }
22
56
 
23
- function normalizeTask(task, defaults = {}) {
57
+ function boundedPositiveInteger(value, fallback, maximum) {
58
+ const parsed = Number(value);
59
+ return Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
60
+ }
61
+
62
+ function boundedPositiveNumber(value, fallback, maximum) {
63
+ const parsed = Number(value);
64
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
65
+ }
66
+
67
+ function normalizeRetry(retry = {}) {
68
+ const value = retry && typeof retry === "object" && !Array.isArray(retry) ? retry : {};
24
69
  return {
70
+ maxAttempts: boundedPositiveInteger(value.maxAttempts, DEFAULT_RETRY.maxAttempts, 10),
71
+ baseDelaySeconds: boundedPositiveNumber(value.baseDelaySeconds, DEFAULT_RETRY.baseDelaySeconds, 3_600),
72
+ maxDelaySeconds: boundedPositiveNumber(value.maxDelaySeconds, DEFAULT_RETRY.maxDelaySeconds, 86_400),
73
+ multiplier: boundedPositiveNumber(value.multiplier, DEFAULT_RETRY.multiplier, 10)
74
+ };
75
+ }
76
+
77
+ function legacyTelegramRoute(telegramContext) {
78
+ if (!telegramContext?.transportChatId) return null;
79
+ return {
80
+ transport: "telegram",
81
+ destination: {
82
+ chatId: telegramContext.transportChatId,
83
+ ...(telegramContext.messageThreadId ? { threadId: telegramContext.messageThreadId } : {})
84
+ }
85
+ };
86
+ }
87
+
88
+ function migrateTask(task = {}) {
89
+ const payload = { ...(task.payload || {}) };
90
+ const route = task.route || legacyTelegramRoute(payload.telegramContext) || null;
91
+ delete payload.telegramContext;
92
+ const migrated = {
93
+ ...task,
94
+ payload,
95
+ route,
96
+ attempts: Number.isSafeInteger(task.attempts) && task.attempts >= 0 ? task.attempts : 0
97
+ };
98
+ if (!TERMINAL_STATUSES.has(migrated.status)) migrated.retry = normalizeRetry(task.retry);
99
+ return migrated;
100
+ }
101
+
102
+ function compactTerminalPayload(payload = {}) {
103
+ return Object.fromEntries(TERMINAL_PAYLOAD_KEYS.flatMap((key) => {
104
+ const value = payload[key];
105
+ return ["string", "number", "boolean"].includes(typeof value) ? [[key, value]] : [];
106
+ }));
107
+ }
108
+
109
+ function compactTerminalTask(task) {
110
+ if (!TERMINAL_STATUSES.has(task.status)) return false;
111
+ const payload = compactTerminalPayload(task.payload);
112
+ const changed = !task.payloadCompacted
113
+ || JSON.stringify(task.payload || {}) !== JSON.stringify(payload)
114
+ || Object.hasOwn(task, "retry")
115
+ || Object.hasOwn(task, "recurrence");
116
+ task.payload = payload;
117
+ task.payloadCompacted = true;
118
+ delete task.retry;
119
+ delete task.recurrence;
120
+ return changed;
121
+ }
122
+
123
+ function normalizeTask(task, defaults = {}) {
124
+ const mergedPayload = {
125
+ ...(defaults.payload || {}),
126
+ ...(task.payload || {})
127
+ };
128
+ const route = task.route
129
+ || defaults.route
130
+ || legacyTelegramRoute(mergedPayload.telegramContext)
131
+ || null;
132
+ delete mergedPayload.telegramContext;
133
+ const normalized = migrateTask({
25
134
  id: task.id || taskId(),
26
135
  status: task.status || "pending",
27
136
  createdAt: task.createdAt || new Date().toISOString(),
28
137
  updatedAt: new Date().toISOString(),
29
138
  kind: task.kind,
30
139
  runAt: task.runAt || new Date().toISOString(),
31
- payload: {
32
- ...(defaults.payload || {}),
33
- ...(task.payload || {})
34
- },
140
+ payload: mergedPayload,
141
+ route,
35
142
  recurrence: task.recurrence || defaults.recurrence || null,
36
143
  source: {
37
144
  ...(defaults.source || {}),
38
145
  ...(task.source || {})
39
- }
40
- };
146
+ },
147
+ ...(task.startedAt ? { startedAt: task.startedAt } : {}),
148
+ ...(task.claimedAt ? { claimedAt: task.claimedAt } : {}),
149
+ ...(task.executionStartedAt ? { executionStartedAt: task.executionStartedAt } : {}),
150
+ attempts: task.attempts || 0,
151
+ retry: task.retry || defaults.retry
152
+ });
153
+ compactTerminalTask(normalized);
154
+ return normalized;
41
155
  }
42
156
 
43
- function computeNextRunAt(task) {
157
+ function computeNextRunAt(task, now = Date.now()) {
44
158
  if (task.recurrence?.type === "interval" && Number(task.recurrence.everySeconds) > 0) {
45
- return new Date(Date.now() + (Number(task.recurrence.everySeconds) * 1000)).toISOString();
159
+ return new Date(now + (Number(task.recurrence.everySeconds) * 1000)).toISOString();
46
160
  }
47
161
  return "";
48
162
  }
49
163
 
164
+ function retryDelayMs(task) {
165
+ const exponent = Math.max(0, Number(task.attempts || 1) - 1);
166
+ const seconds = Math.min(
167
+ task.retry.maxDelaySeconds,
168
+ task.retry.baseDelaySeconds * (task.retry.multiplier ** exponent)
169
+ );
170
+ return Math.max(1, Math.round(seconds * 1000));
171
+ }
172
+
173
+ function failTask(task, error) {
174
+ const failedAt = new Date().toISOString();
175
+ task.status = "failed";
176
+ task.error = error instanceof Error ? error.message : String(error);
177
+ task.lastError = task.error;
178
+ task.failedAt = failedAt;
179
+ task.updatedAt = failedAt;
180
+ compactTerminalTask(task);
181
+ return structuredClone(task);
182
+ }
183
+
50
184
  export class TaskStore {
51
185
  constructor() {
52
186
  this.tasks = null;
53
187
  }
54
188
 
55
189
  async init() {
56
- if (!this.tasks) this.tasks = await loadTasksFile();
190
+ if (!this.tasks) await this.reload();
57
191
  }
58
192
 
59
193
  async reload() {
194
+ await waitForTaskFileOperations();
60
195
  this.tasks = await loadTasksFile();
61
196
  }
62
197
 
198
+ async mutate(operation) {
199
+ return serializeTaskFileOperation(async () => {
200
+ this.tasks = await loadTasksFile();
201
+ const { result, changed = true } = await operation(this.tasks);
202
+ if (changed) await saveTasksFile(this.tasks);
203
+ return result;
204
+ });
205
+ }
206
+
63
207
  async save() {
64
- await saveTasksFile(this.tasks || []);
208
+ const tasks = structuredClone(this.tasks || []);
209
+ return serializeTaskFileOperation(async () => {
210
+ this.tasks = tasks;
211
+ await saveTasksFile(tasks);
212
+ });
65
213
  }
66
214
 
67
215
  async add(task, defaults = {}) {
68
- await this.init();
69
- const normalized = normalizeTask(task, defaults);
70
- this.tasks.push(normalized);
71
- await this.save();
72
- return normalized;
216
+ return this.mutate(async (tasks) => {
217
+ const normalized = normalizeTask(task, defaults);
218
+ tasks.push(normalized);
219
+ return { result: structuredClone(normalized) };
220
+ });
73
221
  }
74
222
 
75
- async addMany(tasks = [], defaults = {}) {
76
- const created = [];
77
- for (const task of tasks) {
78
- created.push(await this.add(task, defaults));
79
- }
80
- return created;
223
+ async addMany(tasksToAdd = [], defaults = {}) {
224
+ return this.mutate(async (tasks) => {
225
+ const created = tasksToAdd.map((task) => normalizeTask(task, defaults));
226
+ tasks.push(...created);
227
+ return { result: structuredClone(created), changed: created.length > 0 };
228
+ });
81
229
  }
82
230
 
83
231
  async claimDue(limit = 10) {
84
- await this.reload();
85
- const now = Date.now();
86
- const due = [];
87
-
88
- for (const task of this.tasks) {
89
- if (due.length >= limit) break;
90
- if (task.status !== "pending") continue;
91
- if (!task.runAt || Number.isNaN(Date.parse(task.runAt))) continue;
92
- if (Date.parse(task.runAt) > now) continue;
93
- task.status = "running";
94
- task.updatedAt = new Date().toISOString();
95
- due.push({ ...task });
96
- }
232
+ return this.mutate(async (tasks) => {
233
+ const now = Date.now();
234
+ const due = tasks
235
+ .filter((task) => task.status === "pending"
236
+ && task.runAt
237
+ && !Number.isNaN(Date.parse(task.runAt))
238
+ && Date.parse(task.runAt) <= now)
239
+ .sort((left, right) => Date.parse(left.runAt) - Date.parse(right.runAt)
240
+ || Date.parse(left.createdAt || 0) - Date.parse(right.createdAt || 0)
241
+ || String(left.id).localeCompare(String(right.id)))
242
+ .slice(0, limit);
97
243
 
98
- if (due.length) await this.save();
99
- return due;
244
+ const claimedAt = new Date(now).toISOString();
245
+ for (const task of due) {
246
+ task.status = "running";
247
+ task.attempts += 1;
248
+ task.startedAt = claimedAt;
249
+ task.claimedAt = claimedAt;
250
+ delete task.executionStartedAt;
251
+ task.updatedAt = claimedAt;
252
+ }
253
+
254
+ return { result: structuredClone(due), changed: due.length > 0 };
255
+ });
256
+ }
257
+
258
+ async markExecutionStarted(taskId) {
259
+ return this.mutate(async (tasks) => {
260
+ const task = tasks.find((item) => item.id === taskId);
261
+ if (!task || task.status !== "running") return { result: null, changed: false };
262
+ task.executionStartedAt = new Date().toISOString();
263
+ task.updatedAt = task.executionStartedAt;
264
+ return { result: structuredClone(task) };
265
+ });
100
266
  }
101
267
 
102
268
  async recoverInterrupted() {
103
- await this.reload();
104
- const recovered = [];
105
- const updatedAt = new Date().toISOString();
269
+ return this.mutate(async (tasks) => {
270
+ const recovered = [];
271
+ let compacted = false;
272
+ const now = Date.now();
106
273
 
107
- for (const task of this.tasks) {
108
- if (task.status !== "running") continue;
109
- task.status = "pending";
110
- task.updatedAt = updatedAt;
111
- recovered.push({ ...task });
112
- }
274
+ for (const task of tasks) {
275
+ if (task.status === "running") {
276
+ const interruptedAt = new Date(now).toISOString();
277
+ if (task.claimedAt && !task.executionStartedAt) {
278
+ task.status = "pending";
279
+ task.attempts = Math.max(0, Number(task.attempts || 0) - 1);
280
+ task.lastError = "execution interrupted before start";
281
+ task.lastFailedAt = interruptedAt;
282
+ task.updatedAt = interruptedAt;
283
+ task.lastClaimedAt = task.claimedAt;
284
+ delete task.startedAt;
285
+ delete task.claimedAt;
286
+ recovered.push(structuredClone(task));
287
+ continue;
288
+ }
289
+ task.lastError = "execution interrupted before confirmation";
290
+ task.lastFailedAt = interruptedAt;
291
+ task.updatedAt = interruptedAt;
292
+ if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
293
+ if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
294
+ delete task.claimedAt;
295
+ delete task.executionStartedAt;
296
+ if (task.kind === "poll_tool") {
297
+ task.status = "pending";
298
+ task.runAt = new Date(now + retryDelayMs(task)).toISOString();
299
+ delete task.startedAt;
300
+ } else {
301
+ const nextRunAt = computeNextRunAt(task, now);
302
+ if (nextRunAt) {
303
+ task.status = "pending";
304
+ task.runAt = nextRunAt;
305
+ task.attempts = 0;
306
+ task.lastOutcome = "outcome_uncertain";
307
+ delete task.startedAt;
308
+ } else {
309
+ task.status = "outcome_uncertain";
310
+ task.error = task.lastError;
311
+ }
312
+ }
313
+ compactTerminalTask(task);
314
+ recovered.push(structuredClone(task));
315
+ continue;
316
+ }
317
+ if (compactTerminalTask(task)) compacted = true;
318
+ }
113
319
 
114
- if (recovered.length) await this.save();
115
- return recovered;
320
+ return { result: recovered, changed: recovered.length > 0 || compacted };
321
+ });
116
322
  }
117
323
 
118
324
  async complete(taskId) {
119
- await this.init();
120
- const task = this.tasks.find((item) => item.id === taskId);
121
- if (!task) return null;
325
+ return this.mutate(async (tasks) => {
326
+ const task = tasks.find((item) => item.id === taskId);
327
+ if (!task) return { result: null, changed: false };
328
+
329
+ const now = Date.now();
330
+ const completedAt = new Date(now).toISOString();
331
+ const nextRunAt = computeNextRunAt(task, now);
332
+ task.lastCompletedAt = completedAt;
333
+ task.lastRunAt = completedAt;
334
+ if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
335
+ if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
336
+ delete task.lastError;
337
+ delete task.error;
338
+ delete task.lastOutcome;
339
+ delete task.consecutiveFailures;
340
+ delete task.claimedAt;
341
+ delete task.executionStartedAt;
342
+ if (nextRunAt) {
343
+ task.status = "pending";
344
+ task.runAt = nextRunAt;
345
+ task.attempts = 0;
346
+ delete task.startedAt;
347
+ } else {
348
+ task.status = "done";
349
+ task.completedAt = completedAt;
350
+ }
351
+ task.updatedAt = completedAt;
352
+ compactTerminalTask(task);
353
+ return { result: structuredClone(task) };
354
+ });
355
+ }
356
+
357
+ async retryOrFail(taskId, error, { retryable = true, outcomeUncertain = false } = {}) {
358
+ return this.mutate(async (tasks) => {
359
+ const task = tasks.find((item) => item.id === taskId);
360
+ if (!task) return { result: null, changed: false };
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ if (outcomeUncertain) {
363
+ const now = Date.now();
364
+ const uncertainAt = new Date(now).toISOString();
365
+ const nextRunAt = computeNextRunAt(task, now);
366
+ task.lastError = message;
367
+ task.lastFailedAt = uncertainAt;
368
+ task.updatedAt = uncertainAt;
369
+ if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
370
+ if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
371
+ delete task.claimedAt;
372
+ delete task.executionStartedAt;
373
+ if (nextRunAt) {
374
+ task.status = "pending";
375
+ task.runAt = nextRunAt;
376
+ task.attempts = 0;
377
+ task.lastOutcome = "outcome_uncertain";
378
+ delete task.startedAt;
379
+ delete task.error;
380
+ return { result: { ...structuredClone(task), terminalFailure: true } };
381
+ }
382
+ task.status = "outcome_uncertain";
383
+ task.error = message;
384
+ task.uncertainAt = uncertainAt;
385
+ compactTerminalTask(task);
386
+ return { result: structuredClone(task) };
387
+ }
388
+ if (!retryable) return { result: failTask(task, message) };
389
+ if (task.attempts >= task.retry.maxAttempts) {
390
+ const now = Date.now();
391
+ const nextRunAt = computeNextRunAt(task, now);
392
+ if (!nextRunAt) return { result: failTask(task, message) };
393
+
394
+ const failedAt = new Date(now).toISOString();
395
+ task.status = "pending";
396
+ task.runAt = nextRunAt;
397
+ task.attempts = 0;
398
+ task.lastOutcome = "failed";
399
+ task.lastError = message;
400
+ task.lastFailedAt = failedAt;
401
+ task.consecutiveFailures = Number(task.consecutiveFailures || 0) + 1;
402
+ task.updatedAt = failedAt;
403
+ if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
404
+ if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
405
+ delete task.startedAt;
406
+ delete task.claimedAt;
407
+ delete task.executionStartedAt;
408
+ delete task.error;
409
+ return { result: { ...structuredClone(task), terminalFailure: true } };
410
+ }
122
411
 
123
- const nextRunAt = computeNextRunAt(task);
124
- if (nextRunAt) {
412
+ const now = Date.now();
125
413
  task.status = "pending";
126
- task.runAt = nextRunAt;
127
- task.lastRunAt = new Date().toISOString();
128
- } else {
129
- task.status = "done";
130
- task.completedAt = new Date().toISOString();
131
- }
132
- task.updatedAt = new Date().toISOString();
133
- await this.save();
134
- return task;
414
+ task.runAt = new Date(now + retryDelayMs(task)).toISOString();
415
+ task.lastError = message;
416
+ task.lastFailedAt = new Date(now).toISOString();
417
+ task.updatedAt = task.lastFailedAt;
418
+ if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
419
+ if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
420
+ delete task.startedAt;
421
+ delete task.claimedAt;
422
+ delete task.executionStartedAt;
423
+ return { result: structuredClone(task) };
424
+ });
135
425
  }
136
426
 
137
427
  async fail(taskId, error) {
138
- await this.init();
139
- const task = this.tasks.find((item) => item.id === taskId);
140
- if (!task) return null;
141
- task.status = "failed";
142
- task.error = error;
143
- task.updatedAt = new Date().toISOString();
144
- await this.save();
145
- return task;
428
+ return this.mutate(async (tasks) => {
429
+ const task = tasks.find((item) => item.id === taskId);
430
+ return task
431
+ ? { result: failTask(task, error) }
432
+ : { result: null, changed: false };
433
+ });
146
434
  }
147
435
 
148
436
  async list(filter = {}) {
149
437
  await this.reload();
150
438
  return this.tasks.filter((task) => {
151
- if (filter.chatId && task.payload?.chatId !== filter.chatId) return false;
439
+ if (filter.chatId && String(task.payload?.chatId) !== String(filter.chatId)) return false;
152
440
  if (filter.status && task.status !== filter.status) return false;
153
441
  if (filter.kind && task.kind !== filter.kind) return false;
154
442
  return true;
155
- });
443
+ }).map((task) => structuredClone(task));
156
444
  }
157
445
 
158
446
  async get(taskId) {
159
447
  await this.reload();
160
- return this.tasks.find((item) => item.id === taskId) || null;
448
+ const task = this.tasks.find((item) => item.id === taskId);
449
+ return task ? structuredClone(task) : null;
161
450
  }
162
451
 
163
452
  async cancel(taskId) {
164
- await this.reload();
165
- const index = this.tasks.findIndex((item) => item.id === taskId);
166
- if (index === -1) return null;
167
- const [task] = this.tasks.splice(index, 1);
168
- await this.save();
169
- return task;
453
+ return this.mutate(async (tasks) => {
454
+ const index = tasks.findIndex((item) => item.id === taskId);
455
+ if (index === -1) return { result: null, changed: false };
456
+ const [task] = tasks.splice(index, 1);
457
+ return { result: structuredClone(task) };
458
+ });
170
459
  }
171
460
 
172
461
  async cancelAll(filter = {}) {
173
- await this.reload();
174
- const removed = [];
175
- this.tasks = this.tasks.filter((task) => {
176
- if (filter.chatId && task.payload?.chatId !== filter.chatId) return true;
177
- if (filter.status && task.status !== filter.status) return true;
178
- if (task.status === "done" || task.status === "failed") return true;
179
- removed.push({ ...task });
180
- return false;
462
+ return this.mutate(async (tasks) => {
463
+ const removed = [];
464
+ const remaining = tasks.filter((task) => {
465
+ if (filter.chatId && String(task.payload?.chatId) !== String(filter.chatId)) return true;
466
+ if (filter.status && task.status !== filter.status) return true;
467
+ if (["done", "failed", "outcome_uncertain"].includes(task.status)) return true;
468
+ removed.push(structuredClone(task));
469
+ return false;
470
+ });
471
+ tasks.splice(0, tasks.length, ...remaining);
472
+ return { result: removed, changed: removed.length > 0 };
181
473
  });
182
- if (removed.length) await this.save();
183
- return removed;
184
474
  }
185
475
  }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { unlink } from "node:fs/promises";
3
+ import { taskWithoutCallerRouting } from "../tasks/task-routing.js";
3
4
 
4
5
  export async function materializeToolOutput({ result, name, chatId, artifactStore, taskStore, taskContext = null }) {
5
6
  const chatArtifactStore = artifactStore.forChat(chatId);
@@ -27,11 +28,10 @@ export async function materializeToolOutput({ result, name, chatId, artifactStor
27
28
  }
28
29
 
29
30
  if (result.asyncTask || result.asyncTasks?.length) {
30
- result.asyncTasks = await taskStore.addMany(result.asyncTasks || [result.asyncTask], {
31
- payload: {
32
- chatId,
33
- ...(taskContext ? { telegramContext: taskContext } : {})
34
- },
31
+ const requestedTasks = (result.asyncTasks || [result.asyncTask]).map(taskWithoutCallerRouting);
32
+ result.asyncTasks = await taskStore.addMany(requestedTasks, {
33
+ payload: { chatId },
34
+ ...(taskContext ? { route: taskContext } : {}),
35
35
  source: { type: "tool", toolName: name, chatId }
36
36
  });
37
37
  delete result.asyncTask;
@@ -11,6 +11,7 @@ import { daemonConfigDefaults } from "../config/config-defaults.js";
11
11
  import { SkillRegistry } from "../skills/skill-registry.js";
12
12
  import { ToolUsageStore } from "./tool-usage-store.js";
13
13
  import { inspectToolDependencies, normalizeToolDependencies } from "./tool-dependencies.js";
14
+ import { normalizeToolExecution, WeightedResourceGovernor } from "./weighted-resource-governor.js";
14
15
 
15
16
  function toolEnv() {
16
17
  return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
@@ -272,7 +273,9 @@ export class ToolRegistry {
272
273
  resolveOfficialToolNames = readOfficialToolNames,
273
274
  helpTimeoutMs = defaultToolHelpTimeoutMs,
274
275
  runTimeoutMs = defaultToolRunTimeoutMs,
275
- killGraceMs = defaultToolKillGraceMs
276
+ killGraceMs = defaultToolKillGraceMs,
277
+ executionPolicy,
278
+ executionGovernor
276
279
  } = {}) {
277
280
  this.logger = logger;
278
281
  this.helpTimeoutMs = positiveDuration(helpTimeoutMs, defaultToolHelpTimeoutMs);
@@ -282,6 +285,10 @@ export class ToolRegistry {
282
285
  this.skillRegistry = new SkillRegistry();
283
286
  this.usageStore = usageStore;
284
287
  this.resolveOfficialToolNames = resolveOfficialToolNames;
288
+ this.executionGovernor = executionGovernor || new WeightedResourceGovernor({
289
+ policy: executionPolicy,
290
+ logger
291
+ });
285
292
  }
286
293
 
287
294
  async buildSnapshot() {
@@ -308,6 +315,7 @@ export class ToolRegistry {
308
315
  snapshot.set(manifest.name, {
309
316
  ...manifest,
310
317
  toolDependencies: normalizeToolDependencies(manifest.toolDependencies),
318
+ execution: normalizeToolExecution(manifest.execution),
311
319
  category: normalizeCategory(manifest.category),
312
320
  keywords: normalizeKeywords(manifest.keywords),
313
321
  skillHints,
@@ -450,6 +458,10 @@ export class ToolRegistry {
450
458
  return inspectToolDependencies(this.tools, name);
451
459
  }
452
460
 
461
+ executionDiagnostic() {
462
+ return this.executionGovernor.snapshot();
463
+ }
464
+
453
465
  async usage(chatId) {
454
466
  const [counts, officialNames] = await Promise.all([
455
467
  this.usageStore.counts(chatId),
@@ -475,14 +487,16 @@ export class ToolRegistry {
475
487
  await this.usageStore.record(chatId, name).catch((error) => {
476
488
  this.logger?.error("tools", `could not record ${name} usage: ${error?.message || String(error)}`);
477
489
  });
478
- this.logger?.log("tools", `running ${name}`);
479
490
  const tmpDir = chatId != null ? getChatToolTmpDir(chatId, name) : getToolTmpDir(name);
480
- await mkdir(tmpDir, { recursive: true });
481
491
  const requestFile = path.join(tmpDir, `.request-${Date.now()}-${randomUUID()}.json`);
482
- const skills = await this.resolveSkills(name);
483
- const enrichedRequest = { ...request, chatId, skills };
492
+ let lease = null;
484
493
  let result;
485
494
  try {
495
+ lease = await this.executionGovernor.acquire(tool.execution, name);
496
+ this.logger?.log("tools", `running ${name}`);
497
+ await mkdir(tmpDir, { recursive: true });
498
+ const skills = await this.resolveSkills(name);
499
+ const enrichedRequest = { ...request, chatId, skills };
486
500
  if (tool.daemon?.protocol === "arisa-daemon-v1") {
487
501
  const scope = tool.daemon.scope === "chat"
488
502
  ? { type: "chat", chatId }
@@ -538,6 +552,7 @@ export class ToolRegistry {
538
552
  error: error?.message || `Invalid tool response for ${name}`
539
553
  });
540
554
  } finally {
555
+ lease?.release();
541
556
  await unlink(requestFile).catch(() => {});
542
557
  await rmdir(tmpDir).catch(() => {});
543
558
  if (chatId != null) {