arisa 5.1.60 → 5.1.65

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 (33) hide show
  1. package/README.md +9 -0
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +10 -0
  4. package/src/core/config/config-defaults.js +28 -1
  5. package/src/core/tasks/task-runner.js +17 -2
  6. package/src/core/tasks/task-store.js +80 -14
  7. package/src/core/tools/tool-registry.js +20 -5
  8. package/src/core/tools/weighted-resource-governor.js +153 -0
  9. package/src/index.js +20 -0
  10. package/src/official-tools.lock.json +57 -43
  11. package/src/runtime/create-app.js +1 -1
  12. package/src/runtime/create-headless-app.js +2 -2
  13. package/src/runtime/paths.js +4 -0
  14. package/src/runtime/service-manager.js +3 -1
  15. package/src/runtime/service-supervisor.js +98 -0
  16. package/src/transport/telegram/bot.js +77 -9
  17. package/src/transport/telegram/chat-queue.js +13 -2
  18. package/src/transport/telegram/reply-topic-routing.js +111 -0
  19. package/src/transport/telegram/task-dispatcher.js +26 -3
  20. package/src/transport/telegram/telegram-session-bridge.js +7 -0
  21. package/src/transport/telegram/workspace-topic-store.js +228 -0
  22. package/test/chat-queue.test.js +32 -0
  23. package/test/model-selection.test.js +9 -1
  24. package/test/official-tool-dependencies.test.js +1 -1
  25. package/test/official-tool-installer.test.js +2 -0
  26. package/test/paths.test.js +8 -0
  27. package/test/service-manager.test.js +48 -0
  28. package/test/task-store.test.js +56 -4
  29. package/test/telegram-reply-topic-routing.test.js +94 -0
  30. package/test/telegram-task-dispatcher.test.js +51 -0
  31. package/test/telegram-workspace-topic-store.test.js +124 -0
  32. package/test/tool-registry-run.test.js +41 -0
  33. package/test/weighted-resource-governor.test.js +95 -0
