arisa 5.1.68 → 5.2.7
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 +7 -4
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +46 -6
- package/src/core/agent/agent-session-lifecycle.js +80 -3
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +1 -1
- package/src/core/config/config-defaults.js +30 -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-store.js +1 -1
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +19 -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 +99 -187
- 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 +188 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +424 -50
- 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 +6 -3
- 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/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- 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 +1 -1
- 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 +42 -320
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/telegram-prompt-controller.js +346 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +36 -1
- package/test/doctor.test.js +22 -0
- package/test/memory-pressure.test.js +36 -0
- package/test/model-selection.test.js +11 -1
- package/test/official-tool-dependencies.test.js +1 -1
- 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 +9 -0
- package/test/service-manager.test.js +6 -1
- package/test/telegram-prompt-controller.test.js +81 -0
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +108 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +97 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test-fixtures/fake-daemon.js +5 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { chmod, mkdir, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
ensureDaemonCapability,
|
|
5
|
+
readJson,
|
|
6
|
+
writeDaemonStatus,
|
|
7
|
+
writeJson
|
|
8
|
+
} from "./daemon-processes.js";
|
|
9
|
+
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
10
|
+
import {
|
|
11
|
+
DAEMON_CONTROL_FIELD,
|
|
12
|
+
DAEMON_PROTOCOL_VERSION,
|
|
13
|
+
daemonFrame,
|
|
14
|
+
daemonJobPaths,
|
|
15
|
+
writeDaemonSocketFrame
|
|
16
|
+
} from "./daemon-protocol.js";
|
|
17
|
+
|
|
18
|
+
async function withTimeout(work, timeoutMs, message) {
|
|
19
|
+
let timer;
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([
|
|
22
|
+
Promise.resolve().then(work),
|
|
23
|
+
new Promise((_, reject) => {
|
|
24
|
+
timer = setTimeout(() => {
|
|
25
|
+
const error = new Error(message);
|
|
26
|
+
error.code = "DAEMON_OPERATION_TIMEOUT";
|
|
27
|
+
reject(error);
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
})
|
|
30
|
+
]);
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDaemonWorker({ toolName, paths }) {
|
|
37
|
+
let statusWrite = Promise.resolve();
|
|
38
|
+
|
|
39
|
+
async function ensure() {
|
|
40
|
+
await mkdir(paths.commandsDir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function getPid() {
|
|
44
|
+
return (await readJson(paths.pidFile, {})).pid;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function writeStatus(patch) {
|
|
48
|
+
statusWrite = statusWrite.catch(() => {}).then(() => writeDaemonStatus(paths, patch));
|
|
49
|
+
return statusWrite;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function claimNext() {
|
|
53
|
+
await ensure();
|
|
54
|
+
const files = await readdir(paths.commandsDir);
|
|
55
|
+
const pending = files
|
|
56
|
+
.filter((file) => file.endsWith(".request.json") || file.endsWith(".processing.json"))
|
|
57
|
+
.sort((a, b) => {
|
|
58
|
+
const aControl = a.startsWith("control-") ? 0 : 1;
|
|
59
|
+
const bControl = b.startsWith("control-") ? 0 : 1;
|
|
60
|
+
return aControl - bControl || a.localeCompare(b);
|
|
61
|
+
});
|
|
62
|
+
for (const file of pending) {
|
|
63
|
+
const id = file.replace(/\.(?:request|processing)\.json$/, "");
|
|
64
|
+
const item = daemonJobPaths(paths, id);
|
|
65
|
+
if (await readJson(item.result, null)) {
|
|
66
|
+
await Promise.all([unlink(item.request).catch(() => {}), unlink(item.processing).catch(() => {})]);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
if (file.endsWith(".request.json")) await rename(item.request, item.processing);
|
|
71
|
+
const record = await readJson(item.processing, null);
|
|
72
|
+
if (!record) continue;
|
|
73
|
+
const accepted = {
|
|
74
|
+
...record,
|
|
75
|
+
status: "accepted",
|
|
76
|
+
acceptedAt: record.acceptedAt || new Date().toISOString()
|
|
77
|
+
};
|
|
78
|
+
await writeJson(item.processing, accepted);
|
|
79
|
+
return { id, ...item, payload: accepted.payload };
|
|
80
|
+
} catch {}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function workLoop({
|
|
86
|
+
processJob,
|
|
87
|
+
healthCheck,
|
|
88
|
+
recover = null,
|
|
89
|
+
beforeExit = null,
|
|
90
|
+
idleTimeoutMs = 0
|
|
91
|
+
}) {
|
|
92
|
+
if (typeof healthCheck !== "function") throw new Error(`${toolName} daemon must declare healthCheck`);
|
|
93
|
+
const policy = await loadDaemonPolicy();
|
|
94
|
+
const ipcLimits = {
|
|
95
|
+
maxFrameBytes: policy.ipcFrameBytes || 1_048_576,
|
|
96
|
+
streamBufferBytes: policy.streamBufferBytes || 1_048_576
|
|
97
|
+
};
|
|
98
|
+
const subscribers = new Map();
|
|
99
|
+
const activeJobs = new Map();
|
|
100
|
+
const cancelledJobs = new Set();
|
|
101
|
+
let lastActivity = Date.now();
|
|
102
|
+
let processing = false;
|
|
103
|
+
let exiting = false;
|
|
104
|
+
let acceptingWork = true;
|
|
105
|
+
let processRequested = false;
|
|
106
|
+
|
|
107
|
+
await ensure();
|
|
108
|
+
const capabilityToken = process.env.ARISA_DAEMON_CAPABILITY || await ensureDaemonCapability(paths);
|
|
109
|
+
await writeStatus({
|
|
110
|
+
state: "starting",
|
|
111
|
+
pid: process.pid,
|
|
112
|
+
heartbeatAt: new Date().toISOString(),
|
|
113
|
+
supportsRecovery: typeof recover === "function",
|
|
114
|
+
message: "Daemon work loop started; waiting for health check"
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
async function publish(frame) {
|
|
118
|
+
const sockets = [...(subscribers.get(frame.jobId) || [])];
|
|
119
|
+
for (const socket of sockets) {
|
|
120
|
+
if (!(await writeDaemonSocketFrame(socket, frame, ipcLimits))) subscribers.get(frame.jobId)?.delete(socket);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function persistTerminal(job, frame) {
|
|
125
|
+
await writeJson(job.result, {
|
|
126
|
+
id: job.id,
|
|
127
|
+
status: frame.type,
|
|
128
|
+
completedAt: new Date().toISOString(),
|
|
129
|
+
terminal: frame
|
|
130
|
+
});
|
|
131
|
+
await unlink(job.processing).catch(() => {});
|
|
132
|
+
await publish(frame);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function execute(job) {
|
|
136
|
+
let sequence = 1;
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
activeJobs.set(job.id, controller);
|
|
139
|
+
if (cancelledJobs.delete(job.id)) controller.abort();
|
|
140
|
+
await publish(daemonFrame(job.id, "accepted", sequence, {}));
|
|
141
|
+
const emit = async (type, payload = {}) => {
|
|
142
|
+
if (!["progress", "chunk"].includes(type)) throw new Error(`Invalid non-terminal daemon event type: ${type}`);
|
|
143
|
+
sequence += 1;
|
|
144
|
+
await publish(daemonFrame(job.id, type, sequence, payload));
|
|
145
|
+
};
|
|
146
|
+
const operation = job.payload?.[DAEMON_CONTROL_FIELD]?.operation;
|
|
147
|
+
try {
|
|
148
|
+
let output;
|
|
149
|
+
if (operation === "health") {
|
|
150
|
+
const checkedAt = new Date().toISOString();
|
|
151
|
+
await writeStatus({ lastHealthCheckAt: checkedAt });
|
|
152
|
+
output = await withTimeout(
|
|
153
|
+
healthCheck,
|
|
154
|
+
policy.healthTimeoutMs,
|
|
155
|
+
`${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
|
|
156
|
+
);
|
|
157
|
+
await writeStatus({
|
|
158
|
+
state: "ready",
|
|
159
|
+
lastHealthSuccessAt: new Date().toISOString(),
|
|
160
|
+
consecutiveHealthFailures: 0,
|
|
161
|
+
restartAttempts: 0,
|
|
162
|
+
restartRequested: false,
|
|
163
|
+
nextRestartAt: null,
|
|
164
|
+
message: output?.message || "Daemon health check passed"
|
|
165
|
+
});
|
|
166
|
+
output ||= { ok: true };
|
|
167
|
+
} else if (operation === "recover") {
|
|
168
|
+
const recovered = typeof recover === "function"
|
|
169
|
+
? await withTimeout(recover, policy.healthTimeoutMs, `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`)
|
|
170
|
+
: false;
|
|
171
|
+
output = { recovered: recovered !== false };
|
|
172
|
+
} else {
|
|
173
|
+
lastActivity = Date.now();
|
|
174
|
+
if (controller.signal.aborted) {
|
|
175
|
+
throw Object.assign(new Error(`Daemon job cancelled: ${job.id}`), { code: "DAEMON_JOB_CANCELLED" });
|
|
176
|
+
}
|
|
177
|
+
output = await processJob(job.payload, { emit, jobId: job.id, signal: controller.signal });
|
|
178
|
+
await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
|
|
179
|
+
lastActivity = Date.now();
|
|
180
|
+
}
|
|
181
|
+
sequence += 1;
|
|
182
|
+
await persistTerminal(job, daemonFrame(job.id, "completed", sequence, { output }));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error?.code === "DAEMON_OPERATION_TIMEOUT") acceptingWork = false;
|
|
185
|
+
const current = await readJson(paths.statusFile, {});
|
|
186
|
+
await writeStatus({
|
|
187
|
+
...(operation === "health" ? {
|
|
188
|
+
state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
|
|
189
|
+
consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
|
|
190
|
+
} : {}),
|
|
191
|
+
lastError: {
|
|
192
|
+
at: new Date().toISOString(),
|
|
193
|
+
phase: operation || "job",
|
|
194
|
+
message: error?.message || String(error),
|
|
195
|
+
...(error?.code ? { code: error.code } : {})
|
|
196
|
+
},
|
|
197
|
+
message: error?.message || String(error)
|
|
198
|
+
});
|
|
199
|
+
sequence += 1;
|
|
200
|
+
await persistTerminal(job, daemonFrame(job.id, "failed", sequence, {
|
|
201
|
+
error: error?.message || String(error),
|
|
202
|
+
code: error?.code || null
|
|
203
|
+
}));
|
|
204
|
+
} finally {
|
|
205
|
+
activeJobs.delete(job.id);
|
|
206
|
+
cancelledJobs.delete(job.id);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function processQueue() {
|
|
211
|
+
if (processing || exiting || !acceptingWork) {
|
|
212
|
+
processRequested = true;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
processing = true;
|
|
216
|
+
try {
|
|
217
|
+
do {
|
|
218
|
+
processRequested = false;
|
|
219
|
+
const job = await claimNext();
|
|
220
|
+
if (!job) break;
|
|
221
|
+
await execute(job);
|
|
222
|
+
} while (!exiting && acceptingWork);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
await writeStatus({
|
|
225
|
+
state: "degraded",
|
|
226
|
+
lastError: { at: new Date().toISOString(), phase: "work-loop", message: error?.message || String(error), ...(error?.code ? { code: error.code } : {}) },
|
|
227
|
+
message: error?.message || String(error)
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
processing = false;
|
|
231
|
+
if (processRequested && !exiting && acceptingWork) queueMicrotask(() => processQueue().catch(() => {}));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (process.platform !== "win32") await rm(paths.socketFile, { force: true });
|
|
236
|
+
const server = net.createServer((socket) => {
|
|
237
|
+
socket.setEncoding("utf8");
|
|
238
|
+
let buffer = "";
|
|
239
|
+
const subscribedJobs = new Set();
|
|
240
|
+
const removeSocket = () => {
|
|
241
|
+
for (const jobId of subscribedJobs) subscribers.get(jobId)?.delete(socket);
|
|
242
|
+
};
|
|
243
|
+
socket.on("data", (chunk) => {
|
|
244
|
+
buffer += chunk;
|
|
245
|
+
if (Buffer.byteLength(buffer, "utf8") > ipcLimits.maxFrameBytes && !buffer.includes("\n")) {
|
|
246
|
+
socket.destroy();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
250
|
+
while (newlineIndex !== -1) {
|
|
251
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
252
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
253
|
+
newlineIndex = buffer.indexOf("\n");
|
|
254
|
+
if (!line) continue;
|
|
255
|
+
let notification;
|
|
256
|
+
try {
|
|
257
|
+
notification = JSON.parse(line);
|
|
258
|
+
} catch {
|
|
259
|
+
socket.destroy();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (notification.version !== DAEMON_PROTOCOL_VERSION
|
|
263
|
+
|| !["submit", "cancel"].includes(notification.type)
|
|
264
|
+
|| typeof notification.jobId !== "string"
|
|
265
|
+
|| notification.capabilityToken !== capabilityToken) {
|
|
266
|
+
socket.destroy();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const { jobId } = notification;
|
|
270
|
+
if (notification.type === "cancel") {
|
|
271
|
+
cancelledJobs.add(jobId);
|
|
272
|
+
activeJobs.get(jobId)?.abort();
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!subscribers.has(jobId)) subscribers.set(jobId, new Set());
|
|
276
|
+
subscribers.get(jobId).add(socket);
|
|
277
|
+
subscribedJobs.add(jobId);
|
|
278
|
+
readJson(daemonJobPaths(paths, jobId).result, null).then((result) => {
|
|
279
|
+
if (result?.terminal) return writeDaemonSocketFrame(socket, result.terminal, ipcLimits);
|
|
280
|
+
return processQueue();
|
|
281
|
+
}).catch(() => socket.destroy());
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
socket.once("close", removeSocket);
|
|
285
|
+
socket.once("error", removeSocket);
|
|
286
|
+
});
|
|
287
|
+
await new Promise((resolve, reject) => {
|
|
288
|
+
server.once("error", reject);
|
|
289
|
+
server.listen(paths.socketFile, resolve);
|
|
290
|
+
});
|
|
291
|
+
if (process.platform !== "win32") await chmod(paths.socketFile, 0o600);
|
|
292
|
+
|
|
293
|
+
const heartbeatTimer = setInterval(() => {
|
|
294
|
+
writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
|
|
295
|
+
}, policy.heartbeatIntervalMs);
|
|
296
|
+
const idleTimer = idleTimeoutMs > 0 ? setInterval(async () => {
|
|
297
|
+
if (processing || exiting || Date.now() - lastActivity <= idleTimeoutMs) return;
|
|
298
|
+
exiting = true;
|
|
299
|
+
clearInterval(heartbeatTimer);
|
|
300
|
+
clearInterval(idleTimer);
|
|
301
|
+
await beforeExit?.();
|
|
302
|
+
await writeStatus({ state: "stopped", restartRequested: false, nextRestartAt: null, message: "Idle timeout reached" });
|
|
303
|
+
server.close(() => process.exit(0));
|
|
304
|
+
}, Math.min(idleTimeoutMs, 1_000)) : null;
|
|
305
|
+
|
|
306
|
+
processQueue().catch(() => {});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { ensure, getPid, writeStatus, claimNext, workLoop };
|
|
310
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import net from "node:net";
|
|
3
|
-
import { arisaIpcSocketFile } from "../../
|
|
3
|
+
import { arisaIpcSocketFile } from "../../platform/paths.js";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
6
6
|
|
|
@@ -91,7 +91,7 @@ export function createArisaClient({
|
|
|
91
91
|
enqueueEvent: (params) => call("agent.enqueueEvent", params)
|
|
92
92
|
},
|
|
93
93
|
tools: {
|
|
94
|
-
list: () => call("tools.list"),
|
|
94
|
+
list: (params = {}) => call("tools.list", params),
|
|
95
95
|
help: (params) => call("tools.help", params),
|
|
96
96
|
skills: (params) => call("tools.skills", params),
|
|
97
97
|
setConfig: (params) => call("tools.setConfig", params),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
function linuxMemoryValues(text) {
|
|
5
|
+
const values = new Map();
|
|
6
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
7
|
+
const match = line.match(/^([A-Za-z_]+):\s+(\d+)\s+kB$/);
|
|
8
|
+
if (match) values.set(match[1], Number(match[2]) * 1024);
|
|
9
|
+
}
|
|
10
|
+
return values;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function readMemoryPressure({
|
|
14
|
+
platform = process.platform,
|
|
15
|
+
readMemInfo = () => readFile("/proc/meminfo", "utf8"),
|
|
16
|
+
freeMemory = () => os.freemem(),
|
|
17
|
+
totalMemory = () => os.totalmem(),
|
|
18
|
+
processMemory = () => process.memoryUsage()
|
|
19
|
+
} = {}) {
|
|
20
|
+
let availableBytes = Number(freeMemory()) || 0;
|
|
21
|
+
let totalBytes = Number(totalMemory()) || 0;
|
|
22
|
+
let swapTotalBytes = 0;
|
|
23
|
+
let swapFreeBytes = 0;
|
|
24
|
+
if (platform === "linux") {
|
|
25
|
+
try {
|
|
26
|
+
const values = linuxMemoryValues(await readMemInfo());
|
|
27
|
+
availableBytes = values.get("MemAvailable") || availableBytes;
|
|
28
|
+
totalBytes = values.get("MemTotal") || totalBytes;
|
|
29
|
+
swapTotalBytes = values.get("SwapTotal") || 0;
|
|
30
|
+
swapFreeBytes = values.get("SwapFree") || 0;
|
|
31
|
+
} catch {}
|
|
32
|
+
}
|
|
33
|
+
const swapUsedPercent = swapTotalBytes > 0
|
|
34
|
+
? ((swapTotalBytes - swapFreeBytes) / swapTotalBytes) * 100
|
|
35
|
+
: 0;
|
|
36
|
+
return {
|
|
37
|
+
availableBytes,
|
|
38
|
+
totalBytes,
|
|
39
|
+
swapTotalBytes,
|
|
40
|
+
swapFreeBytes,
|
|
41
|
+
swapUsedPercent,
|
|
42
|
+
workerRssBytes: Number(processMemory()?.rss) || 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function memoryPressureReason(snapshot, policy) {
|
|
47
|
+
const mebibyte = 1024 * 1024;
|
|
48
|
+
const workerRssMb = snapshot.workerRssBytes / mebibyte;
|
|
49
|
+
if (workerRssMb > policy.maxWorkerRssMb) {
|
|
50
|
+
return `worker RSS ${Math.ceil(workerRssMb)} MiB exceeds the ${policy.maxWorkerRssMb} MiB limit`;
|
|
51
|
+
}
|
|
52
|
+
if (snapshot.swapTotalBytes > 0 && snapshot.swapUsedPercent > policy.maxSwapUsedPercent) {
|
|
53
|
+
return `swap use ${Math.ceil(snapshot.swapUsedPercent)}% exceeds the ${policy.maxSwapUsedPercent}% limit`;
|
|
54
|
+
}
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
rm
|
|
13
13
|
} from "node:fs/promises";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { getToolDir } from "../../
|
|
15
|
+
import { getToolDir } from "../../platform/paths.js";
|
|
16
16
|
import { normalizeToolDependencies, resolveToolDependencyPlan, satisfiesToolVersion } from "./tool-dependencies.js";
|
|
17
17
|
|
|
18
18
|
const bundledLockFile = new URL("../../official-tools.lock.json", import.meta.url);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { getToolConfigPath, getChatToolConfigPath } from "../../
|
|
3
|
+
import { getToolConfigPath, getChatToolConfigPath } from "../../platform/paths.js";
|
|
4
4
|
|
|
5
5
|
export function parseConfigModule(source) {
|
|
6
6
|
const normalized = source.replace(/^export\s+default/, "return");
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { DAEMON_EVENT_TYPES, DAEMON_PROTOCOL_VERSION } from "./daemon-protocol.js";
|
|
2
|
+
|
|
3
|
+
export function createToolOutputParser(name, {
|
|
4
|
+
onEvent,
|
|
5
|
+
maxFrameBytes = 1_048_576,
|
|
6
|
+
maxOutputBytes = maxFrameBytes
|
|
7
|
+
} = {}) {
|
|
8
|
+
let buffer = "";
|
|
9
|
+
let mode = "unknown";
|
|
10
|
+
let rawOutput = "";
|
|
11
|
+
let rawOutputBytes = 0;
|
|
12
|
+
let terminalResult = null;
|
|
13
|
+
let activeJobId = null;
|
|
14
|
+
let sequence = 0;
|
|
15
|
+
let terminalSeen = false;
|
|
16
|
+
|
|
17
|
+
async function parseEvent(line) {
|
|
18
|
+
let event;
|
|
19
|
+
try {
|
|
20
|
+
event = JSON.parse(line);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`Invalid NDJSON from ${name}`);
|
|
23
|
+
}
|
|
24
|
+
if (event?.version !== DAEMON_PROTOCOL_VERSION || !DAEMON_EVENT_TYPES.includes(event?.type)) {
|
|
25
|
+
throw new Error(`Invalid versioned tool event from ${name}`);
|
|
26
|
+
}
|
|
27
|
+
if (typeof event.jobId !== "string" || !event.jobId) throw new Error(`Tool event from ${name} is missing jobId`);
|
|
28
|
+
if (activeJobId == null) activeJobId = event.jobId;
|
|
29
|
+
if (event.jobId !== activeJobId) throw new Error(`Tool ${name} multiplexed an unexpected jobId`);
|
|
30
|
+
if (!Number.isSafeInteger(event.sequence) || event.sequence !== sequence + 1) {
|
|
31
|
+
throw new Error(`Invalid tool event sequence from ${name}: ${event.sequence}`);
|
|
32
|
+
}
|
|
33
|
+
if (terminalSeen) throw new Error(`Tool ${name} emitted more than one terminal event`);
|
|
34
|
+
sequence = event.sequence;
|
|
35
|
+
terminalSeen = event.type === "completed" || event.type === "failed";
|
|
36
|
+
await onEvent?.(event);
|
|
37
|
+
if (terminalSeen) {
|
|
38
|
+
terminalResult = event.type === "completed"
|
|
39
|
+
? event.payload?.result ?? event.payload?.output ?? event.payload
|
|
40
|
+
: { ok: false, error: event.payload?.error || `Tool failed: ${name}`, ...(event.payload?.code ? { code: event.payload.code } : {}) };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function consumeLine(line) {
|
|
45
|
+
if (Buffer.byteLength(line, "utf8") > maxFrameBytes) throw new Error(`Tool event from ${name} exceeds ${maxFrameBytes} bytes`);
|
|
46
|
+
if (mode === "unknown") {
|
|
47
|
+
let candidate;
|
|
48
|
+
try {
|
|
49
|
+
candidate = JSON.parse(line);
|
|
50
|
+
} catch {
|
|
51
|
+
mode = "legacy";
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (candidate?.version === DAEMON_PROTOCOL_VERSION && DAEMON_EVENT_TYPES.includes(candidate?.type)) {
|
|
55
|
+
mode = "ndjson";
|
|
56
|
+
rawOutput = "";
|
|
57
|
+
return parseEvent(line);
|
|
58
|
+
}
|
|
59
|
+
mode = "legacy";
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (mode === "legacy") return;
|
|
63
|
+
return parseEvent(line);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
async push(chunk) {
|
|
68
|
+
const text = chunk.toString("utf8");
|
|
69
|
+
if (mode !== "ndjson") {
|
|
70
|
+
rawOutputBytes += Buffer.byteLength(text, "utf8");
|
|
71
|
+
if (rawOutputBytes > maxOutputBytes) {
|
|
72
|
+
const error = new Error(`Tool output from ${name} exceeds ${maxOutputBytes} bytes`);
|
|
73
|
+
error.code = "TOOL_OUTPUT_LIMIT";
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
rawOutput += text;
|
|
77
|
+
}
|
|
78
|
+
if (mode === "legacy") return;
|
|
79
|
+
buffer += text;
|
|
80
|
+
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
|
|
81
|
+
throw new Error(`Tool event from ${name} exceeds ${maxFrameBytes} bytes`);
|
|
82
|
+
}
|
|
83
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
84
|
+
while (newlineIndex !== -1) {
|
|
85
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
86
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
87
|
+
if (line) await consumeLine(line);
|
|
88
|
+
newlineIndex = buffer.indexOf("\n");
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async finish() {
|
|
92
|
+
const tail = buffer.trim();
|
|
93
|
+
buffer = "";
|
|
94
|
+
if (tail) await consumeLine(tail);
|
|
95
|
+
if (mode !== "ndjson") return { mode: "legacy", output: rawOutput };
|
|
96
|
+
if (!terminalSeen) throw new Error(`Tool ${name} ended without a terminal event`);
|
|
97
|
+
return { mode: "ndjson", result: terminalResult };
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { arisaIpcSocketFile, arisaPackageDir } from "../../platform/paths.js";
|
|
5
|
+
import { daemonConfigDefaults } from "../config/config-defaults.js";
|
|
6
|
+
import { createToolOutputParser } from "./tool-process-output.js";
|
|
7
|
+
|
|
8
|
+
export function toolProcessEnv() {
|
|
9
|
+
return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isolatedToolProcessInvocation(nodeArgs, execution, {
|
|
13
|
+
platform = process.platform,
|
|
14
|
+
systemdAvailable = existsSync("/run/systemd/system"),
|
|
15
|
+
oomAdjustAvailable = existsSync("/usr/bin/choom")
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (!execution?.maxMemoryMb || platform !== "linux" || !systemdAvailable) {
|
|
18
|
+
return { command: "node", args: nodeArgs, isolated: false };
|
|
19
|
+
}
|
|
20
|
+
const memoryHighPercent = Number.isSafeInteger(execution.memoryHighPercent)
|
|
21
|
+
? execution.memoryHighPercent
|
|
22
|
+
: 85;
|
|
23
|
+
const memoryHighMb = Math.max(1, Math.floor(execution.maxMemoryMb * memoryHighPercent / 100));
|
|
24
|
+
const swapMaxMb = Number.isSafeInteger(execution.swapMaxMb) ? execution.swapMaxMb : 128;
|
|
25
|
+
return {
|
|
26
|
+
command: "systemd-run",
|
|
27
|
+
args: [
|
|
28
|
+
"--scope",
|
|
29
|
+
"--quiet",
|
|
30
|
+
"--collect",
|
|
31
|
+
"--slice=arisa-tools.slice",
|
|
32
|
+
"-p", `MemoryHigh=${memoryHighMb}M`,
|
|
33
|
+
"-p", `MemoryMax=${execution.maxMemoryMb}M`,
|
|
34
|
+
"-p", `MemorySwapMax=${swapMaxMb}M`,
|
|
35
|
+
"--",
|
|
36
|
+
...(oomAdjustAvailable ? ["choom", "-n", "500", "--"] : []),
|
|
37
|
+
"node",
|
|
38
|
+
...nodeArgs
|
|
39
|
+
],
|
|
40
|
+
isolated: true
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function terminateToolProcess(child, signal) {
|
|
45
|
+
if (process.platform !== "win32" && Number.isInteger(child.pid)) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(-child.pid, signal);
|
|
48
|
+
return;
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
child.kill(signal);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function waitForToolProcess(child, { timeoutMs, killGraceMs, label }) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
let timedOut = false;
|
|
57
|
+
let forceTimer = null;
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
timedOut = true;
|
|
60
|
+
terminateToolProcess(child, "SIGTERM");
|
|
61
|
+
forceTimer = setTimeout(() => terminateToolProcess(child, "SIGKILL"), killGraceMs);
|
|
62
|
+
}, timeoutMs);
|
|
63
|
+
|
|
64
|
+
const finish = (callback, value) => {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
clearTimeout(forceTimer);
|
|
67
|
+
callback(value);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
child.once("error", (error) => finish(reject, error));
|
|
71
|
+
child.once("close", (code) => {
|
|
72
|
+
if (!timedOut) {
|
|
73
|
+
finish(resolve, code);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
77
|
+
error.code = "TOOL_PROCESS_TIMEOUT";
|
|
78
|
+
finish(reject, error);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function runToolHelpProcess(command, args, {
|
|
84
|
+
timeoutMs,
|
|
85
|
+
killGraceMs,
|
|
86
|
+
label,
|
|
87
|
+
maxOutputBytes = daemonConfigDefaults.ipcFrameBytes,
|
|
88
|
+
...options
|
|
89
|
+
} = {}) {
|
|
90
|
+
const child = spawn(command, args, {
|
|
91
|
+
detached: process.platform !== "win32",
|
|
92
|
+
...options,
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
94
|
+
});
|
|
95
|
+
let stdout = "";
|
|
96
|
+
let stderr = "";
|
|
97
|
+
let outputBytes = 0;
|
|
98
|
+
let outputError = null;
|
|
99
|
+
let forceTimer = null;
|
|
100
|
+
const append = (current, chunk) => {
|
|
101
|
+
if (outputError) return current;
|
|
102
|
+
outputBytes += chunk.length;
|
|
103
|
+
if (outputBytes > maxOutputBytes) {
|
|
104
|
+
outputError = new Error(`${label} output exceeds ${maxOutputBytes} bytes`);
|
|
105
|
+
outputError.code = "TOOL_OUTPUT_LIMIT";
|
|
106
|
+
terminateToolProcess(child, "SIGTERM");
|
|
107
|
+
forceTimer = setTimeout(() => terminateToolProcess(child, "SIGKILL"), killGraceMs);
|
|
108
|
+
forceTimer.unref?.();
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
return current + chunk.toString("utf8");
|
|
112
|
+
};
|
|
113
|
+
child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
|
|
114
|
+
child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
|
|
115
|
+
const code = await waitForToolProcess(child, { timeoutMs, killGraceMs, label });
|
|
116
|
+
clearTimeout(forceTimer);
|
|
117
|
+
if (outputError) throw outputError;
|
|
118
|
+
return { code, stdout, stderr };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function runToolProcess(command, args, {
|
|
122
|
+
onEvent,
|
|
123
|
+
maxFrameBytes,
|
|
124
|
+
maxOutputBytes,
|
|
125
|
+
parserName,
|
|
126
|
+
timeoutMs,
|
|
127
|
+
killGraceMs,
|
|
128
|
+
label,
|
|
129
|
+
...options
|
|
130
|
+
} = {}) {
|
|
131
|
+
const child = spawn(command, args, {
|
|
132
|
+
detached: process.platform !== "win32",
|
|
133
|
+
...options,
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
135
|
+
});
|
|
136
|
+
const parser = createToolOutputParser(parserName || path.basename(args[0] || command), { onEvent, maxFrameBytes, maxOutputBytes });
|
|
137
|
+
const stderrChunks = [];
|
|
138
|
+
let stderrBytes = 0;
|
|
139
|
+
let stdoutError = null;
|
|
140
|
+
let outputForceTimer = null;
|
|
141
|
+
const stdoutTask = (async () => {
|
|
142
|
+
try {
|
|
143
|
+
for await (const chunk of child.stdout) await parser.push(chunk);
|
|
144
|
+
return parser.finish();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
stdoutError = error;
|
|
147
|
+
terminateToolProcess(child, "SIGTERM");
|
|
148
|
+
outputForceTimer = setTimeout(() => terminateToolProcess(child, "SIGKILL"), killGraceMs);
|
|
149
|
+
outputForceTimer.unref?.();
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
const stderrTask = (async () => {
|
|
154
|
+
for await (const chunk of child.stderr) {
|
|
155
|
+
if (stderrBytes >= maxFrameBytes) continue;
|
|
156
|
+
const accepted = chunk.subarray(0, maxFrameBytes - stderrBytes);
|
|
157
|
+
stderrChunks.push(accepted);
|
|
158
|
+
stderrBytes += accepted.length;
|
|
159
|
+
}
|
|
160
|
+
return Buffer.concat(stderrChunks).toString("utf8");
|
|
161
|
+
})();
|
|
162
|
+
child.stdout.resume();
|
|
163
|
+
child.stderr.resume();
|
|
164
|
+
let code;
|
|
165
|
+
try {
|
|
166
|
+
code = await waitForToolProcess(child, { timeoutMs, killGraceMs, label });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
await Promise.allSettled([stdoutTask, stderrTask]);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
const [parsed, stderr] = await Promise.all([stdoutTask, stderrTask]);
|
|
172
|
+
clearTimeout(outputForceTimer);
|
|
173
|
+
if (stdoutError) throw stdoutError;
|
|
174
|
+
return { code, parsed, stderr };
|
|
175
|
+
}
|