arisa 5.1.68 → 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 (97) hide show
  1. package/README.md +28 -6
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +123 -8
  4. package/src/core/agent/agent-session-lifecycle.js +108 -8
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/core-tools.js +1 -1
  7. package/src/core/agent/pi-auth-login.js +1 -1
  8. package/src/core/agent/pi-capability-tools.js +7 -4
  9. package/src/core/agent/pi-runtime.js +1 -1
  10. package/src/core/agent/runtime-context.js +1 -1
  11. package/src/core/agent/session-history-reader.js +168 -0
  12. package/src/core/agent/session-preload-migration.js +162 -0
  13. package/src/core/agent/session-rotation.js +29 -0
  14. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/artifacts/artifact-store.js +1 -1
  17. package/src/core/capabilities/capability-service.js +4 -3
  18. package/src/core/config/config-defaults.js +59 -1
  19. package/src/core/config/config-store.js +1 -1
  20. package/src/core/conversation/session-seed-store.js +1 -1
  21. package/src/core/tasks/task-runner.js +8 -4
  22. package/src/core/tasks/task-store.js +48 -5
  23. package/src/core/tools/daemon-client.js +180 -0
  24. package/src/core/tools/daemon-processes.js +26 -3
  25. package/src/core/tools/daemon-protocol.js +72 -0
  26. package/src/core/tools/daemon-runtime.js +13 -490
  27. package/src/core/tools/daemon-worker.js +310 -0
  28. package/src/core/tools/ipc-client.js +2 -2
  29. package/src/core/tools/memory-pressure.js +56 -0
  30. package/src/core/tools/official-tool-installer.js +1 -1
  31. package/src/core/tools/tool-config.js +1 -1
  32. package/src/core/tools/tool-process-output.js +100 -0
  33. package/src/core/tools/tool-process-runner.js +175 -0
  34. package/src/core/tools/tool-registry.js +131 -191
  35. package/src/core/tools/tool-resource-note-store.js +1 -1
  36. package/src/core/tools/tool-usage-store.js +1 -1
  37. package/src/core/tools/weighted-resource-governor.js +189 -38
  38. package/src/index.js +14 -2
  39. package/src/official-tools.lock.json +526 -78
  40. package/src/platform/paths.js +152 -0
  41. package/src/runtime/bootstrap-cli.js +121 -0
  42. package/src/runtime/bootstrap-config.js +97 -0
  43. package/src/runtime/bootstrap-telegram.js +325 -0
  44. package/src/runtime/bootstrap.js +6 -543
  45. package/src/runtime/doctor.js +26 -76
  46. package/src/runtime/flush.js +1 -1
  47. package/src/runtime/ipc/ipc-server.js +1 -1
  48. package/src/runtime/log-viewer.js +1 -1
  49. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  50. package/src/runtime/oom-protection.js +20 -0
  51. package/src/runtime/paths.js +3 -151
  52. package/src/runtime/process-inspection.js +78 -0
  53. package/src/runtime/restart-receipt.js +1 -1
  54. package/src/runtime/service-manager.js +1 -1
  55. package/src/runtime/service-supervisor.js +14 -0
  56. package/src/runtime/slave-cli.js +1 -1
  57. package/src/runtime/tool-process-supervisor.js +36 -9
  58. package/src/runtime/tui.js +200 -0
  59. package/src/runtime/update-manager.js +1 -1
  60. package/src/runtime/worker-recovery-report.js +142 -0
  61. package/src/transport/telegram/bot.js +45 -321
  62. package/src/transport/telegram/chat-queue.js +6 -2
  63. package/src/transport/telegram/prompt-builders.js +8 -3
  64. package/src/transport/telegram/task-dispatcher.js +36 -11
  65. package/src/transport/telegram/telegram-prompt-controller.js +359 -0
  66. package/src/transport/telegram/workspace-topic-store.js +1 -1
  67. package/test/agent-session-lifecycle.test.js +92 -0
  68. package/test/agent-turn-coordinator.test.js +45 -0
  69. package/test/architecture-boundaries.test.js +29 -0
  70. package/test/bootstrap.test.js +65 -0
  71. package/test/context-and-task-bounds.test.js +2 -1
  72. package/test/daemon-process-invocation.test.js +27 -0
  73. package/test/daemon-runtime.test.js +38 -5
  74. package/test/doctor.test.js +41 -0
  75. package/test/memory-pressure.test.js +41 -0
  76. package/test/model-selection.test.js +11 -1
  77. package/test/obsolete-daemon-reaper.test.js +61 -0
  78. package/test/official-tool-dependencies.test.js +7 -2
  79. package/test/official-tool-installer.test.js +18 -1
  80. package/test/oom-protection.test.js +32 -0
  81. package/test/paths.test.js +7 -0
  82. package/test/pi-compaction.test.js +30 -0
  83. package/test/service-manager.test.js +6 -1
  84. package/test/session-history-reader.test.js +84 -0
  85. package/test/session-preload-migration.test.js +120 -0
  86. package/test/session-rotation.test.js +110 -0
  87. package/test/task-store.test.js +32 -0
  88. package/test/telegram-prompt-controller.test.js +82 -0
  89. package/test/telegram-task-dispatcher.test.js +66 -5
  90. package/test/telegram-text-artifact.test.js +30 -0
  91. package/test/tool-registry-run.test.js +117 -4
  92. package/test/tui.test.js +41 -0
  93. package/test/weighted-resource-governor.test.js +125 -5
  94. package/test/worker-heap-circuit-breaker.test.js +79 -0
  95. package/test/worker-recovery-report.test.js +69 -0
  96. package/test/worker-tool-fanout.test.js +79 -0
  97. package/test-fixtures/fake-daemon.js +5 -0