@@ -0,0 +1,111 @@
1
+ const replyTopicMarkerPattern = /\s*\[\[ARISA_REPLY_TOPIC:(\d+)\]\]\s*$/u;
2
+ const proposalMarkerPattern = /\s*\[\[ARISA_PROPOSE_TOPIC:([^\]\r\n]{1,80})\]\]\s*$/u;
3
+ const anyRoutingMarkerPattern = /\s*\[\[ARISA_(?:REPLY_TOPIC|PROPOSE_TOPIC):[^\]]*\]\]\s*$/u;
4
+
5
+ function singleLine(value, maxLength) {
6
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
7
+ }
8
+
9
+ export function isGeneralWorkspaceRoute(route) {
10
+ return Boolean(route?.workspace
11
+ && route.threadId == null
12
+ && route.topicThreadId === route.generalTopicId);
13
+ }
14
+
15
+ function activeTopics(topics = []) {
16
+ return topics
17
+ .map((topic) => ({
18
+ threadId: Number(topic?.threadId),
19
+ name: singleLine(topic?.name, 80),
20
+ description: singleLine(topic?.description, 240)
21
+ }))
22
+ .filter((topic) => Number.isSafeInteger(topic.threadId) && topic.threadId > 0 && topic.name)
23
+ .sort((left, right) => left.threadId - right.threadId)
24
+ .slice(0, 32);
25
+ }
26
+
27
+ export function buildGeneralReplyRoutingInstruction(topics = [], recentProposals = []) {
28
+ const available = activeTopics(topics);
29
+ const options = available.length
30
+ ? available.map((topic) => [
31
+ `- ${topic.threadId}: ${topic.name}`,
32
+ topic.description ? ` — ${topic.description}` : ""
33
+ ].join(""))
34
+ : ["- No named topics are registered yet."];
35
+ const proposals = recentProposals
36
+ .map((proposal) => singleLine(proposal?.name, 80))
37
+ .filter(Boolean)
38
+ .slice(0, 12);
39
+ return [
40
+ "Telegram General-topic management instruction:",
41
+ "This applies only because the incoming message was written in General inside an owner workspace supergroup. It never applies to a private chat with the bot.",
42
+ "The original message must stay in General and the conversation remains in the General session.",
43
+ "If, and only if, your final response clearly belongs to exactly one registered topic below, append [[ARISA_REPLY_TOPIC:<id>]] as the final line. Do not mention this routing marker.",
44
+ "If the relationship is weak, ambiguous, or no topic fits, do not append a reply marker and the response will remain in General.",
45
+ "You may occasionally propose a new topic only when the General conversation shows a substantial theme recurring across multiple turns and no registered topic fits. Ask for explicit confirmation in the visible response and append [[ARISA_PROPOSE_TOPIC:<short name>]] as the final line. Never create a topic without explicit confirmation.",
46
+ proposals.length ? `Do not repeat these recent topic proposals: ${proposals.join(", ")}.` : null,
47
+ "Registered reply topics:",
48
+ ...options
49
+ ].filter(Boolean).join("\n");
50
+ }
51
+
52
+ export function appendGeneralReplyRoutingInstruction(prompt, topics = [], recentProposals = []) {
53
+ return `${String(prompt || "")}\n\n${buildGeneralReplyRoutingInstruction(topics, recentProposals)}`;
54
+ }
55
+
56
+ export function extractReplyTopicMetadata(text, topics = []) {
57
+ let cleanedText = String(text || "");
58
+ let requestedThreadId = null;
59
+ let proposal = "";
60
+ let changed = true;
61
+ while (changed) {
62
+ changed = false;
63
+ const replyMatch = cleanedText.match(replyTopicMarkerPattern);
64
+ if (replyMatch) {
65
+ requestedThreadId ??= Number(replyMatch[1]);
66
+ cleanedText = cleanedText.replace(replyTopicMarkerPattern, "");
67
+ changed = true;
68
+ continue;
69
+ }
70
+ const proposalMatch = cleanedText.match(proposalMarkerPattern);
71
+ if (proposalMatch) {
72
+ proposal ||= singleLine(proposalMatch[1], 80);
73
+ cleanedText = cleanedText.replace(proposalMarkerPattern, "");
74
+ changed = true;
75
+ continue;
76
+ }
77
+ if (anyRoutingMarkerPattern.test(cleanedText)) {
78
+ cleanedText = cleanedText.replace(anyRoutingMarkerPattern, "");
79
+ changed = true;
80
+ }
81
+ }
82
+
83
+ const available = activeTopics(topics);
84
+ const allowed = available.some((topic) => topic.threadId === requestedThreadId);
85
+ return {
86
+ text: cleanedText.trimEnd(),
87
+ threadId: allowed ? requestedThreadId : null,
88
+ proposal
89
+ };
90
+ }
91
+
92
+ export function routeGeneralWorkspaceReply({ route, text, topics = [] }) {
93
+ const generalWorkspaceRoute = isGeneralWorkspaceRoute(route);
94
+ const extracted = extractReplyTopicMetadata(text, generalWorkspaceRoute ? topics : []);
95
+ if (!generalWorkspaceRoute) {
96
+ return { text: extracted.text, route, topic: null, proposal: "" };
97
+ }
98
+
99
+ const topic = activeTopics(topics).find((item) => item.threadId === extracted.threadId) || null;
100
+ if (!topic) return { text: extracted.text, route, topic: null, proposal: extracted.proposal };
101
+ return {
102
+ text: extracted.text,
103
+ route: {
104
+ ...route,
105
+ threadId: topic.threadId,
106
+ topicThreadId: topic.threadId
107
+ },
108
+ topic,
109
+ proposal: ""
110
+ };
111
+ }
@@ -5,6 +5,13 @@ function errorMessage(error) {
5
5
  return error instanceof Error ? error.message : String(error);
6
6
  }
7
7
 
