arisa 5.2.7 → 5.2.17

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 (45) hide show
  1. package/README.md +21 -2
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +79 -4
  4. package/src/core/agent/agent-session-lifecycle.js +29 -6
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/pi-capability-tools.js +7 -4
  7. package/src/core/agent/session-history-reader.js +168 -0
  8. package/src/core/agent/session-preload-migration.js +162 -0
  9. package/src/core/agent/session-rotation.js +29 -0
  10. package/src/core/agent/worker-tool-fanout.js +117 -0
  11. package/src/core/capabilities/capability-service.js +3 -2
  12. package/src/core/config/config-defaults.js +29 -0
  13. package/src/core/tasks/task-runner.js +8 -4
  14. package/src/core/tasks/task-store.js +47 -4
  15. package/src/core/tools/daemon-processes.js +7 -0
  16. package/src/core/tools/memory-pressure.js +2 -2
  17. package/src/core/tools/tool-registry.js +35 -7
  18. package/src/core/tools/weighted-resource-governor.js +7 -6
  19. package/src/official-tools.lock.json +121 -47
  20. package/src/runtime/doctor.js +20 -73
  21. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  22. package/src/runtime/process-inspection.js +78 -0
  23. package/src/runtime/tool-process-supervisor.js +35 -8
  24. package/src/runtime/tui.js +1 -1
  25. package/src/transport/telegram/bot.js +3 -1
  26. package/src/transport/telegram/chat-queue.js +6 -2
  27. package/src/transport/telegram/task-dispatcher.js +36 -11
  28. package/src/transport/telegram/telegram-prompt-controller.js +17 -4
  29. package/test/agent-turn-coordinator.test.js +45 -0
  30. package/test/context-and-task-bounds.test.js +2 -1
  31. package/test/daemon-runtime.test.js +2 -4
  32. package/test/doctor.test.js +19 -0
  33. package/test/memory-pressure.test.js +7 -2
  34. package/test/obsolete-daemon-reaper.test.js +61 -0
  35. package/test/official-tool-dependencies.test.js +7 -2
  36. package/test/pi-compaction.test.js +21 -0
  37. package/test/session-history-reader.test.js +84 -0
  38. package/test/session-preload-migration.test.js +120 -0
  39. package/test/session-rotation.test.js +110 -0
  40. package/test/task-store.test.js +32 -0
  41. package/test/telegram-prompt-controller.test.js +2 -1
  42. package/test/telegram-task-dispatcher.test.js +66 -5
  43. package/test/tool-registry-run.test.js +10 -1
  44. package/test/weighted-resource-governor.test.js +28 -0
  45. package/test/worker-tool-fanout.test.js +79 -0
@@ -121,6 +121,38 @@ test("recovers interrupted running tasks for retry after restart", async () => {
121
121
  );
122
122
  });
123
123
 
