arisa 5.1.68 → 5.2.7

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 (77) hide show
  1. package/README.md +7 -4
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +46 -6
  4. package/src/core/agent/agent-session-lifecycle.js +80 -3
  5. package/src/core/agent/core-tools.js +1 -1
  6. package/src/core/agent/pi-auth-login.js +1 -1
  7. package/src/core/agent/pi-runtime.js +1 -1
  8. package/src/core/agent/runtime-context.js +1 -1
  9. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  10. package/src/core/artifacts/artifact-store.js +1 -1
  11. package/src/core/capabilities/capability-service.js +1 -1
  12. package/src/core/config/config-defaults.js +30 -1
  13. package/src/core/config/config-store.js +1 -1
  14. package/src/core/conversation/session-seed-store.js +1 -1
  15. package/src/core/tasks/task-store.js +1 -1
  16. package/src/core/tools/daemon-client.js +180 -0
  17. package/src/core/tools/daemon-processes.js +19 -3
  18. package/src/core/tools/daemon-protocol.js +72 -0
  19. package/src/core/tools/daemon-runtime.js +13 -490
  20. package/src/core/tools/daemon-worker.js +310 -0
  21. package/src/core/tools/ipc-client.js +2 -2
  22. package/src/core/tools/memory-pressure.js +56 -0
  23. package/src/core/tools/official-tool-installer.js +1 -1
  24. package/src/core/tools/tool-config.js +1 -1
  25. package/src/core/tools/tool-process-output.js +100 -0
  26. package/src/core/tools/tool-process-runner.js +175 -0
  27. package/src/core/tools/tool-registry.js +99 -187
  28. package/src/core/tools/tool-resource-note-store.js +1 -1
  29. package/src/core/tools/tool-usage-store.js +1 -1
  30. package/src/core/tools/weighted-resource-governor.js +188 -38
  31. package/src/index.js +14 -2
  32. package/src/official-tools.lock.json +424 -50
  33. package/src/platform/paths.js +152 -0
  34. package/src/runtime/bootstrap-cli.js +121 -0
  35. package/src/runtime/bootstrap-config.js +97 -0
  36. package/src/runtime/bootstrap-telegram.js +325 -0
  37. package/src/runtime/bootstrap.js +6 -543
  38. package/src/runtime/doctor.js +6 -3
  39. package/src/runtime/flush.js +1 -1
  40. package/src/runtime/ipc/ipc-server.js +1 -1
  41. package/src/runtime/log-viewer.js +1 -1
  42. package/src/runtime/oom-protection.js +20 -0
  43. package/src/runtime/paths.js +3 -151
  44. package/src/runtime/restart-receipt.js +1 -1
  45. package/src/runtime/service-manager.js +1 -1
  46. package/src/runtime/service-supervisor.js +14 -0
  47. package/src/runtime/slave-cli.js +1 -1
  48. package/src/runtime/tool-process-supervisor.js +1 -1
  49. package/src/runtime/tui.js +200 -0
  50. package/src/runtime/update-manager.js +1 -1
  51. package/src/runtime/worker-recovery-report.js +142 -0
  52. package/src/transport/telegram/bot.js +42 -320
  53. package/src/transport/telegram/prompt-builders.js +8 -3
  54. package/src/transport/telegram/telegram-prompt-controller.js +346 -0
  55. package/src/transport/telegram/workspace-topic-store.js +1 -1
  56. package/test/agent-session-lifecycle.test.js +92 -0
  57. package/test/architecture-boundaries.test.js +29 -0
  58. package/test/bootstrap.test.js +65 -0
  59. package/test/daemon-process-invocation.test.js +27 -0
  60. package/test/daemon-runtime.test.js +36 -1
  61. package/test/doctor.test.js +22 -0
  62. package/test/memory-pressure.test.js +36 -0
  63. package/test/model-selection.test.js +11 -1
  64. package/test/official-tool-dependencies.test.js +1 -1
  65. package/test/official-tool-installer.test.js +18 -1
  66. package/test/oom-protection.test.js +32 -0
  67. package/test/paths.test.js +7 -0
  68. package/test/pi-compaction.test.js +9 -0
  69. package/test/service-manager.test.js +6 -1
  70. package/test/telegram-prompt-controller.test.js +81 -0
  71. package/test/telegram-text-artifact.test.js +30 -0
  72. package/test/tool-registry-run.test.js +108 -4
  73. package/test/tui.test.js +41 -0
  74. package/test/weighted-resource-governor.test.js +97 -5
  75. package/test/worker-heap-circuit-breaker.test.js +79 -0
  76. package/test/worker-recovery-report.test.js +69 -0
  77. package/test-fixtures/fake-daemon.js +5 -0
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { memoryPressureReason, readMemoryPressure } from "../src/core/tools/memory-pressure.js";
4
+
5
+ test("reads Linux available memory, swap pressure, and worker RSS", async () => {
6
+ const snapshot = await readMemoryPressure({
7
+ platform: "linux",
8
+ readMemInfo: async () => [
9
+ "MemTotal: 1000000 kB",
10
+ "MemAvailable: 200000 kB",
11
+ "SwapTotal: 500000 kB",
12
+ "SwapFree: 100000 kB"
13
+ ].join("\n"),
14
+ freeMemory: () => 1,
15
+ totalMemory: () => 2,
16
+ processMemory: () => ({ rss: 123 * 1024 * 1024 })
17
+ });
18
+
19
+ assert.equal(snapshot.availableBytes, 200_000 * 1024);
20
+ assert.equal(snapshot.swapUsedPercent, 80);
21
+ assert.equal(snapshot.workerRssBytes, 123 * 1024 * 1024);
22
+ });
23
+
24
+ test("classifies each configured memory pressure boundary", () => {
25
+ const policy = { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 };
26
+ assert.match(memoryPressureReason({
27
+ workerRssBytes: 400 * 1024 * 1024,
28
+ swapTotalBytes: 0,
29
+ swapUsedPercent: 0
30
+ }, policy), /worker RSS/);
31
+ assert.match(memoryPressureReason({
32
+ workerRssBytes: 100 * 1024 * 1024,
33
+ swapTotalBytes: 100,
34
+ swapUsedPercent: 96
35
+ }, policy), /swap use/);
36
+ });
@@ -302,7 +302,17 @@ test("centralizes Telegram and Pi defaults in config", () => {
302
302
  assert.equal(config.telegram.busyMessageMode, "steer");
303
303
  assert.equal(config.toolExecution.defaultCapacity, toolExecutionConfigDefaults.defaultCapacity);
304
304
  assert.equal(config.toolExecution.maxQueuedPerClass, 100);
305
- assert.deepEqual(config.toolExecution.capacities, { orchestrator: 1 });
305
+ assert.equal(config.toolExecution.maxWorkerRssMb, 384);
306
+ assert.equal(config.toolExecution.maxSwapUsedPercent, 95);
307
+ assert.equal(config.toolExecution.initialToolMemoryMb, 384);
308
+ assert.equal(config.toolExecution.minimumToolMemoryMb, 128);
309
+ assert.equal(config.toolExecution.maximumToolMemoryMb, 4096);
310
+ assert.equal(config.toolExecution.systemReserveMb, 128);
311
+ assert.equal(config.toolExecution.coreReserveMb, 384);
312
+ assert.equal(config.toolExecution.toolHeapPercent, 65);
313
+ assert.equal(config.toolExecution.toolMemoryHighPercent, 85);
314
+ assert.equal(config.toolExecution.toolSwapMaxMb, 128);
315
+ assert.deepEqual(config.toolExecution.capacities, { browser: 1, orchestrator: 1 });
306
316
  assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
307
317
  assert.equal(config.pi.speed, piConfigDefaults.speed);
308
318
  });
