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 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 it with `toolExecution.defaultCapacity`, `toolExecution.capacities`, and `toolExecution.maxQueuedPerClass`. Arisa logs queue waits and new worker RSS peaks for operational measurement.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.1.66",
3
+ "version": "5.1.68",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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({ name, request, chatId = null, onEvent = null }) {
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 { resourceClass, weight };
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": "f5702971f09b70c28b239d8c32fa30cb069c0e38",
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.0",
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": "2c977357f774c6bc3369460ba9cd784c9b75b5f12b338e089e634e09f2a8cbcd",
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": "984772e73d1cbb761771d6f62954a604333a301182ee74368a41ffab762da367",
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": "daa84b83658226ecd979f15d4e12d94740ffe5280d467ad29677fc1d77734a5b",
53
- "tool.manifest.json": "71520f3be858b9c2193223e370c10d938484a2af9281087e6dc14410b45cb2ae"
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.2",
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": "ef00f814dee6105e1cd3b8929e974bc071117f162b17e3dcc3dc0ea68e8b1e92",
107
+ "command-arguments.js": "a67b81b95dadb06fd99a1692e2b52984a02cef5b4cfde59d7863e64c747f5d5e",
112
108
  "config.js": "ff1c1cb6701ae1da7a6eb1519ba96a416e48daa73c2155a6f93cf5d64f65cef8",
113
- "index.js": "404db150dee0ecf79cd64bca245c618fa1e2bb324d6bc15f5ac0022b72497c63",
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": "1f37665b860b7f2f5b37cc931dcc9c4ac4fb82d1260fc14c0646fa4d92ade0b3",
124
- "remote-runtime.js": "55dd7e577a822be17c2f337798a2d7f44ad0c5b6d68c46490e058282135c4658",
125
- "slave-operations.js": "fec2e8addfff98f080284f24f722c1516124e5f6d12d8c03bb3c9cc0c475d1d6",
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": "360c087edd0a589b28111bb82d06e3e73e93a6b22da0a57cb96f0a7e92a9bd5f",
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": "1161bd653dc47b9b8219bf848d0d395ce390f2bb7b58ea41e3e4270b1d504798",
137
- "test/slave-operations.test.js": "a84526ad7554c7d03192a68655b4637bec18750559a5c3436f7471e912ca74cc",
138
- "tool.manifest.json": "b0b4caee5e818cebc537eadaed852a511f4f65c562b5cd5709cc77edbe33ee15"
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": {
@@ -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
  });
@@ -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
- { type: "acquire", execution: { resourceClass: "browser", weight: 1 }, label: "heavy-tool" },
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/);