124
+ test("persists auth blocks, claims only due probes, and clears the block after success", async () => {
125
+ await resetHome();
126
+ const store = new TaskStore();
127
+ await store.add({
128
+ id: "auth-poll",
129
+ kind: "poll_tool",
130
+ runAt: new Date(Date.now() - 1000).toISOString(),
131
+ payload: { toolName: "checker", args: { action: "poll" } },
132
+ recurrence: { type: "interval", everySeconds: 60 }
133
+ });
134
+
135
+ await store.claimDue();
136
+ const blocked = await store.blockAuth("auth-poll", "authentication expired", {
137
+ retryAfterSeconds: 3600,
138
+ probeArgs: { action: "auth-status" }
139
+ });
140
+ assert.equal(blocked.status, "blocked_auth");
141
+ assert.equal(blocked.authBlockedNew, true);
142
+ assert.deepEqual(blocked.authBlock.probeArgs, { action: "auth-status" });
143
+ assert.deepEqual(await store.claimDue(), []);
144
+
145
+ store.tasks.find((task) => task.id === "auth-poll").runAt = new Date(Date.now() - 1000).toISOString();
146
+ await store.save();
147
+ const [probe] = await store.claimDue();
148
+ assert.equal(probe.status, "running");
149
+ assert.ok(probe.authBlock);
150
+
151
+ const completed = await store.complete("auth-poll");
152
+ assert.equal(completed.status, "pending");
153
+ assert.equal(completed.authBlock, undefined);
154
+ });
155
+
124
156
  test("completes one-off tasks and re-schedules recurring interval tasks", async () => {
125
157
  await resetHome();
126
158
  const store = new TaskStore();
@@ -12,7 +12,8 @@ function createController(overrides = {}) {
12
12
  artifactStore: {},
13
13
  toolRegistry: {},
14
14
  agentManager: {
15
- resetSession: (...args) => calls.reset.push(args)
15
+ resetSession: (...args) => calls.reset.push(args),
16
+ runTurn: async (_options, work) => work()
16
17
  },
17
18
  sessionSeeds: {
18
19
  clear: async (chatId) => calls.cleared.push(chatId)
@@ -7,6 +7,10 @@ function createHarness(overrides = {}) {
7
7
  const taskStore = {
8
8
  async fail(...args) { calls.push(["fail", ...args]); return { status: "failed" }; },
9
9
  async complete(...args) { calls.push(["complete", ...args]); return { status: "done" }; },
10
+ async blockAuth(taskId, error, resolution) {
11
+ calls.push(["blockAuth", taskId, error.message, resolution]);
12
+ return { status: "blocked_auth", authBlockedNew: true, runAt: "2026-09-02T05:00:00.000Z" };
13
+ },
10
14
  async retryOrFail(taskId, error, options) {
11
15
  calls.push(["retryOrFail", taskId, error.message, options]);
12
16
  return { status: options.retryable ? "pending" : "failed" };
@@ -21,7 +25,10 @@ function createHarness(overrides = {}) {
21
25
  artifactStore: { forChat() { throw new Error("unexpected artifact access"); } },
22
26
  toolRegistry: {},
23
27
  resourceNotes: { async get() { return ""; } },
24
- agentManager: { async runTool(input) { calls.push(["runTool", input]); return { ok: true }; } },
28
+ agentManager: {
29
+ async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
30
+ async runTool(input) { calls.push(["runTool", input]); return { ok: true }; }
31
+ },
25
32
  logger: null,
26
33
  ...overrides.dependencies
27
34
  });
@@ -74,7 +81,9 @@ test("passes bounded execution deadlines to scheduled prompts", async () => {
74
81
 
75
82
  const enqueues = calls.filter(([name]) => name === "enqueue");
76
83
  assert.equal(enqueues[0][1].timeoutMs, 900);
84
+ assert.equal(enqueues[0][1].priority, "background");
77
85
  assert.equal(enqueues[1][1].timeoutMs, 300);
86
+ assert.equal(enqueues[1][1].priority, "interactive");
78
87
  });
79
88
 
80
89
  test("acknowledges an agent event before executing it", async () => {
@@ -101,15 +110,66 @@ test("runs poll tools headlessly and confirms their result", async () => {
101
110
  });
102
111
 
103
112
  assert.deepEqual(calls, [
113
+ ["runTurn", { priority: "background", label: "poll tool checker" }],
104
114
  ["runTool", { name: "checker", request: { args: { cursor: "4" } }, chatId: 123 }],
105
115
  ["complete", "poll-1"]
106
116
  ]);
107
117
  });
108
118
 
119
+ test("pauses a poll once when its tool reports terminal authentication failure", async () => {
120
+ const resolution = {
121
+ type: "reauthentication_required",
122
+ retryAfterSeconds: 3600,
123
+ probeArgs: { action: "auth-status" }
124
+ };
125
+ const { calls, dispatcher } = createHarness({
126
+ dependencies: {
127
+ agentManager: {
128
+ async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
129
+ async runTool(input) {
130
+ calls.push(["runTool", input]);
131
+ return { ok: false, status: "blocked_auth", error: "authentication expired", resolution };
132
+ }
133
+ }
134
+ }
135
+ });
136
+
137
+ await dispatcher.runClaimedTask({
138
+ id: "poll-auth",
139
+ kind: "poll_tool",
140
+ payload: { chatId: 123, toolName: "checker", args: { action: "poll" } }
141
+ });
142
+
143
+ assert.deepEqual(calls.at(-2), ["blockAuth", "poll-auth", "authentication expired", resolution]);
144
+ assert.deepEqual(calls.at(-1), [
145
+ "send",
146
+ 123,
147
+ "⚠️ Arisa automation paused for authentication\nTool: checker\nReason: authentication expired\nNext authentication check: 2026-09-02T05:00:00.000Z",
148
+ undefined
149
+ ]);
150
+ });
151
+
152
+ test("uses a lightweight auth probe before resuming blocked poll work", async () => {
153
+ const { calls, dispatcher } = createHarness();
154
+ await dispatcher.runClaimedTask({
155
+ id: "poll-resume",
156
+ kind: "poll_tool",
157
+ authBlock: { probeArgs: { action: "auth-status" }, retryAfterSeconds: 3600 },
158
+ payload: { chatId: 123, toolName: "checker", args: { action: "poll" } }
159
+ });
160
+
161
+ assert.deepEqual(calls.filter(([name]) => name === "runTool").map((call) => call[1].request.args), [
162
+ { action: "auth-status" },
163
+ { action: "poll" }
164
+ ]);
165
+ assert.deepEqual(calls.at(-1), ["complete", "poll-resume"]);
166
+ });
167
+
109
168
  test("retries a known poll failure with backoff", async () => {
110
169
  const { calls, dispatcher } = createHarness({
111
170
  dependencies: {
112
171
  agentManager: {
172
+ async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
113
173
  async runTool(input) {
114
174
  calls.push(["runTool", input]);
115
175
  return { ok: false, status: "failed", error: "temporary checker failure" };
@@ -220,10 +280,11 @@ test("due-task dispatch retries one failure without blocking another task", asyn
220
280
 
221
281
  await dispatcher.dispatchDueTasks();
222
282
 
223
- assert.deepEqual(calls, [
283
+ assert.deepEqual(calls.slice(0, 3), [
224
284
  ["claimDue", 10],
225
- ["runTool", { name: "checker", request: { args: {} }, chatId: 123 }],
226
- ["complete", "good"],
227
- ["retryOrFail", "bad", "queue unavailable", { retryable: true }]
285
+ ["runTurn", { priority: "background", label: "poll tool checker" }],
286
+ ["runTool", { name: "checker", request: { args: {} }, chatId: 123 }]
228
287
  ]);
288
+ assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["complete", "good"])));
289
+ assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["retryOrFail", "bad", "queue unavailable", { retryable: true }])));
229
290
  });
@@ -8,7 +8,7 @@ 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, isolatedToolProcessInvocation } = await import("../src/core/tools/tool-registry.js");
11
+ const { ToolRegistry, createToolOutputParser, isolatedToolProcessInvocation, readyDaemonAdmission } = await import("../src/core/tools/tool-registry.js");
12
12
  const { createToolOutputParser: directToolOutputParser } = await import("../src/core/tools/tool-process-output.js");
13
13
  const { isolatedToolProcessInvocation: directToolProcessInvocation } = await import("../src/core/tools/tool-process-runner.js");
14
14
  const {
@@ -242,6 +242,15 @@ test("reports dependency status in help and blocks a tool with a missing depende
242
242
  );
243
243
  });
244
244
 
245
+ test("only a live ready declared daemon bypasses worker RSS spawn admission", () => {
246
+ const tool = { daemon: { scope: "chat", health: "internal" } };
247
+ assert.deepEqual(readyDaemonAdmission(tool, { alive: true, state: "ready", restart: { requested: false } }), { ignoreWorkerRss: true });
248
+ assert.deepEqual(readyDaemonAdmission(tool, { alive: false, state: "ready", restart: { requested: false } }), {});
249
+ assert.deepEqual(readyDaemonAdmission(tool, { alive: true, state: "degraded", restart: { requested: false } }), {});
250
+ assert.deepEqual(readyDaemonAdmission(tool, { alive: true, state: "ready", restart: { requested: true } }), {});
251
+ assert.deepEqual(readyDaemonAdmission({}, { alive: true, state: "ready" }), {});
252
+ });
253
+
245
254
  test("wraps declared tool runs in the shared execution governor", async () => {
246
255
  await resetHome();
247
256
  await createFakeTool("heavy-tool", {
@@ -133,6 +133,34 @@ test("rejects declared heavy tools before spawn when memory pressure is unsafe",
133
133
  assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
134
134
  });
135
135
 
136
+ test("ready daemon jobs bypass only worker RSS spawn admission", async () => {
137
+ const governor = new WeightedResourceGovernor({
138
+ policy: { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 },
139
+ memoryPressure: async () => ({
140
+ availableBytes: 512 * 1024 * 1024,
141
+ totalBytes: 4 * 1024 * 1024 * 1024,
142
+ workerRssBytes: 450 * 1024 * 1024,
143
+ swapTotalBytes: 100,
144
+ swapUsedPercent: 50
145
+ })
146
+ });
147
+ const lease = await governor.acquire({ resourceClass: "browser", weight: 1 }, "ready-daemon", { ignoreWorkerRss: true });
148
+ assert.equal(lease.memoryLimitMb, 384);
149
+ lease.release({ success: true });
150
+
151
+ governor.memoryPressure = async () => ({
152
+ availableBytes: 512 * 1024 * 1024,
153
+ totalBytes: 4 * 1024 * 1024 * 1024,
154
+ workerRssBytes: 450 * 1024 * 1024,
155
+ swapTotalBytes: 100,
156
+ swapUsedPercent: 96
157
+ });
158
+ await assert.rejects(
159
+ () => governor.acquire({ resourceClass: "browser", weight: 1 }, "ready-daemon", { ignoreWorkerRss: true }),
160
+ /swap use/
161
+ );
162
+ });
163
+
136
164
  test("larger weights consume shared capacity and worker RSS peaks are retained", async () => {
137
165
  let rss = 120 * 1024 * 1024;
138
166
  const governor = new WeightedResourceGovernor({
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { WorkerToolFanoutController } from "../src/core/agent/worker-tool-fanout.js";
4
+
5
+ function deferred() {
6
+ let resolve;
7
+ const promise = new Promise((done) => { resolve = done; });
8
+ return { promise, resolve };
9
+ }
10
+
11
+ function breaker(percent = 20) {
12
+ return {
13
+ sample: () => ({ heapUsed: percent, heapLimit: 100, percent }),
14
+ admit: async () => ({ heapUsed: percent, heapLimit: 100, percent })
15
+ };
16
+ }
17
+
18
+ test("admits low-pressure tool calls in pairs", async () => {
19
+ const controller = new WorkerToolFanoutController({ heapCircuitBreaker: breaker() });
20
+ const gates = Array.from({ length: 4 }, deferred);
21
+ let active = 0;
22
+ let peak = 0;
23
+ const runs = gates.map((gate) => controller.run(async () => {
24
+ active += 1;
25
+ peak = Math.max(peak, active);
26
+ await gate.promise;
27
+ active -= 1;
28
+ }));
29
+
30
+ await new Promise((resolve) => setImmediate(resolve));
31
+ assert.equal(active, 2);
32
+ gates[0].resolve();
33
+ gates[1].resolve();
34
+ await new Promise((resolve) => setImmediate(resolve));
35
+ assert.equal(active, 2);
36
+ gates[2].resolve();
37
+ gates[3].resolve();
38
+ await Promise.all(runs);
39
+
40
+ assert.equal(peak, 2);
41
+ assert.equal(controller.getDiagnostic().peakQueued, 3);
42
+ });
43
+
44
+ test("serializes tool calls when the worker heap is elevated", async () => {
45
+ const controller = new WorkerToolFanoutController({ heapCircuitBreaker: breaker(70) });
46
+ const gates = Array.from({ length: 3 }, deferred);
47
+ let active = 0;
48
+ let peak = 0;
49
+ const runs = gates.map((gate) => controller.run(async () => {
50
+ active += 1;
51
+ peak = Math.max(peak, active);
52
+ await gate.promise;
53
+ active -= 1;
54
+ }));
55
+
56
+ await new Promise((resolve) => setImmediate(resolve));
57
+ assert.equal(active, 1);
58
+ for (const gate of gates) {
59
+ gate.resolve();
60
+ await new Promise((resolve) => setImmediate(resolve));
61
+ }
62
+ await Promise.all(runs);
63
+
64
+ assert.equal(peak, 1);
65
+ assert.ok(controller.getDiagnostic().pressureSerializations > 0);
66
+ });
67
+
68
+ test("rejects a queued tool call when heap admission remains critical", async () => {
69
+ const expected = Object.assign(new Error("critical"), { code: "WORKER_HEAP_PRESSURE" });
70
+ const controller = new WorkerToolFanoutController({
71
+ heapCircuitBreaker: {
72
+ sample: () => ({ heapUsed: 90, heapLimit: 100, percent: 90 }),
73
+ admit: async () => { throw expected; }
74
+ }
75
+ });
76
+
77
+ await assert.rejects(controller.run(async () => "unreachable"), expected);
78
+ assert.equal(controller.getDiagnostic().rejectedAdmissions, 1);
79
+ });