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,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -9,7 +9,7 @@ process.env.HOME = homeDir;
9
9
  process.env.USERPROFILE = homeDir;
10
10
 
11
11
  const { TaskStore } = await import("../src/core/tasks/task-store.js");
12
- const { arisaHomeDir } = await import("../src/runtime/paths.js");
12
+ const { arisaHomeDir, tasksFile } = await import("../src/runtime/paths.js");
13
13
 
14
14
  async function resetHome() {
15
15
  await rm(arisaHomeDir, { recursive: true, force: true });
@@ -59,12 +59,37 @@ test("claims only due pending tasks and marks them running", async () => {
59
59
  assert.equal((await store.get("done")).status, "done");
60
60
  });
61
61
 
62
+ test("claims overdue tasks chronologically and records actual execution start", async () => {
63
+ await resetHome();
64
+ const store = new TaskStore();
65
+ const older = new Date(Date.now() - 2_000).toISOString();
66
+ const newer = new Date(Date.now() - 1_000).toISOString();
67
+ await store.add({ id: "newer", kind: "agent_task", runAt: newer });
68
+ await store.add({ id: "older", kind: "agent_task", runAt: older });
69
+
70
+ const claimed = await store.claimDue(2);
71
+ assert.deepEqual(claimed.map((task) => task.id), ["older", "newer"]);
72
+ assert.ok(claimed.every((task) => task.claimedAt));
73
+ assert.equal(claimed[0].executionStartedAt, undefined);
74
+
75
+ const started = await store.markExecutionStarted("older");
76
+ assert.ok(started.executionStartedAt);
77
+ });
78
+
62
79
  test("recovers interrupted running tasks for retry after restart", async () => {
63
80
  await resetHome();
64
81
  const store = new TaskStore();
65
82
  const runAt = new Date(Date.now() - 1000).toISOString();
66
83
 
67
84
  await store.add({ id: "interrupted-once", kind: "agent_task", runAt, status: "running" });
85
+ await store.add({
86
+ id: "claimed-not-started",
87
+ kind: "agent_task",
88
+ runAt,
89
+ status: "running",
90
+ attempts: 1,
91
+ claimedAt: new Date().toISOString()
92
+ });
68
93
  await store.add({
69
94
  id: "interrupted-recurring",
70
95
  kind: "poll_tool",
@@ -77,16 +102,22 @@ test("recovers interrupted running tasks for retry after restart", async () => {
77
102
 
78
103
  const recovered = await store.recoverInterrupted();
79
104
 
80
- assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "interrupted-recurring"]);
81
- assert.ok(recovered.every((task) => task.status === "pending"));
82
- assert.ok(recovered.every((task) => task.runAt === runAt));
105
+ assert.deepEqual(recovered.map((task) => task.id), ["interrupted-once", "claimed-not-started", "interrupted-recurring"]);
106
+ assert.equal(recovered[0].status, "outcome_uncertain");
107
+ assert.equal(recovered[1].status, "pending");
108
+ assert.equal(recovered[1].attempts, 0);
109
+ assert.equal(recovered[1].lastError, "execution interrupted before start");
110
+ assert.equal(recovered[2].status, "pending");
111
+ assert.ok(Date.parse(recovered[2].runAt) > Date.now());
112
+ assert.equal(recovered[0].lastError, "execution interrupted before confirmation");
113
+ assert.equal(recovered[2].lastError, "execution interrupted before confirmation");
83
114
  assert.equal((await store.get("still-pending")).status, "pending");
84
115
  assert.equal((await store.get("already-done")).status, "done");
85
116
 
86
117
  const restartedStore = new TaskStore();
87
118
  assert.deepEqual(
88
119
  (await restartedStore.claimDue()).map((task) => task.id),
89
- ["interrupted-once", "interrupted-recurring", "still-pending"]
120
+ ["claimed-not-started", "still-pending"]
90
121
  );
91
122
  });
92
123
 
@@ -114,6 +145,198 @@ test("completes one-off tasks and re-schedules recurring interval tasks", async
114
145
  assert.ok(repeat.lastRunAt);
115
146
  });
116
147
 
148
+ test("compacts terminal payloads while preserving routing and audit identifiers", async () => {
149
+ await resetHome();
150
+ const store = new TaskStore();
151
+ const route = { transport: "telegram", destination: { chatId: -1001, threadId: 87 } };
152
+ const payload = {
153
+ chatId: "chat-1",
154
+ prompt: "private operational prompt ".repeat(200),
155
+ args: { cursor: "large private state".repeat(100) },
156
+ acknowledgement: "received",
157
+ toolName: "checker",
158
+ resourceId: "inbox:primary",
159
+ artifactId: "artifact-1"
160
+ };
161
+ await store.add({ id: "compact-done", kind: "agent_task", payload, route });
162
+ await store.add({ id: "compact-failed", kind: "agent_task", payload });
163
+ await store.add({ id: "compact-uncertain", kind: "agent_task", payload });
164
+
165
+ const done = await store.complete("compact-done");
166
+ const failed = await store.fail("compact-failed", "permanent failure");
167
+ const uncertain = await store.retryOrFail("compact-uncertain", "unknown outcome", {
168
+ retryable: false,
169
+ outcomeUncertain: true
170
+ });
171
+ const expectedPayload = {
172
+ chatId: "chat-1",
173
+ toolName: "checker",
174
+ resourceId: "inbox:primary",
175
+ artifactId: "artifact-1"
176
+ };
177
+
178
+ for (const task of [done, failed, uncertain]) {
179
+ assert.deepEqual(task.payload, expectedPayload);
180
+ assert.equal(task.payloadCompacted, true);
181
+ assert.equal(task.retry, undefined);
182
+ assert.equal(task.recurrence, undefined);
183
+ }
184
+ assert.deepEqual(done.route, route);
185
+ assert.ok(done.completedAt);
186
+ assert.equal(failed.error, "permanent failure");
187
+ assert.ok(uncertain.uncertainAt);
188
+ assert.deepEqual(
189
+ (await store.list({ chatId: "chat-1" })).map((task) => task.id),
190
+ ["compact-done", "compact-failed", "compact-uncertain"]
191
+ );
192
+ });
193
+
194
+ test("startup recovery compacts historical terminal tasks without changing active payloads", async () => {
195
+ await resetHome();
196
+ await mkdir(path.dirname(tasksFile), { recursive: true });
197
+ await writeFile(tasksFile, `${JSON.stringify([
198
+ {
199
+ id: "historical-done",
200
+ kind: "agent_task",
201
+ status: "done",
202
+ payload: { chatId: "chat-1", prompt: "obsolete prompt", args: { large: "value" } },
203
+ retry: { maxAttempts: 3 },
204
+ recurrence: null
205
+ },
206
+ {
207
+ id: "active-pending",
208
+ kind: "agent_task",
209
+ status: "pending",
210
+ runAt: new Date(Date.now() + 60_000).toISOString(),
211
+ payload: { chatId: "chat-1", prompt: "still required" }
212
+ }
213
+ ], null, 2)}\n`, "utf8");
214
+
215
+ const store = new TaskStore();
216
+ assert.deepEqual(await store.recoverInterrupted(), []);
217
+ const persisted = JSON.parse(await readFile(tasksFile, "utf8"));
218
+ const historical = persisted.find((task) => task.id === "historical-done");
219
+ const active = persisted.find((task) => task.id === "active-pending");
220
+
221
+ assert.deepEqual(historical.payload, { chatId: "chat-1" });
222
+ assert.equal(historical.payloadCompacted, true);
223
+ assert.equal(historical.retry, undefined);
224
+ assert.equal(historical.recurrence, undefined);
225
+ assert.equal(active.payload.prompt, "still required");
226
+ assert.ok(active.retry);
227
+ });
228
+
229
+ test("backs off known failures and fails after the attempt limit", async () => {
230
+ await resetHome();
231
+ const store = new TaskStore();
232
+ const runAt = new Date(Date.now() - 1000).toISOString();
233
+ await store.add({
234
+ id: "retrying",
235
+ kind: "agent_task",
236
+ runAt,
237
+ retry: { maxAttempts: 2, baseDelaySeconds: 1, maxDelaySeconds: 10, multiplier: 2 }
238
+ });
239
+
240
+ await store.claimDue();
241
+ const retrying = await store.retryOrFail("retrying", "temporary");
242
+ assert.equal(retrying.status, "pending");
243
+ assert.equal(retrying.attempts, 1);
244
+ assert.ok(Date.parse(retrying.runAt) > Date.now());
245
+
246
+ retrying.runAt = runAt;
247
+ store.tasks.find((task) => task.id === "retrying").runAt = runAt;
248
+ await store.save();
249
+ await store.claimDue();
250
+ const failed = await store.retryOrFail("retrying", "still broken");
251
+ assert.equal(failed.status, "failed");
252
+ assert.equal(failed.attempts, 2);
253
+ });
254
+
255
+ test("keeps recurring tasks scheduled after a run exhausts its retries", async () => {
256
+ await resetHome();
257
+ const store = new TaskStore();
258
+ const past = new Date(Date.now() - 1000).toISOString();
259
+ await store.add({
260
+ id: "recurring-failure",
261
+ kind: "agent_task",
262
+ runAt: past,
263
+ recurrence: { type: "interval", everySeconds: 60 },
264
+ retry: { maxAttempts: 1 }
265
+ });
266
+
267
+ await store.claimDue();
268
+ const result = await store.retryOrFail("recurring-failure", "temporary outage");
269
+
270
+ assert.equal(result.status, "pending");
271
+ assert.equal(result.terminalFailure, true);
272
+ assert.equal(result.lastOutcome, "failed");
273
+ assert.equal(result.lastError, "temporary outage");
274
+ assert.equal(result.attempts, 0);
275
+ assert.equal(result.consecutiveFailures, 1);
276
+ assert.ok(Date.parse(result.runAt) > Date.now());
277
+
278
+ const persisted = await store.get("recurring-failure");
279
+ assert.equal(persisted.terminalFailure, undefined);
280
+ assert.equal(persisted.status, "pending");
281
+ });
282
+
283
+ test("moves legacy Telegram routing out of task payloads", async () => {
284
+ await resetHome();
285
+ const store = new TaskStore();
286
+ const task = await store.add({
287
+ id: "routed",
288
+ kind: "agent_task",
289
+ payload: {
290
+ chatId: "owner",
291
+ telegramContext: { transportChatId: -1001, messageThreadId: 87 }
292
+ }
293
+ });
294
+
295
+ assert.deepEqual(task.route, {
296
+ transport: "telegram",
297
+ destination: { chatId: -1001, threadId: 87 }
298
+ });
299
+ assert.equal(task.payload.telegramContext, undefined);
300
+ });
301
+
302
+ test("keeps recurring tasks scheduled after an uncertain outcome without replaying the occurrence", async () => {
303
+ await resetHome();
304
+ const store = new TaskStore();
305
+ await store.add({
306
+ id: "recurring-uncertain",
307
+ kind: "agent_task",
308
+ status: "running",
309
+ attempts: 1,
310
+ recurrence: { type: "interval", everySeconds: 60 }
311
+ });
312
+
313
+ const task = await store.retryOrFail("recurring-uncertain", "deadline exceeded", {
314
+ retryable: false,
315
+ outcomeUncertain: true
316
+ });
317
+
318
+ assert.equal(task.status, "pending");
319
+ assert.equal(task.lastOutcome, "outcome_uncertain");
320
+ assert.equal(task.attempts, 0);
321
+ assert.equal(task.terminalFailure, true);
322
+ assert.ok(Date.parse(task.runAt) > Date.now());
323
+ });
324
+
325
+ test("records uncertain outcomes without retrying", async () => {
326
+ await resetHome();
327
+ const store = new TaskStore();
328
+ await store.add({ id: "uncertain", kind: "agent_task", status: "running", attempts: 1 });
329
+
330
+ const task = await store.retryOrFail("uncertain", "turn interrupted", {
331
+ retryable: false,
332
+ outcomeUncertain: true
333
+ });
334
+
335
+ assert.equal(task.status, "outcome_uncertain");
336
+ assert.equal(task.error, "turn interrupted");
337
+ assert.ok(task.uncertainAt);
338
+ });
339
+
117
340
  test("fails and cancels tasks by id", async () => {
118
341
  await resetHome();
119
342
  const store = new TaskStore();
@@ -138,6 +361,7 @@ test("cancelAll preserves done and failed tasks and respects chat filters", asyn
138
361
  await store.add({ id: "chat-2-pending", kind: "agent_task" }, { payload: { chatId: "chat-2" } });
139
362
  await store.add({ id: "chat-1-done", kind: "agent_task", status: "done" }, { payload: { chatId: "chat-1" } });
140
363
  await store.add({ id: "chat-1-failed", kind: "agent_task", status: "failed" }, { payload: { chatId: "chat-1" } });
364
+ await store.add({ id: "chat-1-uncertain", kind: "agent_task", status: "outcome_uncertain" }, { payload: { chatId: "chat-1" } });
141
365
 
142
366
  const removed = await store.cancelAll({ chatId: "chat-1" });
143
367
  const remaining = await store.list();
@@ -145,6 +369,6 @@ test("cancelAll preserves done and failed tasks and respects chat filters", asyn
145
369
  assert.deepEqual(removed.map((task) => task.id), ["chat-1-pending"]);
146
370
  assert.deepEqual(
147
371
  remaining.map((task) => task.id),
148
- ["chat-2-pending", "chat-1-done", "chat-1-failed"]
372
+ ["chat-2-pending", "chat-1-done", "chat-1-failed", "chat-1-uncertain"]
149
373
  );
150
374
  });
@@ -0,0 +1,94 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ appendGeneralReplyRoutingInstruction,
5
+ extractReplyTopicMetadata,
6
+ isGeneralWorkspaceRoute,
7
+ routeGeneralWorkspaceReply
8
+ } from "../src/transport/telegram/reply-topic-routing.js";
9
+
10
+ const topics = [
11
+ { threadId: 23, name: "Stories", description: "Published stories and editorial work" },
12
+ { threadId: 87, name: "CBPR", description: "Castle Bravo press campaign" },
13
+ { threadId: 114, name: "CORE", description: "Arisa core engineering" }
14
+ ];
15
+
16
+ const generalRoute = {
17
+ workspace: true,
18
+ ownerChatId: 42,
19
+ sessionId: "42",
20
+ scopeChatId: 42,
21
+ transportChatId: -100123,
22
+ threadId: null,
23
+ topicThreadId: 1,
24
+ generalTopicId: 1
25
+ };
26
+
27
+ test("General prompts describe conservative dynamic reply routing", () => {
28
+ const prompt = appendGeneralReplyRoutingInstruction("Incoming Telegram message.", topics, [
29
+ { name: "Infrastructure", proposedAt: "2026-08-24T00:00:00.000Z" }
30
+ ]);
31
+ assert.match(prompt, /only because the incoming message was written in General/);
32
+ assert.match(prompt, /never applies to a private chat/);
33
+ assert.match(prompt, /original message must stay in General/);
34
+ assert.match(prompt, /23: Stories/);
35
+ assert.match(prompt, /87: CBPR/);
36
+ assert.match(prompt, /114: CORE/);
37
+ assert.match(prompt, /Do not repeat these recent topic proposals: Infrastructure/);
38
+ assert.match(prompt, /Never create a topic without explicit confirmation/);
39
+ });
40
+
41
+ test("an allowed trailing marker is stripped and selects its topic", () => {
42
+ assert.deepEqual(extractReplyTopicMetadata("Implemented.\n[[ARISA_REPLY_TOPIC:114]]", topics), {
43
+ text: "Implemented.",
44
+ threadId: 114,
45
+ proposal: ""
46
+ });
47
+ });
48
+
49
+ test("invalid markers are stripped without rerouting", () => {
50
+ assert.deepEqual(extractReplyTopicMetadata("Keep this here.\n[[ARISA_REPLY_TOPIC:999]]", topics), {
51
+ text: "Keep this here.",
52
+ threadId: null,
53
+ proposal: ""
54
+ });
55
+ });
56
+
57
+ test("topic proposals are stripped and returned as transport metadata", () => {
58
+ assert.deepEqual(extractReplyTopicMetadata("Should we create it?\n[[ARISA_PROPOSE_TOPIC:Research Lab]]", topics), {
59
+ text: "Should we create it?",
60
+ threadId: null,
61
+ proposal: "Research Lab"
62
+ });
63
+ });
64
+
65
+ test("only replies originating in a supergroup General topic can change destination", () => {
66
+ assert.equal(isGeneralWorkspaceRoute(generalRoute), true);
67
+ const routed = routeGeneralWorkspaceReply({
68
+ route: generalRoute,
69
+ topics,
70
+ text: "Core update.\n[[ARISA_REPLY_TOPIC:114]]"
71
+ });
72
+ assert.equal(routed.text, "Core update.");
73
+ assert.equal(routed.route.sessionId, "42");
74
+ assert.equal(routed.route.threadId, 114);
75
+ assert.equal(routed.topic.name, "CORE");
76
+
77
+ const privateRoute = {
78
+ workspace: false,
79
+ sessionId: "42",
80
+ scopeChatId: 42,
81
+ transportChatId: 42,
82
+ threadId: null
83
+ };
84
+ assert.equal(isGeneralWorkspaceRoute(privateRoute), false);
85
+ const privateReply = routeGeneralWorkspaceReply({
86
+ route: privateRoute,
87
+ topics,
88
+ text: "Private response.\n[[ARISA_REPLY_TOPIC:114]]"
89
+ });
90
+ assert.equal(privateReply.route, privateRoute);
91
+ assert.equal(privateReply.topic, null);
92
+ assert.equal(privateReply.proposal, "");
93
+ assert.equal(privateReply.text, "Private response.");
94
+ });
@@ -5,8 +5,12 @@ import { createTelegramTaskDispatcher } from "../src/transport/telegram/task-dis
5
5
  function createHarness(overrides = {}) {
6
6
  const calls = [];
7
7
  const taskStore = {
8
- async fail(...args) { calls.push(["fail", ...args]); },
9
- async complete(...args) { calls.push(["complete", ...args]); },
8
+ async fail(...args) { calls.push(["fail", ...args]); return { status: "failed" }; },
9
+ async complete(...args) { calls.push(["complete", ...args]); return { status: "done" }; },
10
+ async retryOrFail(taskId, error, options) {
11
+ calls.push(["retryOrFail", taskId, error.message, options]);
12
+ return { status: options.retryable ? "pending" : "failed" };
13
+ },
10
14
  async claimDue() { return []; },
11
15
  ...overrides.taskStore
12
16
  };
@@ -17,31 +21,65 @@ function createHarness(overrides = {}) {
17
21
  artifactStore: { forChat() { throw new Error("unexpected artifact access"); } },
18
22
  toolRegistry: {},
19
23
  resourceNotes: { async get() { return ""; } },
20
- agentManager: { async runTool(input) { calls.push(["runTool", input]); } },
24
+ agentManager: { async runTool(input) { calls.push(["runTool", input]); return { ok: true }; } },
21
25
  logger: null,
22
26
  ...overrides.dependencies
23
27
  });
24
28
  return { calls, taskStore, dispatcher };
25
29
  }
26
30
 
27
- test("dispatches an agent task into the Telegram prompt queue", async () => {
28
- const { calls, dispatcher } = createHarness();
29
- await dispatcher.dispatchTask({
31
+ test("confirms an agent task only after prompt execution resolves", async () => {
32
+ let confirmExecution;
33
+ const execution = new Promise((resolve) => { confirmExecution = resolve; });
34
+ const { calls, dispatcher } = createHarness({
35
+ dependencies: {
36
+ enqueueAsyncPrompt: async (input) => {
37
+ calls.push(["enqueue", input]);
38
+ await execution;
39
+ }
40
+ }
41
+ });
42
+ const running = dispatcher.runClaimedTask({
30
43
  id: "task-1",
31
44
  kind: "agent_task",
32
- payload: { chatId: 123, prompt: "do the thing", telegramContext: { messageThreadId: 9 } }
45
+ status: "running",
46
+ payload: { chatId: 123, prompt: "do the thing" },
47
+ route: { transport: "telegram", destination: { chatId: -1001, threadId: 9 } }
33
48
  });
34
49
 
50
+ await new Promise((resolve) => setImmediate(resolve));
35
51
  assert.equal(calls[0][0], "enqueue");
36
- assert.equal(calls[0][1].chatId, 123);
37
- assert.match(calls[0][1].prompt, /taskId: task-1/);
38
- assert.match(calls[0][1].prompt, /text: do the thing/);
39
- assert.deepEqual(calls[1], ["complete", "task-1"]);
52
+ assert.deepEqual(calls[0][1].route, { transport: "telegram", destination: { chatId: -1001, threadId: 9 } });
53
+ assert.equal(calls[0][1].timeoutMs, 15 * 60_000);
54
+ assert.equal(calls.some(([name]) => name === "complete"), false);
55
+ confirmExecution();
56
+ await running;
57
+ assert.deepEqual(calls.at(-1), ["complete", "task-1"]);
40
58
  });
41
59
 
42
- test("acknowledges an agent event before queueing it", async () => {
60
+ test("passes bounded execution deadlines to scheduled prompts", async () => {
61
+ const { calls, dispatcher } = createHarness({
62
+ dependencies: { taskTimeouts: { agentTimeoutMs: 900, eventTimeoutMs: 300 } }
63
+ });
64
+ await dispatcher.runClaimedTask({
65
+ id: "deadline-task",
66
+ kind: "agent_task",
67
+ payload: { chatId: 123, prompt: "work" }
68
+ });
69
+ await dispatcher.runClaimedTask({
70
+ id: "deadline-event",
71
+ kind: "agent_event",
72
+ payload: { chatId: 123, prompt: "event" }
73
+ });
74
+
75
+ const enqueues = calls.filter(([name]) => name === "enqueue");
76
+ assert.equal(enqueues[0][1].timeoutMs, 900);
77
+ assert.equal(enqueues[1][1].timeoutMs, 300);
78
+ });
79
+
80
+ test("acknowledges an agent event before executing it", async () => {
43
81
  const { calls, dispatcher } = createHarness();
44
- await dispatcher.dispatchTask({
82
+ await dispatcher.runClaimedTask({
45
83
  id: "event-1",
46
84
  kind: "agent_event",
47
85
  payload: { chatId: 123, prompt: "something happened", acknowledgement: "received" }
@@ -54,9 +92,9 @@ test("acknowledges an agent event before queueing it", async () => {
54
92
  assert.deepEqual(calls[2], ["complete", "event-1"]);
55
93
  });
56
94
 
57
- test("runs poll tools headlessly and completes the checker task", async () => {
95
+ test("runs poll tools headlessly and confirms their result", async () => {
58
96
  const { calls, dispatcher } = createHarness();
59
- await dispatcher.dispatchTask({
97
+ await dispatcher.runClaimedTask({
60
98
  id: "poll-1",
61
99
  kind: "poll_tool",
62
100
  payload: { chatId: 123, toolName: "checker", args: { cursor: "4" } }
@@ -68,18 +106,107 @@ test("runs poll tools headlessly and completes the checker task", async () => {
68
106
  ]);
69
107
  });
70
108
 
71
- test("fails malformed and unsupported tasks without enqueueing them", async () => {
109
+ test("retries a known poll failure with backoff", async () => {
110
+ const { calls, dispatcher } = createHarness({
111
+ dependencies: {
112
+ agentManager: {
113
+ async runTool(input) {
114
+ calls.push(["runTool", input]);
115
+ return { ok: false, status: "failed", error: "temporary checker failure" };
116
+ }
117
+ }
118
+ }
119
+ });
120
+ await dispatcher.runClaimedTask({
121
+ id: "poll-failed",
122
+ kind: "poll_tool",
123
+ payload: { chatId: 123, toolName: "checker" }
124
+ });
125
+
126
+ assert.deepEqual(calls.at(-1), [
127
+ "retryOrFail",
128
+ "poll-failed",
129
+ "temporary checker failure",
130
+ { retryable: true }
131
+ ]);
132
+ });
133
+
134
+ test("fails malformed tasks without retrying", async () => {
72
135
  const { calls, dispatcher } = createHarness();
73
- await dispatcher.dispatchTask({ id: "bad-1", kind: "agent_task", payload: {} });
74
- await dispatcher.dispatchTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
136
+ await dispatcher.runClaimedTask({ id: "bad-1", kind: "agent_task", payload: {} });
137
+ await dispatcher.runClaimedTask({ id: "bad-2", kind: "other", payload: { chatId: 123 } });
75
138
 
76
139
  assert.deepEqual(calls, [
77
- ["fail", "bad-1", "Task missing chatId: agent_task"],
78
- ["fail", "bad-2", "Unsupported task: other"]
140
+ ["retryOrFail", "bad-1", "Task missing chatId: agent_task", { retryable: false }],
141
+ ["retryOrFail", "bad-2", "Unsupported task: other", { retryable: false }],
142
+ ["send", 123, "⚠️ Arisa task failed\nTask: other (bad-2)\nError: Unsupported task: other\nNo further retries are scheduled.", undefined]
143
+ ]);
144
+ });
145
+
146
+ test("notifies the routed Telegram topic once retries are exhausted", async () => {
147
+ const { calls, dispatcher } = createHarness({
148
+ taskStore: {
149
+ async retryOrFail(taskId, error, options) {
150
+ calls.push(["retryOrFail", taskId, error.message, options]);
151
+ return {
152
+ status: "pending",
153
+ terminalFailure: true,
154
+ runAt: "2026-08-21T00:00:00.000Z"
155
+ };
156
+ }
157
+ },
158
+ dependencies: {
159
+ enqueueAsyncPrompt: async () => { throw new Error("token=very-secret-value queue unavailable"); }
160
+ }
161
+ });
162
+
163
+ await dispatcher.runClaimedTask({
164
+ id: "recurring-bad",
165
+ kind: "agent_task",
166
+ payload: { chatId: 123, prompt: "private payload must not appear" },
167
+ route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } }
168
+ });
169
+
170
+ assert.deepEqual(calls.at(-1), [
171
+ "send",
172
+ -1001,
173
+ "⚠️ Arisa task failed\nTask: agent_task (recurring-bad)\nError: token=[redacted] queue unavailable\nNext run: 2026-08-21T00:00:00.000Z",
174
+ { message_thread_id: 87 }
79
175
  ]);
176
+ assert.equal(calls.at(-1)[2].includes("private payload"), false);
177
+ });
178
+
179
+ test("serializes tasks within one destination while independent destinations continue", async () => {
180
+ const tasks = [
181
+ { id: "first", kind: "agent_task", payload: { chatId: 123, prompt: "first" }, route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } } },
182
+ { id: "second", kind: "agent_task", payload: { chatId: 123, prompt: "second" }, route: { transport: "telegram", destination: { chatId: -1001, threadId: 87 } } },
183
+ { id: "other", kind: "agent_task", payload: { chatId: 123, prompt: "other" }, route: { transport: "telegram", destination: { chatId: -1001, threadId: 23 } } }
184
+ ];
185
+ let releaseFirst;
186
+ const firstExecution = new Promise((resolve) => { releaseFirst = resolve; });
187
+ const { calls, dispatcher } = createHarness({
188
+ taskStore: { async claimDue() { return tasks; } },
189
+ dependencies: {
190
+ enqueueAsyncPrompt: async (input) => {
191
+ calls.push(["enqueue", input.label]);
192
+ if (input.label.includes("first")) await firstExecution;
193
+ }
194
+ }
195
+ });
196
+
197
+ const dispatching = dispatcher.dispatchDueTasks();
198
+ await new Promise((resolve) => setImmediate(resolve));
199
+ assert.ok(calls.some((call) => call[0] === "enqueue" && call[1].includes("first")));
200
+ assert.ok(calls.some((call) => call[0] === "enqueue" && call[1].includes("other")));
201
+ assert.equal(calls.some((call) => call[0] === "enqueue" && call[1].includes("second")), false);
202
+
203
+ releaseFirst();
204
+ await dispatching;
205
+ const enqueueOrder = calls.filter((call) => call[0] === "enqueue").map((call) => call[1]);
206
+ assert.ok(enqueueOrder.indexOf("scheduled task first") < enqueueOrder.indexOf("scheduled task second"));
80
207
  });
81
208
 
82
- test("due-task dispatch isolates failures between claimed tasks", async () => {
209
+ test("due-task dispatch retries one failure without blocking another task", async () => {
83
210
  const tasks = [
84
211
  { id: "bad", kind: "agent_task", payload: { chatId: 123, prompt: "fail" } },
85
212
  { id: "good", kind: "poll_tool", payload: { chatId: 123, toolName: "checker" } }
@@ -95,8 +222,8 @@ test("due-task dispatch isolates failures between claimed tasks", async () => {
95
222
 
96
223
  assert.deepEqual(calls, [
97
224
  ["claimDue", 10],
98
- ["fail", "bad", "queue unavailable"],
99
225
  ["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
100
- ["complete", "good"]
226
+ ["complete", "good"],
227
+ ["retryOrFail", "bad", "queue unavailable", { retryable: true }]
101
228
  ]);
102
229
  });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildPrompt, buildReactionPrompt, isScheduledTaskPrompt, shouldIncludeArtifactReference, withPromptSpeed } from "../src/transport/telegram/bot.js";
3
+ import { buildPrompt, buildReactionPrompt, isScheduledTaskPrompt, scheduledPromptSpeedOptions, shouldIncludeArtifactReference, withPromptSpeed } from "../src/transport/telegram/bot.js";
4
4
  import { captureIncomingArtifact } from "../src/transport/telegram/media.js";
5
5
 
6
6
  test("scheduled agent prompts use normal speed for one turn and restore chat speed", async () => {
@@ -11,7 +11,18 @@ test("scheduled agent prompts use normal speed for one turn and restore chat spe
11
11
  assert.equal(isScheduledTaskPrompt("Scheduled task fired.\ntaskId: one"), true);
12
12
  assert.equal(isScheduledTaskPrompt("Incoming Telegram message."), false);
13
13
 
14
- await withPromptSpeed({ speedController, speed: 1, restoreSpeed: () => 1.5 }, async () => {
14
+ const speedOptions = scheduledPromptSpeedOptions({
15
+ prompt: "Scheduled task fired.\ntaskId: one",
16
+ session: {
17
+ model: { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.5" }
18
+ },
19
+ speedController,
20
+ configuredSpeed: 1.5
21
+ });
22
+ assert.equal(speedOptions.speed, 1);
23
+ assert.equal(speedOptions.restoreSpeed(), 1.5);
24
+
25
+ await withPromptSpeed(speedOptions, async () => {
15
26
  assert.equal(speed, 1);
16
27
  });
17
28
  assert.equal(speed, 1.5);