@@ -12,7 +12,7 @@ test("official orchestrators declare their hard tool dependencies", async () =>
12
12
  "pr-campaign": "^0.1.0",
13
13
  "gmail-workspace": "^0.1.0"
14
14
  });
15
- assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.2.0" });
15
+ assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.4.0" });
16
16
  assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
17
17
  assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
18
18
  assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import crypto from "node:crypto";
3
- import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -60,6 +60,23 @@ test("verifies the exact file set and digests", async (t) => {
60
60
  await assert.rejects(() => verifyOfficialToolTree(source, files), /unexpected=extra.js/);
61
61
  });
62
62
 
63
+ test("every catalog tool is represented in the bundled lock", async () => {
64
+ const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
65
+ const toolsDir = fileURLToPath(new URL("../../tools/", import.meta.url));
66
+ const entries = await readdir(toolsDir, { withFileTypes: true });
67
+ const catalogNames = [];
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory()) continue;
70
+ try {
71
+ await readFile(path.join(toolsDir, entry.name, "tool.manifest.json"), "utf8");
72
+ catalogNames.push(entry.name);
73
+ } catch (error) {
74
+ if (error.code !== "ENOENT") throw error;
75
+ }
76
+ }
77
+ assert.deepEqual(Object.keys(lock.tools).sort(), catalogNames.sort());
78
+ });
79
+
63
80
  test("every bundled official tool lock matches the catalog source", async () => {
64
81
  const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
65
82
  for (const [name, entry] of Object.entries(lock.tools)) {
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { protectCoreFromOom } from "../src/runtime/oom-protection.js";
4
+
5
+ test("lowers Linux core OOM priority when permitted", async () => {
6
+ const writes = [];
7
+ assert.equal(await protectCoreFromOom({
8
+ platform: "linux",
9
+ score: -900,
10
+ writeScore: async (value) => writes.push(value)
11
+ }), true);
12
+ assert.deepEqual(writes, [-900]);
13
+ });
14
+
15
+ test("keeps running when core OOM priority cannot be changed", async () => {
16
+ const logs = [];
17
+ assert.equal(await protectCoreFromOom({
18
+ platform: "linux",
19
+ writeScore: async () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); },
20
+ logger: { log: (...parts) => logs.push(parts.join(" ")) }
21
+ }), false);
22
+ assert.match(logs.join("\n"), /could not be lowered: EACCES/);
23
+ });
24
+
25
+ test("does not touch OOM controls outside Linux", async () => {
26
+ let called = false;
27
+ assert.equal(await protectCoreFromOom({
28
+ platform: "darwin",
29
+ writeScore: async () => { called = true; }
30
+ }), false);
31
+ assert.equal(called, false);
32
+ });
@@ -17,9 +17,16 @@ import {
17
17
  getToolStateDir,
18
18
  stateDir
19
19
  } from "../src/runtime/paths.js";
20
+ import * as publicPaths from "../src/runtime/paths.js";
21
+ import * as platformPaths from "../src/platform/paths.js";
20
22
 
21
23
  const execFileAsync = promisify(execFile);
22
24
 
25
+ test("runtime paths remains an exact compatibility facade for platform paths", () => {
26
+ assert.deepEqual(Object.keys(publicPaths).sort(), Object.keys(platformPaths).sort());
27
+ for (const name of Object.keys(platformPaths)) assert.equal(publicPaths[name], platformPaths[name]);
28
+ });
29
+
23
30
  test("keeps chat artifact paths scoped below the chat directory", () => {
24
31
  const artifactsDir = getChatArtifactsDir("chat-1");
25
32
 
@@ -14,6 +14,15 @@ test("provides Pi compaction defaults through Arisa config", () => {
14
14
  assert.deepEqual(config.pi.compaction, piConfigDefaults.compaction);
15
15
  });
16
16
 
17
+ test("merges partial resident session cache overrides with defaults", () => {
18
+ const config = applyConfigDefaults({ pi: { sessionCache: { maxSessions: 2 } } });
19
+
20
+ assert.deepEqual(config.pi.sessionCache, {
21
+ maxSessions: 2,
22
+ maxPersistedBytes: 48 * 1024 * 1024
23
+ });
24
+ });
25
+
17
26
  test("merges partial Pi compaction overrides with defaults", () => {
18
27
  const config = applyConfigDefaults({
19
28
  pi: { compaction: { reserveTokens: 8_192 } }
@@ -243,6 +243,7 @@ test("accepts restart handoff from a worker owned by the active supervisor", asy
243
243
  test("supervisor restarts an unexpectedly exited worker and forwards shutdown", async () => {
244
244
  const children = [];
245
245
  const delays = [];
246
+ const reports = [];
246
247
  const spawnProcess = () => {
247
248
  const child = new EventEmitter();
248
249
  child.pid = 100 + children.length;
@@ -261,13 +262,17 @@ test("supervisor restarts an unexpectedly exited worker and forwards shutdown",
261
262
  restartBackoffMaxMs: 20,
262
263
  stableRuntimeMs: 60_000,
263
264
  spawnProcess,
264
- wait: async (ms) => { delays.push(ms); }
265
+ wait: async (ms) => { delays.push(ms); },
266
+ onUnexpectedExit: async (report) => { reports.push(report); }
265
267
  });
266
268
  const running = supervisor.start();
267
269
  children[0].emit("exit", 1, null);
268
270
  await new Promise((resolve) => setImmediate(resolve));
269
271
  assert.equal(children.length, 2);
270
272
  assert.deepEqual(delays, [5]);
273
+ assert.equal(reports.length, 1);
274
+ assert.equal(reports[0].code, 1);
275
+ assert.equal(reports[0].restartDelayMs, 5);
271
276
  await supervisor.stop();
272
277
  await running;
273
278
  assert.equal(children[1].killedWith, "SIGTERM");
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createChatStateStore } from "../src/transport/telegram/chat-queue.js";
4
+ import { createTelegramPromptController } from "../src/transport/telegram/telegram-prompt-controller.js";
5
+
6
+ function createController(overrides = {}) {
7
+ const stateStore = createChatStateStore();
8
+ const calls = { cleared: [], reset: [], steered: [] };
9
+ const controller = createTelegramPromptController({
10
+ config: { pi: { chatModels: {} }, telegram: {} },
11
+ api: {},
12
+ artifactStore: {},
13
+ toolRegistry: {},
14
+ agentManager: {
15
+ resetSession: (...args) => calls.reset.push(args)
16
+ },
17
+ sessionSeeds: {
18
+ clear: async (chatId) => calls.cleared.push(chatId)
19
+ },
20
+ workspaceTopics: {},
21
+ contextRoute: (ctx) => ctx.route,
22
+ getChatState: (chatId) => stateStore.get(chatId),
23
+ createTelegramSessionBridge: () => ({}),
24
+ createWorkspaceAccessGuard: () => async () => {},
25
+ sendTextReply: async () => {},
26
+ authController: { notifyIssueIfNeeded: async () => false },
27
+ ensureWorkspaceTopicModelSelection: async () => {},
28
+ ensureQueuedTyping: async () => {},
29
+ withTyping: async (_ctx, work) => work(),
30
+ resolveBusyMessageMode: () => "steer",
31
+ ...overrides
32
+ });
33
+ return { controller, stateStore, calls };
34
+ }
35
+
36
+ test("busy /new resets only the active session and replaces its queued prompt", async () => {
37
+ const { controller, stateStore, calls } = createController();
38
+ const route = {
39
+ workspace: true,
40
+ sessionId: "topic-87",
41
+ scopeChatId: "owner",
42
+ transportChatId: "group",
43
+ threadId: 87
44
+ };
45
+ const state = stateStore.get(route.sessionId);
46
+ state.processing = true;
47
+ state.pendingPrompts.push("stale prompt");
48
+
49
+ await controller.handleNewCommand({ route, from: { language_code: "es" } });
50
+
51
+ assert.deepEqual(calls.cleared, [route.sessionId]);
52
+ assert.deepEqual(calls.reset, [[route.sessionId]]);
53
+ assert.equal(state.pendingPrompts.length, 1);
54
+ assert.match(state.pendingPrompts[0], /System event: \/new requested/);
55
+ assert.equal(state.continueAfterClose, true);
56
+ });
57
+
58
+ test("a busy prompt for another topic queues instead of steering the active session", async () => {
59
+ const { controller, stateStore, calls } = createController();
60
+ const state = stateStore.get("topic-session");
61
+ state.processing = true;
62
+ state.activeRoute = { transportChatId: "group", threadId: 87 };
63
+ state.activeSession = {
64
+ isStreaming: true,
65
+ steer: async (prompt) => calls.steered.push(prompt)
66
+ };
67
+ const ctx = {
68
+ route: { transportChatId: "group", threadId: 114 }
69
+ };
70
+
71
+ await controller.enqueuePrompt({
72
+ chatId: "topic-session",
73
+ prompt: "different destination",
74
+ label: "cross-topic prompt",
75
+ ctx,
76
+ busyMessageMode: "steer"
77
+ });
78
+
79
+ assert.deepEqual(calls.steered, []);
80
+ assert.deepEqual(state.pendingPrompts, ["different destination"]);
81
+ });
@@ -110,6 +110,36 @@ test("surfaces Telegram forwarding provenance in the prompt", () => {
110
110
  assert.match(prompt, /forwardedAt: 2026-/);
111
111
  });
112
112
 
113
+ test("surfaces Telegram selected quote text when the replied message has no body", () => {
114
+ const ctx = createTextContext("update that too");
115
+ ctx.message.reply_to_message = {
116
+ message_id: 824,
117
+ from: { username: "ArisaWaybot" }
118
+ };
119
+ ctx.message.quote = { text: "master-slave 0.1.9" };
120
+
121
+ const prompt = buildPrompt({ ctx });
122
+
123
+ assert.match(prompt, /quotedMessageId: 824/);
124
+ assert.match(prompt, /quotedSelection: master-slave 0\.1\.9/);
125
+ assert.doesNotMatch(prompt, /no textual body available/);
126
+ });
127
+
128
+ test("surfaces quoted Telegram forum topic metadata", () => {
129
+ const ctx = createTextContext("continue here");
130
+ ctx.message.reply_to_message = {
131
+ message_id: 824,
132
+ from: { username: "ArisaWaybot" },
133
+ forum_topic_created: { name: "storybot" }
134
+ };
135
+
136
+ const prompt = buildPrompt({ ctx });
137
+
138
+ assert.match(prompt, /quotedKind: forum_topic_created/);
139
+ assert.match(prompt, /quotedTopicName: storybot/);
140
+ assert.doesNotMatch(prompt, /no textual body available/);
141
+ });
142
+
113
143
  test("formats Telegram reaction changes as lightweight feedback", () => {
114
144
  const prompt = buildReactionPrompt({
115
145
  reaction: {
@@ -8,7 +8,9 @@ const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-tool-registry-home-"
8
8
  process.env.HOME = homeDir;
9
9
  process.env.USERPROFILE = homeDir;
10
10
 
11
- const { ToolRegistry, createToolOutputParser } = await import("../src/core/tools/tool-registry.js");
11
+ const { ToolRegistry, createToolOutputParser, isolatedToolProcessInvocation } = await import("../src/core/tools/tool-registry.js");
12
+ const { createToolOutputParser: directToolOutputParser } = await import("../src/core/tools/tool-process-output.js");
13
+ const { isolatedToolProcessInvocation: directToolProcessInvocation } = await import("../src/core/tools/tool-process-runner.js");
12
14
  const {
13
15
  arisaHomeDir,
14
16
  arisaPackageDir,
@@ -98,6 +100,33 @@ setInterval(() => {}, 1_000);
98
100
  return dir;
99
101
  }
100
102
 
103
+ test("preserves process helpers through the ToolRegistry compatibility facade", () => {
104
+ assert.equal(createToolOutputParser, directToolOutputParser);
105
+ assert.equal(isolatedToolProcessInvocation, directToolProcessInvocation);
106
+ });
107
+
108
+ test("wraps declared Linux tool processes in a memory-limited cgroup", () => {
109
+ assert.deepEqual(isolatedToolProcessInvocation(
110
+ ["--max-old-space-size=192", "/tool/index.js", "run"],
111
+ { maxMemoryMb: 384 },
112
+ { platform: "linux", systemdAvailable: true, oomAdjustAvailable: true }
113
+ ), {
114
+ command: "systemd-run",
115
+ args: [
116
+ "--scope", "--quiet", "--collect", "--slice=arisa-tools.slice",
117
+ "-p", "MemoryHigh=326M",
118
+ "-p", "MemoryMax=384M",
119
+ "-p", "MemorySwapMax=128M",
120
+ "--", "choom", "-n", "500", "--", "node", "--max-old-space-size=192", "/tool/index.js", "run"
121
+ ],
122
+ isolated: true
123
+ });
124
+ assert.equal(isolatedToolProcessInvocation(["tool.js"], { maxMemoryMb: 384 }, {
125
+ platform: "darwin",
126
+ systemdAvailable: false
127
+ }).isolated, false);
128
+ });
129
+
101
130
  test("loads and lists installed tools from the user tools directory", async () => {
102
131
  await resetHome();
103
132
  await createFakeTool("fake-tool", {
@@ -172,7 +201,10 @@ test("loads weighted execution metadata from the tool manifest", async () => {
172
201
  assert.deepEqual(registry.get("heavy-tool").execution, {
173
202
  resourceClass: "browser",
174
203
  weight: 2,
175
- deduplicateConcurrent: false
204
+ deduplicateConcurrent: false,
205
+ maxHeapMb: 4096,
206
+ maxMemoryMb: 16_384,
207
+ maxOutputBytes: 1_048_576
176
208
  });
177
209
  });
178
210
 
@@ -232,7 +264,14 @@ test("wraps declared tool runs in the shared execution governor", async () => {
232
264
  assert.deepEqual(calls, [
233
265
  {
234
266
  type: "acquire",
235
- execution: { resourceClass: "browser", weight: 1, deduplicateConcurrent: false },
267
+ execution: {
268
+ resourceClass: "browser",
269
+ weight: 1,
270
+ deduplicateConcurrent: false,
271
+ maxHeapMb: 4096,
272
+ maxMemoryMb: 16_384,
273
+ maxOutputBytes: 1_048_576
274
+ },
236
275
  label: "heavy-tool"
237
276
  },
238
277
  { type: "release", label: "heavy-tool" }
@@ -252,7 +291,13 @@ await new Promise((resolve) => setTimeout(resolve, 100));
252
291
  process.stdout.write(JSON.stringify({ ok: true, output: { text: request.args.value } }));
253
292
  `, "utf8");
254
293
  const counterFile = path.join(homeDir, "single-flight-count.txt");
255
- const registry = new ToolRegistry({ executionPolicy: { capacities: { orchestrator: 1 } } });
294
+ const registry = new ToolRegistry({
295
+ executionPolicy: {
296
+ capacities: { orchestrator: 1 },
297
+ maxWorkerRssMb: 4096,
298
+ maxSwapUsedPercent: 100
299
+ }
300
+ });
256
301
  await registry.load();
257
302
  const invocation = {
258
303
  name: "single-flight-tool",
@@ -398,6 +443,57 @@ test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only",
398
443
  assert.doesNotMatch(JSON.stringify(events), /stream diagnostic/);
399
444
  });
400
445
 
446
+ test("contains an isolated tool heap failure and keeps the registry alive", async () => {
447
+ await resetHome();
448
+ const dir = await createFakeTool("heap-bomb-tool", {
449
+ execution: {
450
+ resourceClass: "browser",
451
+ weight: 1,
452
+ maxHeapMb: 64,
453
+ maxMemoryMb: 128,
454
+ maxOutputBytes: 65_536
455
+ }
456
+ });
457
+ await writeFile(path.join(dir, "index.js"), `
458
+ const retained = [];
459
+ while (true) retained.push(new Array(1_000_000).fill("heap-pressure"));
460
+ `, "utf8");
461
+ await createFakeTool("healthy-after-oom");
462
+ const registry = new ToolRegistry({
463
+ runTimeoutMs: 30_000,
464
+ executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
465
+ });
466
+ await registry.load();
467
+
468
+ const failed = await registry.run({ name: "heap-bomb-tool", chatId: "chat-1", request: { args: {} } });
469
+ assert.equal(failed.ok, false);
470
+ assert.equal(failed.status, "outcome_uncertain");
471
+
472
+ const healthy = await registry.run({ name: "healthy-after-oom", chatId: "chat-1", request: { args: {} } });
473
+ assert.equal(healthy.ok, true);
474
+ });
475
+
476
+ test("terminates oversized isolated tool output without taking down the registry", async () => {
477
+ await resetHome();
478
+ const dir = await createFakeTool("oversized-tool", {
479
+ execution: { resourceClass: "browser", weight: 1, maxOutputBytes: 65_536 }
480
+ });
481
+ await writeFile(path.join(dir, "index.js"), `process.stdout.write("x".repeat(70_000));`, "utf8");
482
+ await createFakeTool("healthy-tool");
483
+ const registry = new ToolRegistry({
484
+ executionPolicy: { maxWorkerRssMb: 4096, maxSwapUsedPercent: 100 }
485
+ });
486
+ await registry.load();
487
+
488
+ const oversized = await registry.run({ name: "oversized-tool", chatId: "chat-1", request: { args: {} } });
489
+ assert.equal(oversized.ok, false);
490
+ assert.equal(oversized.status, "outcome_uncertain");
491
+ assert.match(oversized.error, /exceeds 65536 bytes/);
492
+
493
+ const healthy = await registry.run({ name: "healthy-tool", chatId: "chat-1", request: { args: {} } });
494
+ assert.equal(healthy.ok, true);
495
+ });
496
+
401
497
  test("rejects invalid NDJSON sequences and a second terminal event", async () => {
402
498
  const invalidSequence = createToolOutputParser("sequence-tool");
403
499
  await invalidSequence.push(`${JSON.stringify({ version: 1, jobId: "job", type: "accepted", sequence: 1, payload: {} })}\n`);
@@ -414,6 +510,14 @@ test("rejects invalid NDJSON sequences and a second terminal event", async () =>
414
510
  );
415
511
  });
416
512
 
513
+ test("bounds accumulated legacy output before parsing", async () => {
514
+ const parser = createToolOutputParser("legacy-tool", { maxOutputBytes: 10 });
515
+ await assert.rejects(
516
+ () => parser.push("12345678901"),
517
+ (error) => error.code === "TOOL_OUTPUT_LIMIT"
518
+ );
519
+ });
520
+
417
521
  test("preserves pretty-printed single JSON tool responses", async () => {
418
522
  const parser = createToolOutputParser("legacy-tool");
419
523
  const output = JSON.stringify({ ok: true, output: { text: "legacy" } }, null, 2);
@@ -0,0 +1,41 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTuiCapabilityTools, resolveTuiChatId } from "../src/runtime/tui.js";
4
+
5
+ test("uses the first authorized owner scope for TUI capabilities", () => {
6
+ assert.equal(resolveTuiChatId({ telegram: { authorizedChatIds: [879964957, 2] } }), 879964957);
7
+ assert.throws(() => resolveTuiChatId({ telegram: { authorizedChatIds: [] } }), /authorized chat/);
8
+ });
9
+
10
+ test("adapts Pi TUI tools to the running Arisa IPC service", async () => {
11
+ const calls = [];
12
+ const client = {
13
+ tools: {
14
+ list: async (params) => { calls.push(["list", params]); return { tools: [] }; },
15
+ help: async () => "help",
16
+ skills: async () => [],
17
+ setConfig: async () => ({ ok: true }),
18
+ run: async () => ({ ok: true })
19
+ },
20
+ tasks: {
21
+ list: async () => [],
22
+ cancel: async () => ({ ok: true }),
23
+ cancelAll: async () => ({ ok: true })
24
+ }
25
+ };
26
+ const tools = createTuiCapabilityTools(client);
27
+ assert.deepEqual(tools.map((tool) => tool.name), [
28
+ "list_tools",
29
+ "tool_help",
30
+ "tool_skills",
31
+ "set_tool_config",
32
+ "run_tool",
33
+ "list_scheduled_tasks",
34
+ "cancel_scheduled_task",
35
+ "cancel_all_scheduled_tasks"
36
+ ]);
37
+
38
+ const result = await tools[0].execute("call", { query: "email" });
39
+ assert.deepEqual(calls, [["list", { query: "email" }]]);
40
+ assert.match(result.content[0].text, /"tools"/);
41
+ });
@@ -12,11 +12,24 @@ function deferred() {
12
12
  return { promise, resolve };
13
13
  }
14
14
 
15
+ const safeMemoryPressure = async () => ({
16
+ availableBytes: 512 * 1024 * 1024,
17
+ totalBytes: 4 * 1024 * 1024 * 1024,
18
+ workerRssBytes: 100 * 1024 * 1024,
19
+ swapTotalBytes: 1024,
20
+ swapUsedPercent: 50
21
+ });
22
+
23
+ const settle = () => new Promise((resolve) => setImmediate(resolve));
24
+
15
25
  test("normalizes manifest weights and configurable class capacities", () => {
16
26
  assert.deepEqual(normalizeToolExecution({ resourceClass: "browser", weight: 2 }), {
17
27
  resourceClass: "browser",
18
28
  weight: 2,
19
- deduplicateConcurrent: false
29
+ deduplicateConcurrent: false,
30
+ maxHeapMb: 4096,
31
+ maxMemoryMb: 16_384,
32
+ maxOutputBytes: 1_048_576
20
33
  });
21
34
  assert.equal(normalizeToolExecution({
22
35
  resourceClass: "orchestrator",
@@ -33,6 +46,16 @@ test("normalizes manifest weights and configurable class capacities", () => {
33
46
  }), {
34
47
  defaultCapacity: 3,
35
48
  maxQueuedPerClass: 12,
49
+ maxWorkerRssMb: 384,
50
+ maxSwapUsedPercent: 95,
51
+ initialToolMemoryMb: 384,
52
+ minimumToolMemoryMb: 128,
53
+ maximumToolMemoryMb: 4096,
54
+ systemReserveMb: 128,
55
+ coreReserveMb: 384,
56
+ toolHeapPercent: 65,
57
+ toolMemoryHighPercent: 85,
58
+ toolSwapMaxMb: 128,
36
59
  capacities: { browser: 4 }
37
60
  });
38
61
  });
@@ -42,13 +65,15 @@ test("queues weighted work fairly within one resource class", async () => {
42
65
  const governor = new WeightedResourceGovernor({
43
66
  policy: { defaultCapacity: 2 },
44
67
  now: () => time,
45
- memoryUsage: () => ({ rss: 100 * 1024 * 1024 })
68
+ memoryUsage: () => ({ rss: 100 * 1024 * 1024 }),
69
+ memoryPressure: safeMemoryPressure
46
70
  });
47
71
  const execution = { resourceClass: "browser", weight: 1 };
48
72
  const first = await governor.acquire(execution, "first");
49
73
  const second = await governor.acquire(execution, "second");
50
74
  const thirdLease = governor.acquire(execution, "third");
51
75
  const fourthLease = governor.acquire(execution, "fourth");
76
+ await settle();
52
77
 
53
78
  assert.deepEqual(governor.snapshot().resources.browser, {
54
79
  capacity: 2,
@@ -72,26 +97,52 @@ test("queues weighted work fairly within one resource class", async () => {
72
97
  assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
73
98
  });
74
99
 
75
- test("undeclared lightweight work bypasses constrained resource queues", async () => {
76
- const governor = new WeightedResourceGovernor({ policy: { defaultCapacity: 1 } });
100
+ test("undeclared work bypasses the optional resource governor", async () => {
101
+ const governor = new WeightedResourceGovernor({ policy: { defaultCapacity: 1 }, memoryPressure: safeMemoryPressure });
77
102
  const heavy = await governor.acquire({ resourceClass: "browser", weight: 1 }, "heavy");
78
103
  const queued = governor.acquire({ resourceClass: "browser", weight: 1 }, "queued");
104
+ await settle();
79
105
  const light = await governor.acquire(null, "light");
80
106
  assert.equal(light.waitedMs, 0);
107
+ assert.equal(light.memoryLimitMb, undefined);
108
+ assert.equal(light.heapLimitMb, undefined);
81
109
  assert.equal(governor.snapshot().resources.browser.queued, 1);
82
110
  light.release();
83
111
  heavy.release();
84
112
  (await queued).release();
85
113
  });
86
114
 
115
+ test("rejects declared heavy tools before spawn when memory pressure is unsafe", async () => {
116
+ const logs = [];
117
+ const governor = new WeightedResourceGovernor({
118
+ policy: { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 },
119
+ memoryPressure: async () => ({
120
+ availableBytes: 80 * 1024 * 1024,
121
+ workerRssBytes: 400 * 1024 * 1024,
122
+ swapTotalBytes: 1024,
123
+ swapUsedPercent: 50
124
+ }),
125
+ logger: { log: (...parts) => logs.push(parts.join(" ")) }
126
+ });
127
+
128
+ await assert.rejects(
129
+ () => governor.acquire({ resourceClass: "browser", weight: 1 }, "web-browser"),
130
+ (error) => error.code === "TOOL_RESOURCE_PRESSURE" && /was not started/.test(error.message)
131
+ );
132
+ assert.match(logs.join("\n"), /web-browser rejected for browser/);
133
+ assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
134
+ });
135
+
87
136
  test("larger weights consume shared capacity and worker RSS peaks are retained", async () => {
88
137
  let rss = 120 * 1024 * 1024;
89
138
  const governor = new WeightedResourceGovernor({
90
139
  policy: { defaultCapacity: 3 },
91
- memoryUsage: () => ({ rss })
140
+ memoryUsage: () => ({ rss }),
141
+ memoryPressure: safeMemoryPressure
92
142
  });
93
143
  const large = await governor.acquire({ resourceClass: "browser", weight: 2 }, "large");
94
144
  const waiting = governor.acquire({ resourceClass: "browser", weight: 2 }, "waiting");
145
+ await settle();
95
146
  assert.equal(governor.snapshot().resources.browser.queued, 1);
96
147
  rss = 180 * 1024 * 1024;
97
148
  large.release();
@@ -99,3 +150,44 @@ test("larger weights consume shared capacity and worker RSS peaks are retained",
99
150
  assert.equal(governor.snapshot().peakRssBytes, rss);
100
151
  next.release();
101
152
  });
153
+
154
+ test("shares one host memory budget across independent resource classes", async () => {
155
+ const pressure = async () => ({
156
+ availableBytes: 512 * 1024 * 1024,
157
+ totalBytes: 1024 * 1024 * 1024,
158
+ workerRssBytes: 100 * 1024 * 1024,
159
+ swapTotalBytes: 0,
160
+ swapUsedPercent: 0
161
+ });
162
+ const governor = new WeightedResourceGovernor({
163
+ policy: { systemReserveMb: 128, coreReserveMb: 384, initialToolMemoryMb: 384 },
164
+ memoryPressure: pressure
165
+ });
166
+ const browser = await governor.acquire({ resourceClass: "browser" }, "browser");
167
+ const queued = governor.acquire({ resourceClass: "orchestrator" }, "orchestrator");
168
+ await settle();
169
+
170
+ assert.equal(governor.snapshot().memory.budgetMb, 512);
171
+ assert.equal(governor.snapshot().memory.activeMb, 384);
172
+ assert.equal(governor.snapshot().resources.orchestrator.queued, 1);
173
+
174
+ browser.release({ success: true });
175
+ const orchestrator = await queued;
176
+ assert.equal(orchestrator.memoryLimitMb, 384);
177
+ orchestrator.release({ success: true });
178
+ });
179
+
180
+ test("raises a tool memory recommendation after an isolated limit failure", async () => {
181
+ const governor = new WeightedResourceGovernor({
182
+ policy: { initialToolMemoryMb: 256 },
183
+ memoryPressure: safeMemoryPressure
184
+ });
185
+ const first = await governor.acquire({}, "growing-tool");
186
+ assert.equal(first.memoryLimitMb, 256);
187
+ first.release({ memoryLimited: true });
188
+
189
+ const second = await governor.acquire({}, "growing-tool");
190
+ assert.equal(second.memoryLimitMb, 384);
191
+ assert.equal(governor.snapshot().memory.profiles["growing-tool"].memoryLimitFailures, 1);
192
+ second.release({ success: true });
193
+ });