@@ -12,11 +12,24 @@ function deferred() {
12
12
  return { promise, resolve };
13
13
  }
14
14
 
15
+ const safeMemoryPressure = async () => ({
16
+ availableBytes: 512 * 1024 * 1024,
17
+ totalBytes: 4 * 1024 * 1024 * 1024,
18
+ workerRssBytes: 100 * 1024 * 1024,
19
+ swapTotalBytes: 1024,
20
+ swapUsedPercent: 50
21
+ });
22
+
23
+ const settle = () => new Promise((resolve) => setImmediate(resolve));
24
+
15
25
  test("normalizes manifest weights and configurable class capacities", () => {
16
26
  assert.deepEqual(normalizeToolExecution({ resourceClass: "browser", weight: 2 }), {
17
27
  resourceClass: "browser",
18
28
  weight: 2,
19
- deduplicateConcurrent: false
29
+ deduplicateConcurrent: false,
30
+ maxHeapMb: 4096,
31
+ maxMemoryMb: 16_384,
32
+ maxOutputBytes: 1_048_576
20
33
  });
21
34
  assert.equal(normalizeToolExecution({
22
35
  resourceClass: "orchestrator",
@@ -33,6 +46,16 @@ test("normalizes manifest weights and configurable class capacities", () => {
33
46
  }), {
34
47
  defaultCapacity: 3,
35
48
  maxQueuedPerClass: 12,
49
+ maxWorkerRssMb: 384,
50
+ maxSwapUsedPercent: 95,
51
+ initialToolMemoryMb: 384,
52
+ minimumToolMemoryMb: 128,
53
+ maximumToolMemoryMb: 4096,
54
+ systemReserveMb: 128,
55
+ coreReserveMb: 384,
56
+ toolHeapPercent: 65,
57
+ toolMemoryHighPercent: 85,
58
+ toolSwapMaxMb: 128,
36
59
  capacities: { browser: 4 }
37
60
  });
38
61
  });
@@ -42,13 +65,15 @@ test("queues weighted work fairly within one resource class", async () => {
42
65
  const governor = new WeightedResourceGovernor({
43
66
  policy: { defaultCapacity: 2 },
44
67
  now: () => time,
45
- memoryUsage: () => ({ rss: 100 * 1024 * 1024 })
68
+ memoryUsage: () => ({ rss: 100 * 1024 * 1024 }),
69
+ memoryPressure: safeMemoryPressure
46
70
  });
47
71
  const execution = { resourceClass: "browser", weight: 1 };
48
72
  const first = await governor.acquire(execution, "first");
49
73
  const second = await governor.acquire(execution, "second");
50
74
  const thirdLease = governor.acquire(execution, "third");
51
75
  const fourthLease = governor.acquire(execution, "fourth");
76
+ await settle();
52
77
 
53
78
  assert.deepEqual(governor.snapshot().resources.browser, {
54
79
  capacity: 2,
@@ -72,26 +97,80 @@ test("queues weighted work fairly within one resource class", async () => {
72
97
  assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
73
98
  });
74
99
 
75
- test("undeclared lightweight work bypasses constrained resource queues", async () => {
76
- const governor = new WeightedResourceGovernor({ policy: { defaultCapacity: 1 } });
100
+ test("undeclared work bypasses the optional resource governor", async () => {
101
+ const governor = new WeightedResourceGovernor({ policy: { defaultCapacity: 1 }, memoryPressure: safeMemoryPressure });
77
102
  const heavy = await governor.acquire({ resourceClass: "browser", weight: 1 }, "heavy");
78
103
  const queued = governor.acquire({ resourceClass: "browser", weight: 1 }, "queued");
104
+ await settle();
79
105
  const light = await governor.acquire(null, "light");
80
106
  assert.equal(light.waitedMs, 0);
107
+ assert.equal(light.memoryLimitMb, undefined);
108
+ assert.equal(light.heapLimitMb, undefined);
81
109
  assert.equal(governor.snapshot().resources.browser.queued, 1);
82
110
  light.release();
83
111
  heavy.release();
84
112
  (await queued).release();
85
113
  });
86
114
 
115
+ test("rejects declared heavy tools before spawn when memory pressure is unsafe", async () => {
116
+ const logs = [];
117
+ const governor = new WeightedResourceGovernor({
118
+ policy: { maxWorkerRssMb: 384, maxSwapUsedPercent: 95 },
119
+ memoryPressure: async () => ({
120
+ availableBytes: 80 * 1024 * 1024,
121
+ workerRssBytes: 400 * 1024 * 1024,
122
+ swapTotalBytes: 1024,
123
+ swapUsedPercent: 50
124
+ }),
125
+ logger: { log: (...parts) => logs.push(parts.join(" ")) }
126
+ });
127
+
128
+ await assert.rejects(
129
+ () => governor.acquire({ resourceClass: "browser", weight: 1 }, "web-browser"),
130
+ (error) => error.code === "TOOL_RESOURCE_PRESSURE" && /was not started/.test(error.message)
131
+ );
132
+ assert.match(logs.join("\n"), /web-browser rejected for browser/);
133
+ assert.equal(governor.snapshot().resources.browser.activeWeight, 0);
134
+ });
135
+
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
+
87
164
  test("larger weights consume shared capacity and worker RSS peaks are retained", async () => {
88
165
  let rss = 120 * 1024 * 1024;
89
166
  const governor = new WeightedResourceGovernor({
90
167
  policy: { defaultCapacity: 3 },
91
- memoryUsage: () => ({ rss })
168
+ memoryUsage: () => ({ rss }),
169
+ memoryPressure: safeMemoryPressure
92
170
  });
93
171
  const large = await governor.acquire({ resourceClass: "browser", weight: 2 }, "large");
94
172
  const waiting = governor.acquire({ resourceClass: "browser", weight: 2 }, "waiting");
173
+ await settle();
95
174
  assert.equal(governor.snapshot().resources.browser.queued, 1);
96
175
  rss = 180 * 1024 * 1024;
97
176
  large.release();
@@ -99,3 +178,44 @@ test("larger weights consume shared capacity and worker RSS peaks are retained",
99
178
  assert.equal(governor.snapshot().peakRssBytes, rss);
100
179
  next.release();
101
180
  });
181
+
182
+ test("shares one host memory budget across independent resource classes", async () => {
183
+ const pressure = async () => ({
184
+ availableBytes: 512 * 1024 * 1024,
185
+ totalBytes: 1024 * 1024 * 1024,
186
+ workerRssBytes: 100 * 1024 * 1024,
187
+ swapTotalBytes: 0,
188
+ swapUsedPercent: 0
189
+ });
190
+ const governor = new WeightedResourceGovernor({
191
+ policy: { systemReserveMb: 128, coreReserveMb: 384, initialToolMemoryMb: 384 },
192
+ memoryPressure: pressure
193
+ });
194
+ const browser = await governor.acquire({ resourceClass: "browser" }, "browser");
195
+ const queued = governor.acquire({ resourceClass: "orchestrator" }, "orchestrator");
196
+ await settle();
197
+
198
+ assert.equal(governor.snapshot().memory.budgetMb, 512);
199
+ assert.equal(governor.snapshot().memory.activeMb, 384);
200
+ assert.equal(governor.snapshot().resources.orchestrator.queued, 1);
201
+
202
+ browser.release({ success: true });
203
+ const orchestrator = await queued;
204
+ assert.equal(orchestrator.memoryLimitMb, 384);
205
+ orchestrator.release({ success: true });
206
+ });
207
+
208
+ test("raises a tool memory recommendation after an isolated limit failure", async () => {
209
+ const governor = new WeightedResourceGovernor({
210
+ policy: { initialToolMemoryMb: 256 },
211
+ memoryPressure: safeMemoryPressure
212
+ });
213
+ const first = await governor.acquire({}, "growing-tool");
214
+ assert.equal(first.memoryLimitMb, 256);
215
+ first.release({ memoryLimited: true });
216
+
217
+ const second = await governor.acquire({}, "growing-tool");
218
+ assert.equal(second.memoryLimitMb, 384);
219
+ assert.equal(governor.snapshot().memory.profiles["growing-tool"].memoryLimitFailures, 1);
220
+ second.release({ success: true });
221
+ });
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ readWorkerHeapPressure,
5
+ WorkerHeapCircuitBreaker,
6
+ WorkerHeapPressureError
7
+ } from "../src/core/agent/worker-heap-circuit-breaker.js";
8
+
9
+ function pressure(percent) {
10
+ return { heapUsed: percent, heapLimit: 100, percent };
11
+ }
12
+
13
+ test("reports bounded worker heap pressure", () => {
14
+ assert.deepEqual(readWorkerHeapPressure({
15
+ memoryUsage: () => ({ heapUsed: 40 }),
16
+ heapStatistics: () => ({ heap_size_limit: 100 })
17
+ }), pressure(40));
18
+ });
19
+
20
+ test("evicts inactive sessions under soft pressure and admits recovered work", async () => {
21
+ const samples = [pressure(75), pressure(60)];
22
+ let evictions = 0;
23
+ const breaker = new WorkerHeapCircuitBreaker({
24
+ lifecycle: { async evictInactive() { evictions += 1; return [{ sessionKey: "idle" }]; } },
25
+ measure: () => samples.shift() || pressure(60)
26
+ });
27
+
28
+ assert.deepEqual(await breaker.admit(), pressure(60));
29
+ assert.equal(evictions, 1);
30
+ assert.deepEqual(breaker.getDiagnostic(), {
31
+ enabled: true,
32
+ softPercent: 70,
33
+ criticalPercent: 82,
34
+ waitMs: 15_000,
35
+ pollMs: 500,
36
+ heapUsed: 60,
37
+ heapLimit: 100,
38
+ currentHeapPercent: 60,
39
+ pressureEvents: 1,
40
+ evictedSessions: 1,
41
+ delayedAdmissions: 0,
42
+ rejectedAdmissions: 0,
43
+ peakHeapPercent: 75
44
+ });
45
+ });
46
+
47
+ test("delays critical admissions until active work releases memory", async () => {
48
+ const samples = [pressure(90), pressure(89), pressure(78)];
49
+ let now = 0;
50
+ const breaker = new WorkerHeapCircuitBreaker({
51
+ lifecycle: { async evictInactive() { return []; } },
52
+ config: { waitMs: 1_000, pollMs: 100 },
53
+ measure: () => samples.shift() || pressure(78),
54
+ now: () => now,
55
+ sleep: async (ms) => { now += ms; }
56
+ });
57
+
58
+ assert.deepEqual(await breaker.admit(), pressure(78));
59
+ assert.equal(breaker.getDiagnostic().delayedAdmissions, 1);
60
+ });
61
+
62
+ test("rejects persistent critical pressure with a retryable error", async () => {
63
+ let now = 0;
64
+ const breaker = new WorkerHeapCircuitBreaker({
65
+ lifecycle: { async evictInactive() { return []; } },
66
+ config: { waitMs: 200, pollMs: 100 },
67
+ measure: () => pressure(90),
68
+ now: () => now,
69
+ sleep: async (ms) => { now += ms; }
70
+ });
71
+
72
+ await assert.rejects(
73
+ breaker.admit(),
74
+ (error) => error instanceof WorkerHeapPressureError
75
+ && error.code === "WORKER_HEAP_PRESSURE"
76
+ && error.retryable === true
77
+ );
78
+ assert.equal(breaker.getDiagnostic().rejectedAdmissions, 1);
79
+ });
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ consumeWorkerRecoveryReport,
8
+ loadWorkerRecoveryReport,
9
+ recordUnexpectedWorkerExit,
10
+ summarizeRecoveryEvidence
11
+ } from "../src/runtime/worker-recovery-report.js";
12
+
13
+ function localLogTimestamp(date) {
14
+ const two = (value) => String(value).padStart(2, "0");
15
+ return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
16
+ }
17
+
18
+ test("summarizes only bounded crash evidence", () => {
19
+ const evidence = summarizeRecoveryEvidence([
20
+ "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory",
21
+ "[agent] run_tool web-browser",
22
+ "[agent] run_tool web-browser",
23
+ "[agent] run_tool campaign-draft-runner"
24
+ ], { occurredAt: "invalid", signal: "SIGABRT" });
25
+
26
+ assert.equal(evidence.cause, "JavaScript heap out of memory");
27
+ assert.deepEqual(evidence.tools, [["web-browser", 2], ["campaign-draft-runner", 1]]);
28
+ });
29
+
30
+ test("persists, formats, and consumes one automatic recovery report", async () => {
31
+ const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-worker-recovery-"));
32
+ const reportFile = path.join(directory, "report.json");
33
+ const taskFile = path.join(directory, "tasks.json");
34
+ const occurredAt = new Date();
35
+ const report = await recordUnexpectedWorkerExit({
36
+ occurredAt: occurredAt.toISOString(),
37
+ runtimeMs: 10_000,
38
+ restartDelayMs: 2_000,
39
+ consecutiveFailures: 1,
40
+ code: null,
41
+ signal: "SIGABRT",
42
+ detail: "signal=SIGABRT"
43
+ }, { reportFile });
44
+ await writeFile(taskFile, `${JSON.stringify({ tasks: [{
45
+ updatedAt: occurredAt.toISOString(),
46
+ lastOutcome: "outcome_uncertain",
47
+ lastError: "execution interrupted before confirmation"
48
+ }] })}\n`);
49
+ const timestamp = localLogTimestamp(occurredAt);
50
+ const recovery = await loadWorkerRecoveryReport({
51
+ reportFile,
52
+ taskFile,
53
+ readLines: async () => ({ text: [
54
+ `[${timestamp}] [agent] run_tool web-browser`,
55
+ `[${timestamp}] [agent] run_tool web-browser`,
56
+ "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory",
57
+ `[${timestamp}] [service] worker exited unexpectedly (signal=SIGABRT)`
58
+ ].join("\n") }),
59
+ getVersion: async () => "5.1.70"
60
+ });
61
+
62
+ assert.match(recovery.text, /Cause: JavaScript heap out of memory/);
63
+ assert.match(recovery.text, /web-browser ×2/);
64
+ assert.match(recovery.text, /1 scheduled execution was marked outcome-uncertain and not replayed/);
65
+ assert.match(recovery.text, /restarted after 2s; Arisa 5\.1\.70 is running/);
66
+ assert.equal(await consumeWorkerRecoveryReport(report.id, { reportFile }), true);
67
+ await assert.rejects(() => readFile(reportFile), /ENOENT/);
68
+ await rm(directory, { recursive: true, force: true });
69
+ });
@@ -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
+ });
@@ -43,6 +43,11 @@ await runtime.workLoop({
43
43
  recover,
44
44
  processJob: async (payload, execution) => {
45
45
  if (payload.action === "fail") throw new Error("synthetic job failure");
46
+ if (payload.action === "hang-until-cancelled") {
47
+ await new Promise((_, reject) => execution.signal.addEventListener("abort", () => {
48
+ reject(Object.assign(new Error("synthetic job cancelled"), { code: "DAEMON_JOB_CANCELLED" }));
49
+ }, { once: true }));
50
+ }
46
51
  if (payload.action === "stream") {
47
52
  await execution.emit("progress", { percent: 50 });
48
53
  await execution.emit("chunk", { text: "partial" });