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.
- package/README.md +28 -6
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +123 -8
- package/src/core/agent/agent-session-lifecycle.js +108 -8
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-capability-tools.js +7 -4
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- 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-heap-circuit-breaker.js +122 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +4 -3
- package/src/core/config/config-defaults.js +59 -1
- package/src/core/config/config-store.js +1 -1
- package/src/core/conversation/session-seed-store.js +1 -1
- package/src/core/tasks/task-runner.js +8 -4
- package/src/core/tasks/task-store.js +48 -5
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +26 -3
- package/src/core/tools/daemon-protocol.js +72 -0
- package/src/core/tools/daemon-runtime.js +13 -490
- package/src/core/tools/daemon-worker.js +310 -0
- package/src/core/tools/ipc-client.js +2 -2
- package/src/core/tools/memory-pressure.js +56 -0
- package/src/core/tools/official-tool-installer.js +1 -1
- package/src/core/tools/tool-config.js +1 -1
- package/src/core/tools/tool-process-output.js +100 -0
- package/src/core/tools/tool-process-runner.js +175 -0
- package/src/core/tools/tool-registry.js +131 -191
- package/src/core/tools/tool-resource-note-store.js +1 -1
- package/src/core/tools/tool-usage-store.js +1 -1
- package/src/core/tools/weighted-resource-governor.js +189 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +526 -78
- package/src/platform/paths.js +152 -0
- package/src/runtime/bootstrap-cli.js +121 -0
- package/src/runtime/bootstrap-config.js +97 -0
- package/src/runtime/bootstrap-telegram.js +325 -0
- package/src/runtime/bootstrap.js +6 -543
- package/src/runtime/doctor.js +26 -76
- package/src/runtime/flush.js +1 -1
- package/src/runtime/ipc/ipc-server.js +1 -1
- package/src/runtime/log-viewer.js +1 -1
- package/src/runtime/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/process-inspection.js +78 -0
- package/src/runtime/restart-receipt.js +1 -1
- package/src/runtime/service-manager.js +1 -1
- package/src/runtime/service-supervisor.js +14 -0
- package/src/runtime/slave-cli.js +1 -1
- package/src/runtime/tool-process-supervisor.js +36 -9
- package/src/runtime/tui.js +200 -0
- package/src/runtime/update-manager.js +1 -1
- package/src/runtime/worker-recovery-report.js +142 -0
- package/src/transport/telegram/bot.js +45 -321
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/task-dispatcher.js +36 -11
- package/src/transport/telegram/telegram-prompt-controller.js +359 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/agent-turn-coordinator.test.js +45 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +38 -5
- package/test/doctor.test.js +41 -0
- package/test/memory-pressure.test.js +41 -0
- package/test/model-selection.test.js +11 -1
- 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 +18 -1
- package/test/oom-protection.test.js +32 -0
- package/test/paths.test.js +7 -0
- package/test/pi-compaction.test.js +30 -0
- package/test/service-manager.test.js +6 -1
- 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/task-store.test.js +32 -0
- package/test/telegram-prompt-controller.test.js +82 -0
- package/test/telegram-task-dispatcher.test.js +66 -5
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +117 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +125 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test/worker-tool-fanout.test.js +79 -0
- 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
|
|
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" });
|