arisa 5.2.7 → 5.2.19
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.
- package/README.md +25 -4
- package/package.json +10 -9
- package/pnpm-workspace.yaml +7 -5
- package/src/core/agent/agent-manager.js +92 -18
- package/src/core/agent/agent-session-lifecycle.js +29 -6
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/auth-flow.js +6 -6
- package/src/core/agent/model-speed.js +3 -1
- package/src/core/agent/pi-auth-login.js +28 -28
- package/src/core/agent/pi-capability-tools.js +8 -4
- package/src/core/agent/pi-runtime.js +14 -21
- package/src/core/agent/session-history-reader.js +168 -0
- package/src/core/agent/session-preload-migration.js +162 -0
- package/src/core/agent/session-rotation.js +29 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/capabilities/capability-service.js +28 -2
- package/src/core/config/config-defaults.js +29 -0
- package/src/core/tasks/task-runner.js +8 -4
- package/src/core/tasks/task-store.js +48 -4
- package/src/core/tools/daemon-processes.js +7 -0
- package/src/core/tools/memory-pressure.js +2 -2
- package/src/core/tools/tool-registry.js +35 -7
- package/src/core/tools/weighted-resource-governor.js +7 -6
- package/src/official-tools.lock.json +129 -48
- package/src/runtime/bootstrap-cli.js +3 -3
- package/src/runtime/bootstrap-telegram.js +7 -7
- package/src/runtime/doctor.js +20 -73
- package/src/runtime/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/process-inspection.js +78 -0
- package/src/runtime/slave-cli.js +20 -10
- package/src/runtime/slave-service.js +299 -7
- package/src/runtime/tool-process-supervisor.js +35 -8
- package/src/runtime/tui.js +6 -7
- package/src/transport/telegram/bot.js +11 -5
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/model-controls.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +67 -15
- package/src/transport/telegram/telegram-auth-controller.js +7 -7
- package/src/transport/telegram/telegram-prompt-controller.js +17 -4
- package/src/transport/telegram/telegram-session-bridge.js +2 -1
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/auth-flow.test.js +2 -2
- package/test/capabilities-security.test.js +36 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-runtime.test.js +2 -4
- package/test/doctor.test.js +19 -0
- package/test/memory-pressure.test.js +7 -2
- package/test/model-selection.test.js +3 -2
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- package/test/official-tool-installer.test.js +13 -0
- package/test/pi-auth-login.test.js +78 -0
- package/test/pi-capability-tools.test.js +3 -0
- package/test/pi-compaction.test.js +21 -0
- package/test/pi-speed-integration.test.js +176 -0
- package/test/session-history-reader.test.js +84 -0
- package/test/session-preload-migration.test.js +120 -0
- package/test/session-rotation.test.js +110 -0
- package/test/slave-cli.test.js +221 -5
- package/test/task-store.test.js +34 -0
- package/test/telegram-prompt-controller.test.js +2 -1
- package/test/telegram-task-dispatcher.test.js +121 -5
- package/test/tool-registry-run.test.js +10 -1
- package/test/weighted-resource-governor.test.js +28 -0
- package/test/worker-tool-fanout.test.js +79 -0
package/test/slave-cli.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { access, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { access, mkdir, mkdtemp, readFile, rm, stat, 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";
|
|
@@ -8,9 +8,15 @@ import { parseSlaveBootstrapUrl } from "../src/runtime/slave-bootstrap-url.js";
|
|
|
8
8
|
import { withSecureRequestFile } from "../src/runtime/secure-request-file.js";
|
|
9
9
|
import { ensureMasterSlaveTool, formatSlaveStatus, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
|
|
10
10
|
import {
|
|
11
|
+
buildSlaveLaunchdPlist,
|
|
11
12
|
buildSlaveSystemdUnit,
|
|
13
|
+
buildSlaveWindowsLauncher,
|
|
14
|
+
buildSlaveWindowsTaskXml,
|
|
15
|
+
controlSlaveService,
|
|
12
16
|
getSlavePaths,
|
|
17
|
+
installSlaveLaunchdService,
|
|
13
18
|
installSlaveSystemdService,
|
|
19
|
+
installSlaveWindowsService,
|
|
14
20
|
registerSlaveServiceProcess,
|
|
15
21
|
selectSlaveServiceAccount
|
|
16
22
|
} from "../src/runtime/slave-service.js";
|
|
@@ -75,6 +81,14 @@ test("selects the invoking service account without prompting", async () => {
|
|
|
75
81
|
await selectSlaveServiceAccount({ euid: 0, currentUser: "root", environment: {} }),
|
|
76
82
|
{ scope: "system", user: "root", root: true, dedicated: false }
|
|
77
83
|
);
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
await selectSlaveServiceAccount({
|
|
86
|
+
platform: "win32",
|
|
87
|
+
currentUser: "martin.clasen",
|
|
88
|
+
environment: { USERDOMAIN: "MCL4SEN", USERNAME: "martin.clasen" }
|
|
89
|
+
}),
|
|
90
|
+
{ scope: "user", user: "MCL4SEN\\martin.clasen", root: false, dedicated: false }
|
|
91
|
+
);
|
|
78
92
|
});
|
|
79
93
|
|
|
80
94
|
test("builds a dedicated headless systemd service with isolated state", () => {
|
|
@@ -106,7 +120,7 @@ test("escapes systemd WorkingDirectory paths without quoting the entire value",
|
|
|
106
120
|
|
|
107
121
|
test("reports Master connectivity separately from pairing and daemon readiness", () => {
|
|
108
122
|
const text = formatSlaveStatus({
|
|
109
|
-
|
|
123
|
+
service: { running: true, status: "active", serviceManager: "launchd" },
|
|
110
124
|
diagnostic: {
|
|
111
125
|
daemon: { state: "ready" },
|
|
112
126
|
role: "slave",
|
|
@@ -118,6 +132,7 @@ test("reports Master connectivity separately from pairing and daemon readiness",
|
|
|
118
132
|
pendingSecrets: 0
|
|
119
133
|
}
|
|
120
134
|
});
|
|
135
|
+
assert.match(text, /Service \(launchd\): active/);
|
|
121
136
|
assert.match(text, /Daemon: ready/);
|
|
122
137
|
assert.match(text, /Paired: yes/);
|
|
123
138
|
assert.match(text, /Connected: no/);
|
|
@@ -163,6 +178,207 @@ test("installs and restarts the Linux systemd target after pairing", async (t) =
|
|
|
163
178
|
]);
|
|
164
179
|
});
|
|
165
180
|
|
|
181
|
+
test("builds and installs a macOS launchd service with isolated state", async (t) => {
|
|
182
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-launchd-"));
|
|
183
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
184
|
+
const account = { scope: "user", user: "tester", root: false, dedicated: false };
|
|
185
|
+
const slaveHome = path.join(root, "home & state");
|
|
186
|
+
const entryFile = "/Applications/Arisa & Tools/index.js";
|
|
187
|
+
const plist = buildSlaveLaunchdPlist({
|
|
188
|
+
account,
|
|
189
|
+
slaveHome,
|
|
190
|
+
entryFile,
|
|
191
|
+
nodePath: "/opt/homebrew/bin/node",
|
|
192
|
+
environment: { PATH: "/opt/homebrew/bin:/usr/bin" }
|
|
193
|
+
});
|
|
194
|
+
assert.match(plist, /<string>com\.arisa\.slave<\/string>/);
|
|
195
|
+
assert.match(plist, /<string>\/Applications\/Arisa & Tools\/index\.js<\/string>/);
|
|
196
|
+
assert.match(plist, /<string>\/opt\/homebrew\/bin:\/usr\/bin<\/string>/);
|
|
197
|
+
assert.match(plist, /<key>RunAtLoad<\/key>\n <true\/>/);
|
|
198
|
+
assert.match(plist, /<key>KeepAlive<\/key>\n <true\/>/);
|
|
199
|
+
|
|
200
|
+
const calls = [];
|
|
201
|
+
const unitDir = path.join(root, "LaunchAgents");
|
|
202
|
+
const result = await installSlaveLaunchdService({
|
|
203
|
+
account,
|
|
204
|
+
slaveHome,
|
|
205
|
+
entryFile,
|
|
206
|
+
execute: async (command, args) => {
|
|
207
|
+
calls.push([command, args]);
|
|
208
|
+
if (args[0] === "bootout") throw new Error("not loaded");
|
|
209
|
+
return { stdout: "", stderr: "" };
|
|
210
|
+
},
|
|
211
|
+
environment: { PATH: "/opt/homebrew/bin:/usr/bin" },
|
|
212
|
+
platform: "darwin",
|
|
213
|
+
uid: 501,
|
|
214
|
+
userUnitDir: unitDir
|
|
215
|
+
});
|
|
216
|
+
assert.equal(result.serviceManager, "launchd");
|
|
217
|
+
assert.equal(result.serviceTarget, "gui/501/com.arisa.slave");
|
|
218
|
+
assert.equal(await access(result.unitFile).then(() => true, () => false), true);
|
|
219
|
+
assert.deepEqual(calls.slice(1), [
|
|
220
|
+
["launchctl", ["bootstrap", "gui/501", result.unitFile]],
|
|
221
|
+
["launchctl", ["enable", "gui/501/com.arisa.slave"]],
|
|
222
|
+
["launchctl", ["kickstart", "-k", "gui/501/com.arisa.slave"]]
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("controls a persisted macOS launchd service", async (t) => {
|
|
227
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-launchd-control-"));
|
|
228
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
229
|
+
const paths = getSlavePaths(root);
|
|
230
|
+
await mkdir(paths.state, { recursive: true });
|
|
231
|
+
await writeFile(paths.descriptorFile, `${JSON.stringify({
|
|
232
|
+
serviceManager: "launchd",
|
|
233
|
+
serviceTarget: "gui/501/com.arisa.slave",
|
|
234
|
+
unitFile: "/Users/tester/Library/LaunchAgents/com.arisa.slave.plist"
|
|
235
|
+
})}\n`);
|
|
236
|
+
const calls = [];
|
|
237
|
+
const execute = async (command, args) => {
|
|
238
|
+
calls.push([command, args]);
|
|
239
|
+
return { stdout: "", stderr: "" };
|
|
240
|
+
};
|
|
241
|
+
const status = await controlSlaveService(paths, "status", { execute });
|
|
242
|
+
assert.deepEqual(status, { running: true, status: "active", serviceManager: "launchd" });
|
|
243
|
+
await controlSlaveService(paths, "restart", { execute });
|
|
244
|
+
assert.deepEqual(calls, [
|
|
245
|
+
["launchctl", ["print", "gui/501/com.arisa.slave"]],
|
|
246
|
+
["launchctl", ["print", "gui/501/com.arisa.slave"]],
|
|
247
|
+
["launchctl", ["enable", "gui/501/com.arisa.slave"]],
|
|
248
|
+
["launchctl", ["kickstart", "-k", "gui/501/com.arisa.slave"]]
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("builds, installs, and controls a Windows scheduled Slave task", async (t) => {
|
|
253
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-windows-"));
|
|
254
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
255
|
+
const account = { scope: "user", user: "MCL4SEN\\martin.clasen", root: false, dedicated: false };
|
|
256
|
+
const entryFile = path.join(root, "Arisa & Tools", "index.js");
|
|
257
|
+
const launcher = buildSlaveWindowsLauncher({
|
|
258
|
+
slaveHome: path.join(root, "slave's home"),
|
|
259
|
+
entryFile,
|
|
260
|
+
nodePath: path.join(root, "Node", "node.exe")
|
|
261
|
+
});
|
|
262
|
+
assert.match(launcher, /process\.env\.ARISA_SLAVE_HOME/);
|
|
263
|
+
assert.match(launcher, /slave's home/);
|
|
264
|
+
assert.match(launcher, /"--service-runner"/);
|
|
265
|
+
assert.match(launcher, /windowsHide: true/);
|
|
266
|
+
assert.doesNotThrow(() => new Function(launcher));
|
|
267
|
+
|
|
268
|
+
const taskXml = buildSlaveWindowsTaskXml({
|
|
269
|
+
account,
|
|
270
|
+
launcherFile: path.join(root, "arisa-slave-launcher.cjs"),
|
|
271
|
+
nodePath: path.join(root, "Node", "node.exe")
|
|
272
|
+
});
|
|
273
|
+
assert.match(taskXml, /<UserId>MCL4SEN\\martin\.clasen<\/UserId>/);
|
|
274
|
+
assert.match(taskXml, /<RestartOnFailure>/);
|
|
275
|
+
assert.match(taskXml, /Node\/node\.exe/);
|
|
276
|
+
assert.match(taskXml, /arisa-slave-launcher\.cjs/);
|
|
277
|
+
assert.match(taskXml, /<WorkingDirectory>/);
|
|
278
|
+
|
|
279
|
+
const calls = [];
|
|
280
|
+
const result = await installSlaveWindowsService({
|
|
281
|
+
account,
|
|
282
|
+
slaveHome: path.join(root, "home"),
|
|
283
|
+
entryFile,
|
|
284
|
+
execute: async (command, args) => {
|
|
285
|
+
calls.push([command, args]);
|
|
286
|
+
if (args[0] === "/End") throw new Error("not running");
|
|
287
|
+
return { stdout: "", stderr: "" };
|
|
288
|
+
},
|
|
289
|
+
platform: "win32"
|
|
290
|
+
});
|
|
291
|
+
assert.equal(result.serviceManager, "windows-task");
|
|
292
|
+
assert.equal(result.serviceTarget, "Arisa Slave");
|
|
293
|
+
assert.equal(await access(result.launcherFile).then(() => true, () => false), true);
|
|
294
|
+
assert.equal(await access(result.unitFile).then(() => true, () => false), true);
|
|
295
|
+
const persistedLauncher = await readFile(result.launcherFile);
|
|
296
|
+
assert.deepEqual([...persistedLauncher.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
|
|
297
|
+
const persistedTask = await readFile(result.unitFile);
|
|
298
|
+
assert.deepEqual([...persistedTask.subarray(0, 2)], [0xff, 0xfe]);
|
|
299
|
+
assert.match(persistedTask.toString("utf16le"), /<Task version="1\.4"/);
|
|
300
|
+
assert.deepEqual(calls.slice(1), [
|
|
301
|
+
["schtasks.exe", ["/Create", "/TN", "Arisa Slave", "/XML", result.unitFile, "/F"]],
|
|
302
|
+
["schtasks.exe", ["/Run", "/TN", "Arisa Slave"]]
|
|
303
|
+
]);
|
|
304
|
+
|
|
305
|
+
const paths = result.paths;
|
|
306
|
+
await writeFile(paths.descriptorFile, `${JSON.stringify({
|
|
307
|
+
serviceManager: "windows-task",
|
|
308
|
+
serviceTarget: "Arisa Slave",
|
|
309
|
+
unitFile: result.unitFile,
|
|
310
|
+
launcherFile: result.launcherFile
|
|
311
|
+
})}\n`);
|
|
312
|
+
await writeFile(paths.pidFile, `${process.pid}\n`);
|
|
313
|
+
calls.length = 0;
|
|
314
|
+
const status = await controlSlaveService(paths, "status", { execute: async (command, args) => {
|
|
315
|
+
calls.push([command, args]);
|
|
316
|
+
return { stdout: "", stderr: "" };
|
|
317
|
+
} });
|
|
318
|
+
assert.deepEqual(status, { running: true, status: "active", serviceManager: "windows-task" });
|
|
319
|
+
assert.deepEqual(calls, [["schtasks.exe", ["/Query", "/TN", "Arisa Slave"]]]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("bootstraps macOS through launchd and persists its service identity", async (t) => {
|
|
323
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-macos-bootstrap-"));
|
|
324
|
+
t.after(() => rm(home, { recursive: true, force: true }));
|
|
325
|
+
const paths = getSlavePaths(home);
|
|
326
|
+
const account = { scope: "user", user: "tester", root: false, dedicated: false };
|
|
327
|
+
const unitFile = "/Users/tester/Library/LaunchAgents/com.arisa.slave.plist";
|
|
328
|
+
await runSlaveBootstrap(`tcp://198.51.100.12:4719/${secret}`, {
|
|
329
|
+
paths,
|
|
330
|
+
entryFile: "/opt/arisa/src/index.js",
|
|
331
|
+
platform: "darwin",
|
|
332
|
+
selectAccount: async () => account,
|
|
333
|
+
ensureTool: async () => {},
|
|
334
|
+
invokeTool: async () => ({ ok: true }),
|
|
335
|
+
installService: async (options) => {
|
|
336
|
+
assert.equal(options.platform, "darwin");
|
|
337
|
+
return {
|
|
338
|
+
serviceManager: "launchd",
|
|
339
|
+
serviceTarget: "gui/501/com.arisa.slave",
|
|
340
|
+
unitFile
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
output: { log: () => {} }
|
|
344
|
+
});
|
|
345
|
+
const descriptor = JSON.parse(await readFile(paths.descriptorFile, "utf8"));
|
|
346
|
+
assert.equal(descriptor.serviceManager, "launchd");
|
|
347
|
+
assert.equal(descriptor.serviceTarget, "gui/501/com.arisa.slave");
|
|
348
|
+
assert.equal(descriptor.unitFile, unitFile);
|
|
349
|
+
assert.deepEqual(descriptor.account, account);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("bootstraps Windows through Task Scheduler and persists its service identity", async (t) => {
|
|
353
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-windows-bootstrap-"));
|
|
354
|
+
t.after(() => rm(home, { recursive: true, force: true }));
|
|
355
|
+
const paths = getSlavePaths(home);
|
|
356
|
+
const account = { scope: "user", user: "MCL4SEN\\martin.clasen", root: false, dedicated: false };
|
|
357
|
+
const launcherFile = path.join(home, "state", "arisa-slave-launcher.cjs");
|
|
358
|
+
await runSlaveBootstrap(`tcp://198.51.100.12:4719/${secret}`, {
|
|
359
|
+
paths,
|
|
360
|
+
entryFile: "/opt/arisa/src/index.js",
|
|
361
|
+
platform: "win32",
|
|
362
|
+
selectAccount: async () => account,
|
|
363
|
+
ensureTool: async () => {},
|
|
364
|
+
invokeTool: async () => ({ ok: true }),
|
|
365
|
+
installService: async (options) => {
|
|
366
|
+
assert.equal(options.platform, "win32");
|
|
367
|
+
return {
|
|
368
|
+
serviceManager: "windows-task",
|
|
369
|
+
serviceTarget: "Arisa Slave",
|
|
370
|
+
unitFile: path.join(home, "state", "arisa-slave-task.xml"),
|
|
371
|
+
launcherFile
|
|
372
|
+
};
|
|
373
|
+
},
|
|
374
|
+
output: { log: () => {} }
|
|
375
|
+
});
|
|
376
|
+
const descriptor = JSON.parse(await readFile(paths.descriptorFile, "utf8"));
|
|
377
|
+
assert.equal(descriptor.serviceManager, "windows-task");
|
|
378
|
+
assert.equal(descriptor.serviceTarget, "Arisa Slave");
|
|
379
|
+
assert.equal(descriptor.launcherFile, launcherFile);
|
|
380
|
+
});
|
|
381
|
+
|
|
166
382
|
test("validates before effects and keeps the bootstrap secret out of service metadata", async (t) => {
|
|
167
383
|
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-home-"));
|
|
168
384
|
t.after(() => rm(home, { recursive: true, force: true }));
|
|
@@ -298,7 +514,7 @@ test("combines systemd and local tool diagnostics for Slave status", async (t) =
|
|
|
298
514
|
paths: getSlavePaths(home),
|
|
299
515
|
controlService: async (_paths, operation) => {
|
|
300
516
|
assert.equal(operation, "status");
|
|
301
|
-
return { running: true, status: "active" };
|
|
517
|
+
return { running: true, status: "active", serviceManager: "systemd" };
|
|
302
518
|
},
|
|
303
519
|
toolInstalled: async () => true,
|
|
304
520
|
invokeTool: async (_paths, args) => {
|
|
@@ -321,8 +537,8 @@ test("combines systemd and local tool diagnostics for Slave status", async (t) =
|
|
|
321
537
|
},
|
|
322
538
|
output: { log: (line) => output.push(line) }
|
|
323
539
|
});
|
|
324
|
-
assert.equal(result.
|
|
325
|
-
assert.match(output[0], /
|
|
540
|
+
assert.equal(result.service.running, true);
|
|
541
|
+
assert.match(output[0], /Service \(systemd\): active/);
|
|
326
542
|
assert.match(output[0], /Daemon: ready/);
|
|
327
543
|
assert.match(output[0], /Role: slave/);
|
|
328
544
|
assert.match(output[0], /Endpoint: tcp:\/\/198\.51\.100\.12:4719/);
|
package/test/task-store.test.js
CHANGED
|
@@ -121,6 +121,40 @@ 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
|
+
toolName: "checker"
|
|
140
|
+
});
|
|
141
|
+
assert.equal(blocked.status, "blocked_auth");
|
|
142
|
+
assert.equal(blocked.authBlockedNew, true);
|
|
143
|
+
assert.deepEqual(blocked.authBlock.probeArgs, { action: "auth-status" });
|
|
144
|
+
assert.equal(blocked.authBlock.toolName, "checker");
|
|
145
|
+
assert.deepEqual(await store.claimDue(), []);
|
|
146
|
+
|
|
147
|
+
store.tasks.find((task) => task.id === "auth-poll").runAt = new Date(Date.now() - 1000).toISOString();
|
|
148
|
+
await store.save();
|
|
149
|
+
const [probe] = await store.claimDue();
|
|
150
|
+
assert.equal(probe.status, "running");
|
|
151
|
+
assert.ok(probe.authBlock);
|
|
152
|
+
|
|
153
|
+
const completed = await store.complete("auth-poll");
|
|
154
|
+
assert.equal(completed.status, "pending");
|
|
155
|
+
assert.equal(completed.authBlock, undefined);
|
|
156
|
+
});
|
|
157
|
+
|
|
124
158
|
test("completes one-off tasks and re-schedules recurring interval tasks", async () => {
|
|
125
159
|
await resetHome();
|
|
126
160
|
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, authBlock: resolution, 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: {
|
|
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,121 @@ 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, toolName: "checker" }]);
|
|
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("pauses a recurring agent task after a nested tool reports blocked authentication", async () => {
|
|
153
|
+
const resolution = {
|
|
154
|
+
type: "reauthentication_required",
|
|
155
|
+
retryAfterSeconds: 3600,
|
|
156
|
+
probeArgs: { action: "status" }
|
|
157
|
+
};
|
|
158
|
+
const { calls, dispatcher } = createHarness({
|
|
159
|
+
dependencies: {
|
|
160
|
+
enqueueAsyncPrompt: async (input) => {
|
|
161
|
+
calls.push(["enqueue", input]);
|
|
162
|
+
input.agentTaskExecution.blockedAuth = {
|
|
163
|
+
toolName: "creator-scout",
|
|
164
|
+
error: "authentication expired",
|
|
165
|
+
resolution
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await dispatcher.runClaimedTask({
|
|
172
|
+
id: "agent-auth",
|
|
173
|
+
kind: "agent_task",
|
|
174
|
+
payload: { chatId: 123, prompt: "run harvest" },
|
|
175
|
+
recurrence: { type: "interval", everySeconds: 14400 }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(calls.at(-2), ["blockAuth", "agent-auth", "authentication expired", { ...resolution, toolName: "creator-scout" }]);
|
|
179
|
+
assert.deepEqual(calls.at(-1), [
|
|
180
|
+
"send",
|
|
181
|
+
123,
|
|
182
|
+
"⚠️ Arisa automation paused for authentication\nTool: creator-scout\nReason: authentication expired\nNext authentication check: 2026-09-02T05:00:00.000Z",
|
|
183
|
+
undefined
|
|
184
|
+
]);
|
|
185
|
+
assert.equal(calls.some(([name]) => name === "complete"), false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("probes a blocked agent task before starting another reasoning turn", async () => {
|
|
189
|
+
const { calls, dispatcher } = createHarness();
|
|
190
|
+
await dispatcher.runClaimedTask({
|
|
191
|
+
id: "agent-resume",
|
|
192
|
+
kind: "agent_task",
|
|
193
|
+
authBlock: { toolName: "creator-scout", probeArgs: { action: "status" }, retryAfterSeconds: 3600 },
|
|
194
|
+
payload: { chatId: 123, prompt: "run harvest" }
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
assert.deepEqual(calls[0], ["runTurn", { priority: "background", label: "authentication probe creator-scout" }]);
|
|
198
|
+
assert.deepEqual(calls[1], ["runTool", {
|
|
199
|
+
name: "creator-scout",
|
|
200
|
+
request: { args: { action: "status" } },
|
|
201
|
+
chatId: 123
|
|
202
|
+
}]);
|
|
203
|
+
assert.equal(calls.filter(([name]) => name === "enqueue").length, 1);
|
|
204
|
+
assert.deepEqual(calls.at(-1), ["complete", "agent-resume"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("uses a lightweight auth probe before resuming blocked poll work", async () => {
|
|
208
|
+
const { calls, dispatcher } = createHarness();
|
|
209
|
+
await dispatcher.runClaimedTask({
|
|
210
|
+
id: "poll-resume",
|
|
211
|
+
kind: "poll_tool",
|
|
212
|
+
authBlock: { probeArgs: { action: "auth-status" }, retryAfterSeconds: 3600 },
|
|
213
|
+
payload: { chatId: 123, toolName: "checker", args: { action: "poll" } }
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
assert.deepEqual(calls.filter(([name]) => name === "runTool").map((call) => call[1].request.args), [
|
|
217
|
+
{ action: "auth-status" },
|
|
218
|
+
{ action: "poll" }
|
|
219
|
+
]);
|
|
220
|
+
assert.deepEqual(calls.at(-1), ["complete", "poll-resume"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
109
223
|
test("retries a known poll failure with backoff", async () => {
|
|
110
224
|
const { calls, dispatcher } = createHarness({
|
|
111
225
|
dependencies: {
|
|
112
226
|
agentManager: {
|
|
227
|
+
async runTurn(options, work) { calls.push(["runTurn", options]); return work(); },
|
|
113
228
|
async runTool(input) {
|
|
114
229
|
calls.push(["runTool", input]);
|
|
115
230
|
return { ok: false, status: "failed", error: "temporary checker failure" };
|
|
@@ -220,10 +335,11 @@ test("due-task dispatch retries one failure without blocking another task", asyn
|
|
|
220
335
|
|
|
221
336
|
await dispatcher.dispatchDueTasks();
|
|
222
337
|
|
|
223
|
-
assert.deepEqual(calls, [
|
|
338
|
+
assert.deepEqual(calls.slice(0, 3), [
|
|
224
339
|
["claimDue", 10],
|
|
225
|
-
["
|
|
226
|
-
["
|
|
227
|
-
["retryOrFail", "bad", "queue unavailable", { retryable: true }]
|
|
340
|
+
["runTurn", { priority: "background", label: "poll tool checker" }],
|
|
341
|
+
["runTool", { name: "checker", request: { args: {} }, chatId: 123 }]
|
|
228
342
|
]);
|
|
343
|
+
assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["complete", "good"])));
|
|
344
|
+
assert.ok(calls.some((call) => JSON.stringify(call) === JSON.stringify(["retryOrFail", "bad", "queue unavailable", { retryable: true }])));
|
|
229
345
|
});
|
|
@@ -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
|
+
});
|