8
+ function boundedTimeout(value, fallback) {
9
+ const parsed = Number(value);
10
+ return Number.isFinite(parsed) && parsed > 0
11
+ ? Math.min(Math.round(parsed), 60 * 60_000)
12
+ : fallback;
13
+ }
14
+
8
15
  function requireChatId(task) {
9
16
  const chatId = task.payload?.chatId;
10
17
  if (chatId == null || chatId === "") {
@@ -31,7 +38,7 @@ function failureDestination(task) {
31
38
  }
32
39
 
33
40
  function buildFailureNotice({ task, result, error }) {
34
- const uncertain = result?.status === "outcome_uncertain";
41
+ const uncertain = result?.status === "outcome_uncertain" || result?.lastOutcome === "outcome_uncertain";
35
42
  const recurring = result?.terminalFailure === true && result?.status === "pending";
36
43
  const lines = [
37
44
  uncertain ? "⚠️ Arisa task outcome is uncertain" : "⚠️ Arisa task failed",
@@ -51,8 +58,12 @@ export function createTelegramTaskDispatcher({
51
58
  toolRegistry,
52
59
  resourceNotes,
53
60
  agentManager,
61
+ taskTimeouts = {},
54
62
  logger
55
63
  }) {
64
+ const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
65
+ const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
66
+
56
67
  async function dispatchAgentTask(task, chatId) {
57
68
  if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
58
69
  logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
@@ -60,7 +71,8 @@ export function createTelegramTaskDispatcher({
60
71
  chatId,
61
72
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
62
73
  label: `scheduled task ${task.id}`,
63
- route: task.route
74
+ route: task.route,
75
+ timeoutMs: agentTimeoutMs
64
76
  });
65
77
  }
66
78
 
@@ -79,7 +91,8 @@ export function createTelegramTaskDispatcher({
79
91
  chatId,
80
92
  prompt: await buildAsyncEventPrompt(task, resourceNotes),
81
93
  label: `agent event ${task.id}`,
82
- route: task.route
94
+ route: task.route,
95
+ timeoutMs: eventTimeoutMs
83
96
  });
84
97
  }
85
98
 
@@ -124,6 +137,16 @@ export function createTelegramTaskDispatcher({
124
137
  const runner = createTaskRunner({
125
138
  taskStore,
126
139
  dispatch: dispatchTask,
140
+ laneKey(task) {
141
+ if (task.kind === "poll_tool") {
142
+ return `poll:${task.payload?.chatId}:${task.payload?.toolName || task.id}`;
143
+ }
144
+ const destination = task.route?.transport === "telegram" ? task.route.destination : null;
145
+ if (!destination?.chatId || Number(destination.threadId) === 1) {
146
+ return `agent:${task.payload?.chatId}`;
147
+ }
148
+ return `agent:${destination.chatId}:${destination.threadId || 0}`;
149
+ },
127
150
  onTerminalFailure: notifyTerminalFailure,
128
151
  logger
129
152
  });
@@ -24,6 +24,7 @@ export function createTelegramSessionBridgeController({
24
24
  agentManager,
25
25
  artifactStore,
26
26
  sessionSeeds,
27
+ workspaceTopics,
27
28
  getChatState,
28
29
  buildTopicInitializationHandoff,
29
30
  logger
@@ -61,6 +62,12 @@ export function createTelegramSessionBridgeController({
61
62
  throw new Error("The General topic already uses the owner's private session and cannot be reinitialized here.");
62
63
  }
63
64
  const handoff = buildTopicInitializationHandoff({ name, context });
65
+ await workspaceTopics.upsertTopic(route.ownerChatId, route.transportChatId, {
66
+ threadId: messageThreadId,
67
+ name,
68
+ description: context,
69
+ source: "arisa-initialized"
70
+ });
64
71
  await sessionSeeds.set(initializedSessionId, handoff);
65
72
  agentManager.resetSession(initializedSessionId, { handoff });
66
73
  await agentManager.waitForSessionClose(initializedSessionId);
@@ -0,0 +1,228 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getChatTelegramWorkspacesFile } from "../../runtime/paths.js";
4
+
5
+ const recentProposalWindowMs = 30 * 24 * 60 * 60 * 1000;
6
+
7
+ function emptyState() {
8
+ return { version: 1, groups: {} };
9
+ }
10
+
11
+ function integer(value, label) {
12
+ const parsed = Number(value);
13
+ if (!Number.isSafeInteger(parsed)) throw new Error(`${label} must be an integer`);
14
+ return parsed;
15
+ }
16
+
17
+ function oneLine(value, maxLength) {
18
+ return String(value || "").replace(/\s+/g, " ").trim().slice(0, maxLength);
19
+ }
20
+
21
+ function normalizeState(parsed) {
22
+ if (!parsed || parsed.version !== 1 || !parsed.groups || typeof parsed.groups !== "object") {
23
+ throw new Error("Unsupported Telegram workspace topic state");
24
+ }
25
+ return parsed;
26
+ }
27
+
28
+ async function readState(file) {
29
+ try {
30
+ return normalizeState(JSON.parse(await readFile(file, "utf8")));
31
+ } catch (error) {
32
+ if (error?.code === "ENOENT") return emptyState();
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async function writeState(file, state) {
38
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
39
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
40
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
41
+ await rename(temporary, file);
42
+ }
43
+
44
+ function workspace(state, groupChatId) {
45
+ const groupKey = String(groupChatId);
46
+ state.groups[groupKey] ||= { topics: {}, proposals: {} };
47
+ state.groups[groupKey].topics ||= {};
48
+ state.groups[groupKey].proposals ||= {};
49
+ return state.groups[groupKey];
50
+ }
51
+
52
+ function normalizeTopic(threadId, topic) {
53
+ return {
54
+ threadId: Number(threadId),
55
+ name: oneLine(topic?.name, 80),
56
+ description: oneLine(topic?.description, 240),
57
+ status: topic?.status === "closed" ? "closed" : "open",
58
+ source: oneLine(topic?.source, 40),
59
+ createdAt: oneLine(topic?.createdAt, 40),
60
+ updatedAt: oneLine(topic?.updatedAt, 40)
61
+ };
62
+ }
63
+
64
+ export async function migrateLegacyReplyTopics(config, store) {
65
+ let migratedGroups = 0;
66
+ for (const [groupChatId, workspaceConfig] of Object.entries(config.telegram?.ownerWorkspaceGroups || {})) {
67
+ const configured = workspaceConfig?.replyTopics;
68
+ if (!configured || typeof configured !== "object" || Array.isArray(configured)) continue;
69
+ const generalTopicId = Number(workspaceConfig.generalTopicId) || 1;
70
+ for (const [threadId, topic] of Object.entries(configured)) {
71
+ const numericThreadId = Number(threadId);
72
+ const name = oneLine(topic?.name, 80);
73
+ if (!Number.isSafeInteger(numericThreadId) || numericThreadId <= 0 || numericThreadId === generalTopicId || !name) continue;
74
+ await store.upsertTopic(workspaceConfig.ownerChatId, Number(groupChatId), {
75
+ threadId: numericThreadId,
76
+ name,
77
+ description: topic?.description,
78
+ source: "legacy-config-migration"
79
+ });
80
+ }
81
+ delete workspaceConfig.replyTopics;
82
+ migratedGroups += 1;
83
+ }
84
+ return migratedGroups;
85
+ }
86
+
87
+ export class WorkspaceTopicStore {
88
+ constructor({ resolveFile = getChatTelegramWorkspacesFile, now = () => Date.now() } = {}) {
89
+ this.resolveFile = resolveFile;
90
+ this.now = now;
91
+ this.queues = new Map();
92
+ }
93
+
94
+ async withOwnerLock(ownerChatId, work) {
95
+ const key = String(ownerChatId);
96
+ const previous = this.queues.get(key) || Promise.resolve();
97
+ const current = previous.catch(() => {}).then(work);
98
+ this.queues.set(key, current);
99
+ try {
100
+ return await current;
101
+ } finally {
102
+ if (this.queues.get(key) === current) this.queues.delete(key);
103
+ }
104
+ }
105
+
106
+ async read(ownerChatId) {
107
+ await (this.queues.get(String(ownerChatId)) || Promise.resolve()).catch(() => {});
108
+ return readState(this.resolveFile(ownerChatId));
109
+ }
110
+
111
+ async listTopics(ownerChatId, groupChatId, { includeClosed = false } = {}) {
112
+ const state = await this.read(ownerChatId);
113
+ const group = state.groups[String(groupChatId)];
114
+ if (!group?.topics) return [];
115
+ return Object.entries(group.topics)
116
+ .map(([threadId, topic]) => normalizeTopic(threadId, topic))
117
+ .filter((topic) => Number.isSafeInteger(topic.threadId)
118
+ && topic.threadId > 0
119
+ && topic.name
120
+ && (includeClosed || topic.status === "open"))
121
+ .sort((left, right) => left.threadId - right.threadId);
122
+ }
123
+
124
+ async upsertTopic(ownerChatId, groupChatId, { threadId, name, description, status = "open", source = "observed" }) {
125
+ const scopedOwner = integer(ownerChatId, "ownerChatId");
126
+ const scopedGroup = integer(groupChatId, "groupChatId");
127
+ const scopedThread = integer(threadId, "threadId");
128
+ const scopedName = oneLine(name, 80);
129
+ if (scopedThread <= 0) throw new Error("threadId must be positive");
130
+ if (!scopedName) throw new Error("topic name is required");
131
+
132
+ return this.withOwnerLock(scopedOwner, async () => {
133
+ const file = this.resolveFile(scopedOwner);
134
+ const state = await readState(file);
135
+ const group = workspace(state, scopedGroup);
136
+ const key = String(scopedThread);
137
+ const existing = group.topics[key] || {};
138
+ const timestamp = new Date(this.now()).toISOString();
139
+ group.topics[key] = {
140
+ ...existing,
141
+ name: scopedName,
142
+ description: description === undefined ? oneLine(existing.description, 240) : oneLine(description, 240),
143
+ status: status === "closed" ? "closed" : "open",
144
+ source: oneLine(source, 40) || oneLine(existing.source, 40) || "observed",
145
+ createdAt: existing.createdAt || timestamp,
146
+ updatedAt: timestamp
147
+ };
148
+ await writeState(file, state);
149
+ return normalizeTopic(key, group.topics[key]);
150
+ });
151
+ }
152
+
153
+ async setTopicStatus(ownerChatId, groupChatId, threadId, status) {
154
+ const scopedOwner = integer(ownerChatId, "ownerChatId");
155
+ const scopedGroup = integer(groupChatId, "groupChatId");
156
+ const scopedThread = integer(threadId, "threadId");
157
+ return this.withOwnerLock(scopedOwner, async () => {
158
+ const file = this.resolveFile(scopedOwner);
159
+ const state = await readState(file);
160
+ const topic = state.groups[String(scopedGroup)]?.topics?.[String(scopedThread)];
161
+ if (!topic) return null;
162
+ topic.status = status === "closed" ? "closed" : "open";
163
+ topic.updatedAt = new Date(this.now()).toISOString();
164
+ await writeState(file, state);
165
+ return normalizeTopic(scopedThread, topic);
166
+ });
167
+ }
168
+
169
+ async observeMessage(route, message = {}) {
170
+ if (!route?.workspace || !route.ownerChatId || !route.transportChatId) return null;
171
+ const threadId = route.topicThreadId;
172
+ if (!Number.isSafeInteger(threadId) || threadId === route.generalTopicId) return null;
173
+ if (message.forum_topic_created?.name) {
174
+ return this.upsertTopic(route.ownerChatId, route.transportChatId, {
175
+ threadId,
176
+ name: message.forum_topic_created.name,
177
+ source: "telegram-created"
178
+ });
179
+ }
180
+ if (message.forum_topic_edited?.name) {
181
+ return this.upsertTopic(route.ownerChatId, route.transportChatId, {
182
+ threadId,
183
+ name: message.forum_topic_edited.name,
184
+ source: "telegram-edited"
185
+ });
186
+ }
187
+ if (message.forum_topic_closed) {
188
+ return this.setTopicStatus(route.ownerChatId, route.transportChatId, threadId, "closed");
189
+ }
190
+ if (message.forum_topic_reopened) {
191
+ return this.setTopicStatus(route.ownerChatId, route.transportChatId, threadId, "open");
192
+ }
193
+ return null;
194
+ }
195
+
196
+ async recordProposal(ownerChatId, groupChatId, name) {
197
+ const scopedOwner = integer(ownerChatId, "ownerChatId");
198
+ const scopedGroup = integer(groupChatId, "groupChatId");
199
+ const scopedName = oneLine(name, 80);
200
+ if (!scopedName) return null;
201
+ return this.withOwnerLock(scopedOwner, async () => {
202
+ const file = this.resolveFile(scopedOwner);
203
+ const state = await readState(file);
204
+ const group = workspace(state, scopedGroup);
205
+ const key = scopedName.toLocaleLowerCase("en-US");
206
+ group.proposals[key] = {
207
+ name: scopedName,
208
+ proposedAt: new Date(this.now()).toISOString()
209
+ };
210
+ const ordered = Object.entries(group.proposals)
211
+ .sort((left, right) => String(right[1]?.proposedAt || "").localeCompare(String(left[1]?.proposedAt || "")))
212
+ .slice(0, 50);
213
+ group.proposals = Object.fromEntries(ordered);
214
+ await writeState(file, state);
215
+ return group.proposals[key];
216
+ });
217
+ }
218
+
219
+ async listRecentProposals(ownerChatId, groupChatId) {
220
+ const state = await this.read(ownerChatId);
221
+ const proposals = state.groups[String(groupChatId)]?.proposals || {};
222
+ const cutoff = this.now() - recentProposalWindowMs;
223
+ return Object.values(proposals)
224
+ .filter((proposal) => Date.parse(proposal?.proposedAt || "") >= cutoff)
225
+ .sort((left, right) => String(right.proposedAt).localeCompare(String(left.proposedAt)))
226
+ .slice(0, 12);
227
+ }
228
+ }
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ createChatStateStore,
5
+ createPromptExecutionReceipt,
6
+ drainChatPromptQueue,
7
+ queueChatPrompt
8
+ } from "../src/transport/telegram/chat-queue.js";
9
+
10
+ test("execution receipts start only when their queued prompt begins", async () => {
11
+ const state = createChatStateStore().get("chat-1");
12
+ const starts = [];
13
+ const receipt = createPromptExecutionReceipt(() => starts.push("second"));
14
+ queueChatPrompt(state, "second", { receipt });
15
+ let releaseFirst;
16
+ const first = new Promise((resolve) => { releaseFirst = resolve; });
17
+
18
+ const draining = drainChatPromptQueue({
19
+ chatState: state,
20
+ initialPrompt: "first",
21
+ processPrompt: async ({ prompt }) => {
22
+ if (prompt === "first") await first;
23
+ }
24
+ });
25
+
26
+ await new Promise((resolve) => setImmediate(resolve));
27
+ assert.deepEqual(starts, []);
28
+ releaseFirst();
29
+ await draining;
30
+ assert.deepEqual(starts, ["second"]);
31
+ await receipt.promise;
32
+ });
@@ -10,7 +10,12 @@ import {
10
10
  selectChatSpeed,
11
11
  selectChatThinkingLevel
12
12
  } from "../src/core/agent/model-selection.js";
13
- import { applyConfigDefaults, piConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
13
+ import {
14
+ applyConfigDefaults,
15
+ piConfigDefaults,
16
+ telegramConfigDefaults,
17
+ toolExecutionConfigDefaults
18
+ } from "../src/core/config/config-defaults.js";
14
19
  import {
15
20
  clampModelThinkingLevel,
16
21
  listModelThinkingLevels,
@@ -295,6 +300,9 @@ test("centralizes Telegram and Pi defaults in config", () => {
295
300
 
296
301
  assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
297
302
  assert.equal(config.telegram.busyMessageMode, "steer");
303
+ assert.equal(config.toolExecution.defaultCapacity, toolExecutionConfigDefaults.defaultCapacity);
304
+ assert.equal(config.toolExecution.maxQueuedPerClass, 100);
305
+ assert.deepEqual(config.toolExecution.capacities, {});
298
306
  assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
299
307
  assert.equal(config.pi.speed, piConfigDefaults.speed);
300
308
  });
@@ -7,7 +7,7 @@ async function manifest(name) {
7
7
  }
8
8
 
9
9
  test("official orchestrators declare their hard tool dependencies", async () => {
10
- assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.1.0" });
10
+ assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.2.0" });
11
11
  assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
12
12
  "pr-campaign": "^0.1.0",
13
13
  "gmail-workspace": "^0.1.0"
@@ -69,6 +69,8 @@ test("every bundled official tool lock matches the catalog source", async () =>
69
69
  { files: Object.keys(entry.files).length },
70
70
  name
71
71
  );
72
+ const manifest = JSON.parse(await readFile(path.join(source, "tool.manifest.json"), "utf8"));
73
+ assert.equal(manifest.version ?? null, entry.version ?? null, `${name} version`);
72
74
  }
73
75
  });
74
76
 
@@ -10,6 +10,7 @@ import {
10
10
  createIpcSocketPath,
11
11
  getChatArtifactsDir,
12
12
  getChatSessionSeedFile,
13
+ getChatTelegramWorkspacesFile,
13
14
  getChatToolConfigPath,
14
15
  getChatToolUsageFile,
15
16
  getChatToolStateDir,
@@ -39,6 +40,13 @@ test("keeps tool usage scoped below the chat state directory", () => {
39
40
  );
40
41
  });
41
42
 
43
+ test("keeps Telegram workspace topics scoped below the owner chat state directory", () => {
44
+ assert.equal(
45
+ getChatTelegramWorkspacesFile("chat-1"),
46
+ path.join(chatsDir, "chat-1", "state", "telegram-workspaces.json")
47
+ );
48
+ });
49
+
42
50
  test("keeps chat tool state and config paths scoped below the chat directory for normal names", () => {
43
51
  assert.equal(
44
52
  getChatToolStateDir("chat-1", "strudel-agent"),
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { EventEmitter } from "node:events";
3
4
  import { createTelegramRestartHandler, telegramCommands } from "../src/transport/telegram/bot.js";
4
5
  import { handoffServiceRestart, restartService, serviceEntryFile, waitForServiceStop } from "../src/runtime/service-manager.js";
6
+ import { createServiceSupervisor } from "../src/runtime/service-supervisor.js";
5
7
 
6
8
  test("registers maintenance as native Telegram commands", () => {
7
9
  assert.equal(
@@ -225,6 +227,52 @@ test("restart does not start a second service when shutdown times out", async ()
225
227
  assert.equal(started, false);
226
228
  });
227
229
 
230
+ test("accepts restart handoff from a worker owned by the active supervisor", async () => {
231
+ let spawned = false;
232
+ await handoffServiceRestart({}, {
233
+ ensureHome: async () => {},
234
+ getStatus: async () => ({ running: true, pid: 77 }),
235
+ openLog: async () => ({ fd: 17, close: async () => {} }),
236
+ spawnProcess: () => ({ pid: 84, unref() { spawned = true; } }),
237
+ environment: { ARISA_SUPERVISOR_PID: "77" },
238
+ currentPid: 41
239
+ });
240
+ assert.equal(spawned, true);
241
+ });
242
+
243
+ test("supervisor restarts an unexpectedly exited worker and forwards shutdown", async () => {
244
+ const children = [];
245
+ const delays = [];
246
+ const spawnProcess = () => {
247
+ const child = new EventEmitter();
248
+ child.pid = 100 + children.length;
249
+ child.kill = (signal) => {
250
+ child.killedWith = signal;
251
+ queueMicrotask(() => child.emit("exit", 0, signal));
252
+ };
253
+ children.push(child);
254
+ return child;
255
+ };
256
+ const supervisor = createServiceSupervisor({
257
+ command: "node",
258
+ args: ["worker.js"],
259
+ restartLimit: 2,
260
+ restartBackoffMs: 5,
261
+ restartBackoffMaxMs: 20,
262
+ stableRuntimeMs: 60_000,
263
+ spawnProcess,
264
+ wait: async (ms) => { delays.push(ms); }
265
+ });
266
+ const running = supervisor.start();
267
+ children[0].emit("exit", 1, null);
268
+ await new Promise((resolve) => setImmediate(resolve));
269
+ assert.equal(children.length, 2);
270
+ assert.deepEqual(delays, [5]);
271
+ await supervisor.stop();
272
+ await running;
273
+ assert.equal(children[1].killedWith, "SIGTERM");
274
+ });
275
+
228
276
  test("requires explicit positive shutdown timing policy", async () => {
229
277
  await assert.rejects(
230
278
  waitForServiceStop({ timeoutMs: 0, pollIntervalMs: 1 }),