arisa 5.1.66 → 5.1.68
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 +1 -1
- package/package.json +1 -1
- package/src/core/config/config-defaults.js +1 -1
- package/src/core/tools/daemon-health.js +5 -2
- package/src/core/tools/tool-registry.js +34 -2
- package/src/core/tools/weighted-resource-governor.js +5 -1
- package/src/official-tools.lock.json +16 -20
- package/src/runtime/slave-cli.js +1 -0
- package/test/daemon-runtime.test.js +20 -0
- package/test/model-selection.test.js +1 -1
- package/test/slave-cli.test.js +20 -1
- package/test/tool-registry-run.test.js +42 -3
- package/test/weighted-resource-governor.test.js +7 -1
package/README.md
CHANGED
|
@@ -183,7 +183,7 @@ Authorized Telegram chats can run the same safe service lifecycle with `/restart
|
|
|
183
183
|
|
|
184
184
|
Background mode runs the Telegram/Pi worker under a lightweight supervisor. Unexpected worker exits use bounded exponential restart backoff. Scheduled agent tasks are serialized FIFO per conversation while different conversations remain independent; execution deadlines default to 15 minutes for scheduled prompts and 5 minutes for agent events. A timed-out turn is marked outcome-uncertain and is never replayed automatically. These policies can be overridden with `service.workerRestart*` and `tasks.*TimeoutMs` in the Arisa config.
|
|
185
185
|
|
|
186
|
-
Tools may declare weighted execution resources in their manifest, for example `"execution": { "resourceClass": "browser", "weight": 1 }`. Runs sharing a declared class queue fairly once they reach its capacity; undeclared lightweight tools remain unconstrained. The default capacity is two per declared class. Override
|
|
186
|
+
Tools may declare weighted execution resources in their manifest, for example `"execution": { "resourceClass": "browser", "weight": 1 }`. Runs sharing a declared class queue fairly once they reach its capacity; undeclared lightweight tools remain unconstrained. The default capacity is two per declared class, while the built-in `orchestrator` class has capacity one. An opt-in tool may set `deduplicateConcurrent: true` to join only exact concurrent duplicates from the same chat; later or different requests still execute normally. Override capacities with `toolExecution.defaultCapacity`, `toolExecution.capacities`, and `toolExecution.maxQueuedPerClass`. Arisa logs queue waits, joined duplicates, and new worker RSS peaks for operational measurement.
|
|
187
187
|
|
|
188
188
|
Runtime model override (current process only):
|
|
189
189
|
|
package/package.json
CHANGED
|
@@ -19,7 +19,7 @@ export const daemonConfigDefaults = Object.freeze({
|
|
|
19
19
|
export const toolExecutionConfigDefaults = Object.freeze({
|
|
20
20
|
defaultCapacity: 2,
|
|
21
21
|
maxQueuedPerClass: 100,
|
|
22
|
-
capacities: Object.freeze({})
|
|
22
|
+
capacities: Object.freeze({ orchestrator: 1 })
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
export const telegramConfigDefaults = Object.freeze({
|
|
@@ -97,7 +97,7 @@ async function tryInternalRecovery(record, paths, policy) {
|
|
|
97
97
|
async function scheduleRestart(record, paths, status, policy, reason) {
|
|
98
98
|
const current = await readJson(paths.statusFile, status);
|
|
99
99
|
const restartAttempts = Number(current.restartAttempts || 0) + 1;
|
|
100
|
-
if (restartAttempts > policy.restartLimit) {
|
|
100
|
+
if (restartAttempts > policy.restartLimit && !record.autoStart) {
|
|
101
101
|
await stopManagedDaemon(
|
|
102
102
|
{ toolName: record.toolName, scope: record.scope },
|
|
103
103
|
{ state: null }
|
|
@@ -115,6 +115,9 @@ async function scheduleRestart(record, paths, status, policy, reason) {
|
|
|
115
115
|
return "failed";
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
const retainedAttempts = record.autoStart && restartAttempts > policy.restartLimit
|
|
119
|
+
? policy.restartLimit
|
|
120
|
+
: restartAttempts;
|
|
118
121
|
const delayMs = retryDelay(restartAttempts, policy);
|
|
119
122
|
await stopManagedDaemon(
|
|
120
123
|
{ toolName: record.toolName, scope: record.scope },
|
|
@@ -124,7 +127,7 @@ async function scheduleRestart(record, paths, status, policy, reason) {
|
|
|
124
127
|
state: "restarting",
|
|
125
128
|
pid: null,
|
|
126
129
|
heartbeatAt: null,
|
|
127
|
-
restartAttempts,
|
|
130
|
+
restartAttempts: retainedAttempts,
|
|
128
131
|
restartRequested: true,
|
|
129
132
|
nextRestartAt: new Date(Date.now() + delayMs).toISOString(),
|
|
130
133
|
lastError: errorRecord("restart", reason),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { arisaIpcSocketFile, arisaPackageDir, getToolConfigPath, getToolStateDir, getToolTmpDir, getChatToolTmpDir, toolsDir as userToolsRoot } from "../../runtime/paths.js";
|
|
6
6
|
import { loadToolConfig, parseConfigModule, writeToolConfig } from "./tool-config.js";
|
|
7
7
|
import { normalizeToolResult } from "./tool-result.js";
|
|
@@ -25,6 +25,19 @@ function positiveDuration(value, fallback) {
|
|
|
25
25
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function canonicalRequestValue(value) {
|
|
29
|
+
if (Array.isArray(value)) return value.map(canonicalRequestValue);
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalRequestValue(value[key])]));
|
|
32
|
+
}
|
|
33
|
+
return value === undefined ? null : value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function concurrentExecutionKey(name, chatId, request) {
|
|
37
|
+
const serialized = JSON.stringify(canonicalRequestValue({ name, chatId: chatId == null ? null : String(chatId), request }));
|
|
38
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
function waitForToolProcess(child, { timeoutMs, killGraceMs, label }) {
|
|
29
42
|
return new Promise((resolve, reject) => {
|
|
30
43
|
let timedOut = false;
|
|
@@ -289,6 +302,7 @@ export class ToolRegistry {
|
|
|
289
302
|
policy: executionPolicy,
|
|
290
303
|
logger
|
|
291
304
|
});
|
|
305
|
+
this.concurrentExecutions = new Map();
|
|
292
306
|
}
|
|
293
307
|
|
|
294
308
|
async buildSnapshot() {
|
|
@@ -476,7 +490,25 @@ export class ToolRegistry {
|
|
|
476
490
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
477
491
|
}
|
|
478
492
|
|
|
479
|
-
async run(
|
|
493
|
+
async run(invocation) {
|
|
494
|
+
const tool = this.get(invocation.name);
|
|
495
|
+
if (!tool?.execution?.deduplicateConcurrent) return this.runOnce(invocation);
|
|
496
|
+
const key = concurrentExecutionKey(invocation.name, invocation.chatId, invocation.request);
|
|
497
|
+
const active = this.concurrentExecutions.get(key);
|
|
498
|
+
if (active) {
|
|
499
|
+
this.logger?.log("tools", `joined concurrent duplicate ${invocation.name}`);
|
|
500
|
+
return active;
|
|
501
|
+
}
|
|
502
|
+
const execution = this.runOnce(invocation);
|
|
503
|
+
this.concurrentExecutions.set(key, execution);
|
|
504
|
+
try {
|
|
505
|
+
return await execution;
|
|
506
|
+
} finally {
|
|
507
|
+
if (this.concurrentExecutions.get(key) === execution) this.concurrentExecutions.delete(key);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async runOnce({ name, request, chatId = null, onEvent = null }) {
|
|
480
512
|
const tool = this.get(name);
|
|
481
513
|
if (!tool) throw new Error(`Tool not found: ${name}`);
|
|
482
514
|
const dependencyIssue = this.dependencyIssues(name)[0];
|
|
@@ -24,7 +24,11 @@ export function normalizeToolExecution(execution) {
|
|
|
24
24
|
if (!resourceClass) throw new Error("Tool execution resourceClass is required");
|
|
25
25
|
const weight = positiveInteger(execution.weight, 0);
|
|
26
26
|
if (!weight) throw new Error("Tool execution weight must be a positive integer");
|
|
27
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
resourceClass,
|
|
29
|
+
weight,
|
|
30
|
+
deduplicateConcurrent: execution.deduplicateConcurrent === true
|
|
31
|
+
};
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
export function normalizeToolExecutionPolicy(policy = {}) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"repository": "https://github.com/clasen/Arisa.git",
|
|
4
|
-
"commit": "
|
|
4
|
+
"commit": "c05810a8a719ddcd96b044632334df32554bf804",
|
|
5
5
|
"tools": {
|
|
6
6
|
"browser-session-bridge": {
|
|
7
7
|
"version": "0.1.0",
|
|
@@ -31,13 +31,9 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"campaign-draft-runner": {
|
|
34
|
-
"version": "0.5.
|
|
35
|
-
"toolDependencies": {
|
|
36
|
-
"pr-campaign": "^0.1.0",
|
|
37
|
-
"gmail-workspace": "^0.1.0"
|
|
38
|
-
},
|
|
34
|
+
"version": "0.5.2",
|
|
39
35
|
"files": {
|
|
40
|
-
"README.md": "
|
|
36
|
+
"README.md": "c5c91bad056e5ffc5a17624074d766d3489a502dd84e176286a515980f32fb7a",
|
|
41
37
|
"batch-skip.js": "ad428464b739e7355e00ac7cf399247ce611fcef0470d97a73893701e5b07627",
|
|
42
38
|
"config.js": "9b9c2d9686cebf17c3ba2630088c7028281883608f61054e733283aeb8d383cb",
|
|
43
39
|
"index.js": "669bf964a0e1e1a0bacc5997c6e49539022649f405543e5914ba01aa11a99330",
|
|
@@ -46,11 +42,11 @@
|
|
|
46
42
|
"product-facts.js": "69339e20e7b70a6b9833878d72b10b70ea4ede4aa448097975d0961b6aaced59",
|
|
47
43
|
"search-quality.js": "ab5412d45d5144e976cccef52992e634c11aca0e21b73fd53f9dc2d489c56dd7",
|
|
48
44
|
"source-exhaustion.js": "407f940f5205eb01715a797672d0dad8bd0b0b897024b5fcb87052a062ecd054",
|
|
49
|
-
"telemetry.js": "
|
|
45
|
+
"telemetry.js": "b3ededa1277d5028f7a8299926e16bb4dc06d70c470d3b97352c29080cfc0974",
|
|
50
46
|
"test/batch-skip.test.js": "638d185dc0b38900afe6ee9058e733ae36583d0762a0ff0347fdf245df064abc",
|
|
51
47
|
"test/selection-policy.test.js": "1283f18b861925aee810d156eb64c54b147304ff7352a8f83b9c2311e1f51de2",
|
|
52
|
-
"test/telemetry.test.js": "
|
|
53
|
-
"tool.manifest.json": "
|
|
48
|
+
"test/telemetry.test.js": "8e52a43fa9eb08fa0f3be124b72b7cd39258666cb88311b1df6978037372c257",
|
|
49
|
+
"tool.manifest.json": "cb5eff1b5fd85d97c704e0287f1527c9763b320cacac22093e3aceeba4f7f256"
|
|
54
50
|
}
|
|
55
51
|
},
|
|
56
52
|
"creator-scout": {
|
|
@@ -103,14 +99,14 @@
|
|
|
103
99
|
}
|
|
104
100
|
},
|
|
105
101
|
"master-slave": {
|
|
106
|
-
"version": "0.1.
|
|
102
|
+
"version": "0.1.7",
|
|
107
103
|
"files": {
|
|
108
104
|
"README.md": "9755dd59d01680236d12b57f87e4bc52568649a0e63b1a96065baf800d8dc1c8",
|
|
109
105
|
"batch-runner.js": "97756a03b36278cd0315cc16da0c34a7821c322149c51ba6666f1c5081dbecbc",
|
|
110
106
|
"chat-state-store.js": "50de39aa33432733bb688eb01968e314a32b169324a66d0f0089b2a19ed9c880",
|
|
111
|
-
"command-arguments.js": "
|
|
107
|
+
"command-arguments.js": "a67b81b95dadb06fd99a1692e2b52984a02cef5b4cfde59d7863e64c747f5d5e",
|
|
112
108
|
"config.js": "ff1c1cb6701ae1da7a6eb1519ba96a416e48daa73c2155a6f93cf5d64f65cef8",
|
|
113
|
-
"index.js": "
|
|
109
|
+
"index.js": "d00dfd7669cbb24d85bf32ee11ab21c805329182d0bba9068912c75bca58961a",
|
|
114
110
|
"lib/bootstrap-url.js": "f05925cc0f0dff0882f94f9908c6f2c80aa8632cd51f63389381dcbb0f73afb3",
|
|
115
111
|
"lib/encrypted-frames.js": "47e1b11fafdbbcdfd86a0993a47a774e4f37d6386edeaf8ec1c8511b109728cf",
|
|
116
112
|
"lib/handshake-crypto.js": "9728b117aad7de53f5f0e255c0fd865ee1a19633368730a7634334d374094578",
|
|
@@ -120,22 +116,22 @@
|
|
|
120
116
|
"lib/secure-store.js": "b8f365197f8e60d7f81fde6ed1cfc8851b603f6b0783e4cdc9036bb5c99726e1",
|
|
121
117
|
"master-domain.js": "10b7dfb43eb9939eb5baec54f742076cc73a2add6589a0ff57c51bc79c236faf",
|
|
122
118
|
"network-session.js": "db2d81a7ef47af29236d963b98e21414a11fef68e8b20895126eaad3f696ba3d",
|
|
123
|
-
"package.json": "
|
|
124
|
-
"remote-runtime.js": "
|
|
125
|
-
"slave-operations.js": "
|
|
119
|
+
"package.json": "1f14897232b405300927a7494bd18900bdd7b2eeddea0ceaeb88707c8cb26a01",
|
|
120
|
+
"remote-runtime.js": "87c53c012b1f9d032af4028b8f92d2c8a84b9730e39e32f3dfcbd60e8d021c20",
|
|
121
|
+
"slave-operations.js": "5a8967e2006f3f30ee221614d25fbf7863ffd9c717fd7eeeb0ba981c8c8c367a",
|
|
126
122
|
"state-store.js": "c56849a3f1b9c990128276e17963034a7836abacedc44f2e500d5732ebf9e451",
|
|
127
123
|
"test/batch-runner.test.js": "69f69150eadd18c1ad1bc54f429e6cb5bc0c4492e3c6a6e76c2ce6827780ae41",
|
|
128
124
|
"test/bootstrap-url.test.js": "62c112b1a38c59ea2c77f797e32873667b52db32386da661a6ae8410490126fe",
|
|
129
|
-
"test/command-arguments.test.js": "
|
|
125
|
+
"test/command-arguments.test.js": "bc50a1fe3d6474a5daeb7c76a96ba960f4f5e37d8742090b756c152490d8d01c",
|
|
130
126
|
"test/encrypted-frames.test.js": "cfb3195d3a58df7dc570bc7840a21c1841a53e0abc8b92840e0c24ed3435bcc1",
|
|
131
127
|
"test/handshake-crypto.test.js": "f11d6e95658f2055d8256bd7dfb66b39bedf95bf62a3cec5525b109bb6f9bc95",
|
|
132
128
|
"test/master-domain.test.js": "7298766db3e6b186cca0109f352766e1bf417a8af12a58dd1c2b78fd1a61ff24",
|
|
133
129
|
"test/network-session.test.js": "2e459346cc9364be705bc5f54691245044588b2cd03ae77e2e870dee6dfbcca6",
|
|
134
130
|
"test/pairing-secret-store.test.js": "c51257365fd0b641c24768d710cdff2d1c5f8816bf90f66c2c01a226904cb43f",
|
|
135
131
|
"test/profile-catalog.test.js": "d8e297bee413d53ec13f2e1779c13f2e019cb8304976d567eab0e9f1ca36637c",
|
|
136
|
-
"test/remote-runtime.test.js": "
|
|
137
|
-
"test/slave-operations.test.js": "
|
|
138
|
-
"tool.manifest.json": "
|
|
132
|
+
"test/remote-runtime.test.js": "887705a9c15d979353b3db922f471b67d9607c2c6e0e8c7cebd83618a83597df",
|
|
133
|
+
"test/slave-operations.test.js": "c826be39bee62ec1b85645324971347cbe6a71e72e383619bc2c7a74f7c0f55b",
|
|
134
|
+
"tool.manifest.json": "26ae9907720e1893454b3bd3a6dda44207d7cc180459665088037c5d605530aa"
|
|
139
135
|
}
|
|
140
136
|
},
|
|
141
137
|
"mcp-client": {
|
package/src/runtime/slave-cli.js
CHANGED
|
@@ -148,6 +148,7 @@ export function formatSlaveStatus({ systemd, diagnostic }) {
|
|
|
148
148
|
`Endpoint: ${diagnostic?.endpoint || "not configured"}`,
|
|
149
149
|
`Identity: ${diagnostic?.identityFingerprint || diagnostic?.identity || "not configured"}`,
|
|
150
150
|
`Paired: ${diagnostic?.paired === true ? "yes" : diagnostic?.paired === false ? "no" : "unknown"}`,
|
|
151
|
+
`Connected: ${diagnostic?.network?.connected === true ? "yes" : diagnostic?.network?.connected === false ? "no" : "unknown"}`,
|
|
151
152
|
`Tools: ${Number.isSafeInteger(diagnostic?.toolCount) ? diagnostic.toolCount : "unknown"}`,
|
|
152
153
|
`Jobs: active=${jobs.active ?? "unknown"}, queued=${jobs.queued ?? "unknown"}, failed=${jobs.failed ?? "unknown"}`,
|
|
153
154
|
`Pending secrets: ${Number.isSafeInteger(diagnostic?.pendingSecrets) ? diagnostic.pendingSecrets : "unknown"}`
|
|
@@ -278,6 +278,25 @@ test("describes an intentionally stopped on-demand daemon without treating it as
|
|
|
278
278
|
assert.equal(diagnostic.lastError, null);
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
test("keeps auto-start ingress daemons in bounded backoff after the burst retry limit", async () => {
|
|
282
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: true });
|
|
283
|
+
await writeDaemonStatus(runtime.paths, {
|
|
284
|
+
state: "starting",
|
|
285
|
+
pid: null,
|
|
286
|
+
message: "Network unavailable during boot",
|
|
287
|
+
restartAttempts: policy.restartLimit,
|
|
288
|
+
restartRequested: false,
|
|
289
|
+
lastError: { phase: "health", message: "connect ENETUNREACH" }
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
assert.equal(await superviseDaemon(runtime.registration, policy), "restart-scheduled");
|
|
293
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
294
|
+
assert.equal(status.state, "restarting");
|
|
295
|
+
assert.equal(status.restartAttempts, policy.restartLimit);
|
|
296
|
+
assert.equal(status.restartRequested, true);
|
|
297
|
+
assert.ok(Date.parse(status.nextRestartAt) > Date.now());
|
|
298
|
+
});
|
|
299
|
+
|
|
281
300
|
test("keeps a terminal daemon failure stable until it receives explicit attention", async () => {
|
|
282
301
|
const runtime = runtimeFor({ type: "global" }, { autoStart: true });
|
|
283
302
|
await writeDaemonStatus(runtime.paths, {
|
|
@@ -286,6 +305,7 @@ test("keeps a terminal daemon failure stable until it receives explicit attentio
|
|
|
286
305
|
message: "Daemon restart limit reached: synthetic crash",
|
|
287
306
|
restartAttempts: policy.restartLimit + 1,
|
|
288
307
|
restartRequested: false,
|
|
308
|
+
nextRestartAt: null,
|
|
289
309
|
lastError: { phase: "restart", message: "synthetic crash token=private-value", code: "SYNTHETIC" }
|
|
290
310
|
});
|
|
291
311
|
|
|
@@ -302,7 +302,7 @@ 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, {});
|
|
305
|
+
assert.deepEqual(config.toolExecution.capacities, { orchestrator: 1 });
|
|
306
306
|
assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
|
|
307
307
|
assert.equal(config.pi.speed, piConfigDefaults.speed);
|
|
308
308
|
});
|
package/test/slave-cli.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import test from "node:test";
|
|
|
6
6
|
import { createHeadlessApp } from "../src/runtime/create-headless-app.js";
|
|
7
7
|
import { parseSlaveBootstrapUrl } from "../src/runtime/slave-bootstrap-url.js";
|
|
8
8
|
import { withSecureRequestFile } from "../src/runtime/secure-request-file.js";
|
|
9
|
-
import { ensureMasterSlaveTool, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
|
|
9
|
+
import { ensureMasterSlaveTool, formatSlaveStatus, runSlaveBootstrap, runSlaveCli } from "../src/runtime/slave-cli.js";
|
|
10
10
|
import {
|
|
11
11
|
buildSlaveSystemdUnit,
|
|
12
12
|
getSlavePaths,
|
|
@@ -104,6 +104,25 @@ test("escapes systemd WorkingDirectory paths without quoting the entire value",
|
|
|
104
104
|
assert.match(unit, /^StandardOutput=append:\/srv\/arisa\\x20slave\/state\/arisa-slave\.log$/m);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
test("reports Master connectivity separately from pairing and daemon readiness", () => {
|
|
108
|
+
const text = formatSlaveStatus({
|
|
109
|
+
systemd: { running: true, status: "active" },
|
|
110
|
+
diagnostic: {
|
|
111
|
+
daemon: { state: "ready" },
|
|
112
|
+
role: "slave",
|
|
113
|
+
endpoint: "tcp://198.51.100.12:4719",
|
|
114
|
+
paired: true,
|
|
115
|
+
network: { connected: false },
|
|
116
|
+
toolCount: 1,
|
|
117
|
+
jobs: { active: 0, queued: 0, failed: 0 },
|
|
118
|
+
pendingSecrets: 0
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
assert.match(text, /Daemon: ready/);
|
|
122
|
+
assert.match(text, /Paired: yes/);
|
|
123
|
+
assert.match(text, /Connected: no/);
|
|
124
|
+
});
|
|
125
|
+
|
|
107
126
|
test("refuses to replace the PID of an active Slave host", async (t) => {
|
|
108
127
|
const home = await mkdtemp(path.join(os.tmpdir(), "arisa-slave-pid-"));
|
|
109
128
|
t.after(() => rm(home, { recursive: true, force: true }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -171,7 +171,8 @@ test("loads weighted execution metadata from the tool manifest", async () => {
|
|
|
171
171
|
|
|
172
172
|
assert.deepEqual(registry.get("heavy-tool").execution, {
|
|
173
173
|
resourceClass: "browser",
|
|
174
|
-
weight: 2
|
|
174
|
+
weight: 2,
|
|
175
|
+
deduplicateConcurrent: false
|
|
175
176
|
});
|
|
176
177
|
});
|
|
177
178
|
|
|
@@ -229,11 +230,49 @@ test("wraps declared tool runs in the shared execution governor", async () => {
|
|
|
229
230
|
|
|
230
231
|
assert.equal(result.ok, true);
|
|
231
232
|
assert.deepEqual(calls, [
|
|
232
|
-
{
|
|
233
|
+
{
|
|
234
|
+
type: "acquire",
|
|
235
|
+
execution: { resourceClass: "browser", weight: 1, deduplicateConcurrent: false },
|
|
236
|
+
label: "heavy-tool"
|
|
237
|
+
},
|
|
233
238
|
{ type: "release", label: "heavy-tool" }
|
|
234
239
|
]);
|
|
235
240
|
});
|
|
236
241
|
|
|
242
|
+
test("joins exact concurrent duplicates only for tools that opt in", async () => {
|
|
243
|
+
await resetHome();
|
|
244
|
+
const dir = await createFakeTool("single-flight-tool", {
|
|
245
|
+
execution: { resourceClass: "orchestrator", weight: 1, deduplicateConcurrent: true }
|
|
246
|
+
});
|
|
247
|
+
await writeFile(path.join(dir, "index.js"), `import { appendFile, readFile } from "node:fs/promises";
|
|
248
|
+
const requestFile = process.argv[process.argv.indexOf("--request-file") + 1];
|
|
249
|
+
const request = JSON.parse(await readFile(requestFile, "utf8"));
|
|
250
|
+
await appendFile(request.args.counterFile, "x");
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
252
|
+
process.stdout.write(JSON.stringify({ ok: true, output: { text: request.args.value } }));
|
|
253
|
+
`, "utf8");
|
|
254
|
+
const counterFile = path.join(homeDir, "single-flight-count.txt");
|
|
255
|
+
const registry = new ToolRegistry({ executionPolicy: { capacities: { orchestrator: 1 } } });
|
|
256
|
+
await registry.load();
|
|
257
|
+
const invocation = {
|
|
258
|
+
name: "single-flight-tool",
|
|
259
|
+
chatId: "123",
|
|
260
|
+
request: { args: { counterFile, value: "same" } }
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const [first, second] = await Promise.all([registry.run(invocation), registry.run(invocation)]);
|
|
264
|
+
|
|
265
|
+
assert.deepEqual(second, first);
|
|
266
|
+
assert.equal(await readFile(counterFile, "utf8"), "x");
|
|
267
|
+
await Promise.all([
|
|
268
|
+
registry.run({ ...invocation, request: { args: { counterFile, value: "cross-chat" } } }),
|
|
269
|
+
registry.run({ ...invocation, chatId: "456", request: { args: { counterFile, value: "cross-chat" } } })
|
|
270
|
+
]);
|
|
271
|
+
assert.equal(await readFile(counterFile, "utf8"), "xxx");
|
|
272
|
+
await registry.run({ ...invocation, request: { args: { counterFile, value: "different" } } });
|
|
273
|
+
assert.equal(await readFile(counterFile, "utf8"), "xxxx");
|
|
274
|
+
});
|
|
275
|
+
|
|
237
276
|
test("runs a registered tool process with an enriched request and cleans up request files", async () => {
|
|
238
277
|
await resetHome();
|
|
239
278
|
await createFakeTool("fake-tool");
|
|
@@ -15,8 +15,14 @@ function deferred() {
|
|
|
15
15
|
test("normalizes manifest weights and configurable class capacities", () => {
|
|
16
16
|
assert.deepEqual(normalizeToolExecution({ resourceClass: "browser", weight: 2 }), {
|
|
17
17
|
resourceClass: "browser",
|
|
18
|
-
weight: 2
|
|
18
|
+
weight: 2,
|
|
19
|
+
deduplicateConcurrent: false
|
|
19
20
|
});
|
|
21
|
+
assert.equal(normalizeToolExecution({
|
|
22
|
+
resourceClass: "orchestrator",
|
|
23
|
+
weight: 1,
|
|
24
|
+
deduplicateConcurrent: true
|
|
25
|
+
}).deduplicateConcurrent, true);
|
|
20
26
|
assert.equal(normalizeToolExecution(undefined), null);
|
|
21
27
|
assert.throws(() => normalizeToolExecution({ resourceClass: "Browser!", weight: 1 }), /Invalid tool execution resource class/);
|
|
22
28
|
assert.throws(() => normalizeToolExecution({ resourceClass: "browser", weight: 0 }), /positive integer/);
|