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
|
@@ -1,244 +1,21 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import net from "node:net";
|
|
3
|
-
import { chmod, mkdir, readFile, readdir, rename, rm, unlink } from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
1
|
import {
|
|
6
2
|
daemonPaths,
|
|
7
|
-
ensureDaemonCapability,
|
|
8
3
|
isProcessAlive,
|
|
9
4
|
readJson,
|
|
10
5
|
startManagedDaemon,
|
|
11
|
-
stopManagedDaemon
|
|
12
|
-
writeDaemonStatus,
|
|
13
|
-
writeJson
|
|
6
|
+
stopManagedDaemon
|
|
14
7
|
} from "./daemon-processes.js";
|
|
15
8
|
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
9
|
+
import { enqueueDaemonJob, submitDaemonControl } from "./daemon-client.js";
|
|
10
|
+
import { createDaemonWorker } from "./daemon-worker.js";
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
export
|
|
19
|
-
export const DAEMON_EVENT_TYPES = Object.freeze(["accepted", "progress", "chunk", "completed", "failed"]);
|
|
20
|
-
const TERMINAL_EVENT_TYPES = new Set(["completed", "failed"]);
|
|
12
|
+
export { submitDaemonControl } from "./daemon-client.js";
|
|
13
|
+
export { DAEMON_EVENT_TYPES, DAEMON_PROTOCOL_VERSION } from "./daemon-protocol.js";
|
|
21
14
|
|
|
22
15
|
function sleep(ms) {
|
|
23
16
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
17
|
}
|
|
25
18
|
|
|
26
|
-
async function withTimeout(work, timeoutMs, message) {
|
|
27
|
-
let timer;
|
|
28
|
-
try {
|
|
29
|
-
return await Promise.race([
|
|
30
|
-
Promise.resolve().then(work),
|
|
31
|
-
new Promise((_, reject) => {
|
|
32
|
-
timer = setTimeout(() => {
|
|
33
|
-
const error = new Error(message);
|
|
34
|
-
error.code = "DAEMON_OPERATION_TIMEOUT";
|
|
35
|
-
reject(error);
|
|
36
|
-
}, timeoutMs);
|
|
37
|
-
})
|
|
38
|
-
]);
|
|
39
|
-
} finally {
|
|
40
|
-
clearTimeout(timer);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function jobPaths(paths, id) {
|
|
45
|
-
return {
|
|
46
|
-
request: path.join(paths.commandsDir, `${id}.request.json`),
|
|
47
|
-
processing: path.join(paths.commandsDir, `${id}.processing.json`),
|
|
48
|
-
result: path.join(paths.commandsDir, `${id}.result.json`)
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function daemonFrame(jobId, type, sequence, payload = {}) {
|
|
53
|
-
return { version: DAEMON_PROTOCOL_VERSION, jobId, type, sequence, payload };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function terminalResult(frame) {
|
|
57
|
-
if (frame.type === "failed") {
|
|
58
|
-
const error = new Error(frame.payload?.error || "Daemon job failed");
|
|
59
|
-
if (frame.payload?.code) error.code = frame.payload.code;
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
return frame.payload?.output || {};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function readCapability(paths) {
|
|
66
|
-
const token = (await readFile(paths.capabilityFile, "utf8")).trim();
|
|
67
|
-
if (!token) throw new Error(`Invalid daemon capability for ${paths.toolName}`);
|
|
68
|
-
return token;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function writeSocketFrame(socket, frame, { maxFrameBytes = 1_048_576, streamBufferBytes = 1_048_576 } = {}) {
|
|
72
|
-
if (socket.destroyed || !socket.writable) return false;
|
|
73
|
-
const encoded = `${JSON.stringify(frame)}\n`;
|
|
74
|
-
if (Buffer.byteLength(encoded, "utf8") > maxFrameBytes) {
|
|
75
|
-
throw new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`);
|
|
76
|
-
}
|
|
77
|
-
if (socket.writableLength >= streamBufferBytes) {
|
|
78
|
-
await new Promise((resolve) => {
|
|
79
|
-
const finish = () => {
|
|
80
|
-
socket.off("drain", finish);
|
|
81
|
-
socket.off("close", finish);
|
|
82
|
-
socket.off("error", finish);
|
|
83
|
-
resolve();
|
|
84
|
-
};
|
|
85
|
-
socket.once("drain", finish);
|
|
86
|
-
socket.once("close", finish);
|
|
87
|
-
socket.once("error", finish);
|
|
88
|
-
});
|
|
89
|
-
if (socket.destroyed || !socket.writable) return false;
|
|
90
|
-
}
|
|
91
|
-
if (socket.write(encoded)) return true;
|
|
92
|
-
await new Promise((resolve) => {
|
|
93
|
-
const finish = () => {
|
|
94
|
-
socket.off("drain", finish);
|
|
95
|
-
socket.off("close", finish);
|
|
96
|
-
socket.off("error", finish);
|
|
97
|
-
resolve();
|
|
98
|
-
};
|
|
99
|
-
socket.once("drain", finish);
|
|
100
|
-
socket.once("close", finish);
|
|
101
|
-
socket.once("error", finish);
|
|
102
|
-
});
|
|
103
|
-
return !socket.destroyed;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function validateDaemonEvent(frame, { jobId, previousSequence = 0, terminalSeen = false } = {}) {
|
|
107
|
-
if (!frame || frame.version !== DAEMON_PROTOCOL_VERSION || frame.jobId !== jobId) {
|
|
108
|
-
throw new Error("Invalid daemon event identity or protocol version");
|
|
109
|
-
}
|
|
110
|
-
if (!DAEMON_EVENT_TYPES.includes(frame.type)) throw new Error(`Invalid daemon event type: ${frame.type}`);
|
|
111
|
-
if (!Number.isSafeInteger(frame.sequence) || frame.sequence <= previousSequence) {
|
|
112
|
-
throw new Error(`Invalid daemon event sequence for ${jobId}: ${frame.sequence}`);
|
|
113
|
-
}
|
|
114
|
-
if (terminalSeen) throw new Error(`Daemon job ${jobId} emitted more than one terminal event`);
|
|
115
|
-
return { sequence: frame.sequence, terminal: TERMINAL_EVENT_TYPES.has(frame.type) };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function connectDaemon(paths, request, { timeoutMs, onEvent, maxFrameBytes }) {
|
|
119
|
-
const startedAt = Date.now();
|
|
120
|
-
const token = await readCapability(paths);
|
|
121
|
-
let lastError;
|
|
122
|
-
while (Date.now() - startedAt < timeoutMs) {
|
|
123
|
-
try {
|
|
124
|
-
return await new Promise((resolve, reject) => {
|
|
125
|
-
const socket = net.createConnection(paths.socketFile);
|
|
126
|
-
let buffer = "";
|
|
127
|
-
let sequence = 0;
|
|
128
|
-
let terminalSeen = false;
|
|
129
|
-
let settled = false;
|
|
130
|
-
let observerChain = Promise.resolve();
|
|
131
|
-
const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt));
|
|
132
|
-
const timer = setTimeout(() => finish(reject, Object.assign(
|
|
133
|
-
new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`),
|
|
134
|
-
{ code: "DAEMON_JOB_TIMEOUT" }
|
|
135
|
-
)), remainingMs);
|
|
136
|
-
timer.unref?.();
|
|
137
|
-
|
|
138
|
-
function finish(fn, value) {
|
|
139
|
-
if (settled) return;
|
|
140
|
-
settled = true;
|
|
141
|
-
clearTimeout(timer);
|
|
142
|
-
socket.destroy();
|
|
143
|
-
fn(value);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
socket.setEncoding("utf8");
|
|
147
|
-
socket.once("connect", () => {
|
|
148
|
-
socket.write(`${JSON.stringify({
|
|
149
|
-
version: DAEMON_PROTOCOL_VERSION,
|
|
150
|
-
type: "submit",
|
|
151
|
-
jobId: request.jobId,
|
|
152
|
-
capabilityToken: token
|
|
153
|
-
})}\n`);
|
|
154
|
-
});
|
|
155
|
-
socket.on("data", (chunk) => {
|
|
156
|
-
buffer += chunk;
|
|
157
|
-
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
|
|
158
|
-
finish(reject, new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`));
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
let newlineIndex = buffer.indexOf("\n");
|
|
162
|
-
while (newlineIndex !== -1) {
|
|
163
|
-
const line = buffer.slice(0, newlineIndex).trim();
|
|
164
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
165
|
-
newlineIndex = buffer.indexOf("\n");
|
|
166
|
-
if (!line) continue;
|
|
167
|
-
let frame;
|
|
168
|
-
try {
|
|
169
|
-
frame = JSON.parse(line);
|
|
170
|
-
const validated = validateDaemonEvent(frame, {
|
|
171
|
-
jobId: request.jobId,
|
|
172
|
-
previousSequence: sequence,
|
|
173
|
-
terminalSeen
|
|
174
|
-
});
|
|
175
|
-
sequence = validated.sequence;
|
|
176
|
-
terminalSeen = validated.terminal;
|
|
177
|
-
} catch (error) {
|
|
178
|
-
finish(reject, error);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
observerChain = observerChain.then(() => onEvent?.(frame));
|
|
182
|
-
if (terminalSeen) {
|
|
183
|
-
observerChain.then(() => {
|
|
184
|
-
try {
|
|
185
|
-
finish(resolve, terminalResult(frame));
|
|
186
|
-
} catch (error) {
|
|
187
|
-
finish(reject, error);
|
|
188
|
-
}
|
|
189
|
-
}, (error) => finish(reject, error));
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
socket.once("error", (error) => finish(reject, error));
|
|
194
|
-
socket.once("close", () => {
|
|
195
|
-
if (!settled) finish(reject, new Error("Daemon IPC connection closed before terminal result"));
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
} catch (error) {
|
|
199
|
-
lastError = error;
|
|
200
|
-
if (!["ENOENT", "ECONNREFUSED"].includes(error?.code)) throw error;
|
|
201
|
-
await sleep(Math.min(25, Math.max(1, timeoutMs - (Date.now() - startedAt))));
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
const error = new Error(`${paths.toolName} daemon IPC was unavailable after ${timeoutMs}ms`);
|
|
205
|
-
error.code = lastError?.code || "DAEMON_IPC_UNAVAILABLE";
|
|
206
|
-
throw error;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function enqueue(paths, payload, { control = false, timeoutMs, onEvent, jobId, maxFrameBytes } = {}) {
|
|
210
|
-
await mkdir(paths.commandsDir, { recursive: true });
|
|
211
|
-
const id = jobId || `${control ? "control" : "job"}-${crypto.randomUUID()}`;
|
|
212
|
-
const files = jobPaths(paths, id);
|
|
213
|
-
const existingResult = await readJson(files.result, null);
|
|
214
|
-
if (existingResult?.terminal) {
|
|
215
|
-
await onEvent?.(existingResult.terminal);
|
|
216
|
-
return terminalResult(existingResult.terminal);
|
|
217
|
-
}
|
|
218
|
-
const existingRequest = await readJson(files.request, null);
|
|
219
|
-
const existingAccepted = await readJson(files.processing, null);
|
|
220
|
-
if (!existingRequest && !existingAccepted) {
|
|
221
|
-
await writeJson(files.request, {
|
|
222
|
-
id,
|
|
223
|
-
status: "queued",
|
|
224
|
-
queuedAt: new Date().toISOString(),
|
|
225
|
-
payload
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
return connectDaemon(paths, { jobId: id }, { timeoutMs, onEvent, maxFrameBytes });
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function submitDaemonControl(record, operation, { timeoutMs, onEvent } = {}) {
|
|
232
|
-
const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
|
|
233
|
-
const policy = await loadDaemonPolicy();
|
|
234
|
-
return enqueue(paths, { [CONTROL_FIELD]: { operation } }, {
|
|
235
|
-
control: true,
|
|
236
|
-
timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
|
|
237
|
-
onEvent,
|
|
238
|
-
maxFrameBytes: policy.ipcFrameBytes || 1_048_576
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
19
|
export function isDaemonReady(status, pid, policy, now = Date.now()) {
|
|
243
20
|
if (status.state !== "ready" || !isProcessAlive(pid)) return false;
|
|
244
21
|
const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
|
|
@@ -258,20 +35,7 @@ export function createDaemonRuntime({
|
|
|
258
35
|
}) {
|
|
259
36
|
const paths = daemonPaths({ toolName, scope });
|
|
260
37
|
const registration = { toolName, entryPath, scope: paths.scope, startupContext, autoStart };
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
async function ensure() {
|
|
264
|
-
await mkdir(paths.commandsDir, { recursive: true });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function getPid() {
|
|
268
|
-
return (await readJson(paths.pidFile, {})).pid;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function writeStatus(patch) {
|
|
272
|
-
statusWrite = statusWrite.catch(() => {}).then(() => writeDaemonStatus(paths, patch));
|
|
273
|
-
return statusWrite;
|
|
274
|
-
}
|
|
38
|
+
const worker = createDaemonWorker({ toolName, paths });
|
|
275
39
|
|
|
276
40
|
async function start() {
|
|
277
41
|
return startManagedDaemon({ ...registration, beforeStart });
|
|
@@ -287,7 +51,7 @@ export function createDaemonRuntime({
|
|
|
287
51
|
const startTime = Date.now();
|
|
288
52
|
while (Date.now() - startTime < effectiveTimeoutMs) {
|
|
289
53
|
const status = await readJson(paths.statusFile, {});
|
|
290
|
-
const pid = await getPid();
|
|
54
|
+
const pid = await worker.getPid();
|
|
291
55
|
if (isDaemonReady(status, pid, policy)) return status;
|
|
292
56
|
if (status.state === "failed") throw new Error(status.message || `${toolName} daemon failed`);
|
|
293
57
|
await sleep(policy.queuePollIntervalMs);
|
|
@@ -298,7 +62,7 @@ export function createDaemonRuntime({
|
|
|
298
62
|
async function ensureReady({ timeoutMs } = {}) {
|
|
299
63
|
const policy = await loadDaemonPolicy();
|
|
300
64
|
const status = await readJson(paths.statusFile, {});
|
|
301
|
-
const pid = await getPid();
|
|
65
|
+
const pid = await worker.getPid();
|
|
302
66
|
if (isDaemonReady(status, pid, policy)) return status;
|
|
303
67
|
await submitDaemonControl(registration, "health", { timeoutMs: timeoutMs ?? policy.healthTimeoutMs });
|
|
304
68
|
return waitReady({ timeoutMs: timeoutMs ?? policy.startupTimeoutMs });
|
|
@@ -314,7 +78,7 @@ export function createDaemonRuntime({
|
|
|
314
78
|
const policy = await loadDaemonPolicy();
|
|
315
79
|
await start();
|
|
316
80
|
if (requireReady) await ensureReady({ timeoutMs: readyTimeoutMs });
|
|
317
|
-
return
|
|
81
|
+
return enqueueDaemonJob(paths, payload, {
|
|
318
82
|
timeoutMs: timeoutMs ?? policy.startupTimeoutMs,
|
|
319
83
|
onEvent,
|
|
320
84
|
jobId,
|
|
@@ -322,258 +86,17 @@ export function createDaemonRuntime({
|
|
|
322
86
|
});
|
|
323
87
|
}
|
|
324
88
|
|
|
325
|
-
async function claimNext() {
|
|
326
|
-
await ensure();
|
|
327
|
-
const files = await readdir(paths.commandsDir);
|
|
328
|
-
const pending = files
|
|
329
|
-
.filter((file) => file.endsWith(".request.json") || file.endsWith(".processing.json"))
|
|
330
|
-
.sort((a, b) => {
|
|
331
|
-
const aControl = a.startsWith("control-") ? 0 : 1;
|
|
332
|
-
const bControl = b.startsWith("control-") ? 0 : 1;
|
|
333
|
-
return aControl - bControl || a.localeCompare(b);
|
|
334
|
-
});
|
|
335
|
-
for (const file of pending) {
|
|
336
|
-
const id = file.replace(/\.(?:request|processing)\.json$/, "");
|
|
337
|
-
const item = jobPaths(paths, id);
|
|
338
|
-
if (await readJson(item.result, null)) {
|
|
339
|
-
await Promise.all([unlink(item.request).catch(() => {}), unlink(item.processing).catch(() => {})]);
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
try {
|
|
343
|
-
if (file.endsWith(".request.json")) await rename(item.request, item.processing);
|
|
344
|
-
const record = await readJson(item.processing, null);
|
|
345
|
-
if (!record) continue;
|
|
346
|
-
const accepted = {
|
|
347
|
-
...record,
|
|
348
|
-
status: "accepted",
|
|
349
|
-
acceptedAt: record.acceptedAt || new Date().toISOString()
|
|
350
|
-
};
|
|
351
|
-
await writeJson(item.processing, accepted);
|
|
352
|
-
return { id, ...item, payload: accepted.payload };
|
|
353
|
-
} catch {}
|
|
354
|
-
}
|
|
355
|
-
return null;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function workLoop({
|
|
359
|
-
processJob,
|
|
360
|
-
healthCheck,
|
|
361
|
-
recover = null,
|
|
362
|
-
beforeExit = null,
|
|
363
|
-
idleTimeoutMs = 0
|
|
364
|
-
}) {
|
|
365
|
-
if (typeof healthCheck !== "function") throw new Error(`${toolName} daemon must declare healthCheck`);
|
|
366
|
-
const policy = await loadDaemonPolicy();
|
|
367
|
-
const ipcLimits = {
|
|
368
|
-
maxFrameBytes: policy.ipcFrameBytes || 1_048_576,
|
|
369
|
-
streamBufferBytes: policy.streamBufferBytes || 1_048_576
|
|
370
|
-
};
|
|
371
|
-
const subscribers = new Map();
|
|
372
|
-
let lastActivity = Date.now();
|
|
373
|
-
let processing = false;
|
|
374
|
-
let exiting = false;
|
|
375
|
-
let acceptingWork = true;
|
|
376
|
-
let processRequested = false;
|
|
377
|
-
|
|
378
|
-
await ensure();
|
|
379
|
-
const capabilityToken = process.env.ARISA_DAEMON_CAPABILITY || await ensureDaemonCapability(paths);
|
|
380
|
-
await writeStatus({
|
|
381
|
-
state: "starting",
|
|
382
|
-
pid: process.pid,
|
|
383
|
-
heartbeatAt: new Date().toISOString(),
|
|
384
|
-
supportsRecovery: typeof recover === "function",
|
|
385
|
-
message: "Daemon work loop started; waiting for health check"
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
async function publish(frame) {
|
|
389
|
-
const sockets = [...(subscribers.get(frame.jobId) || [])];
|
|
390
|
-
for (const socket of sockets) {
|
|
391
|
-
if (!(await writeSocketFrame(socket, frame, ipcLimits))) subscribers.get(frame.jobId)?.delete(socket);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
async function persistTerminal(job, frame) {
|
|
396
|
-
await writeJson(job.result, {
|
|
397
|
-
id: job.id,
|
|
398
|
-
status: frame.type,
|
|
399
|
-
completedAt: new Date().toISOString(),
|
|
400
|
-
terminal: frame
|
|
401
|
-
});
|
|
402
|
-
await unlink(job.processing).catch(() => {});
|
|
403
|
-
await publish(frame);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
async function execute(job) {
|
|
407
|
-
let sequence = 1;
|
|
408
|
-
await publish(daemonFrame(job.id, "accepted", sequence, {}));
|
|
409
|
-
const emit = async (type, payload = {}) => {
|
|
410
|
-
if (!['progress', 'chunk'].includes(type)) throw new Error(`Invalid non-terminal daemon event type: ${type}`);
|
|
411
|
-
sequence += 1;
|
|
412
|
-
await publish(daemonFrame(job.id, type, sequence, payload));
|
|
413
|
-
};
|
|
414
|
-
const operation = job.payload?.[CONTROL_FIELD]?.operation;
|
|
415
|
-
try {
|
|
416
|
-
let output;
|
|
417
|
-
if (operation === "health") {
|
|
418
|
-
const checkedAt = new Date().toISOString();
|
|
419
|
-
await writeStatus({ lastHealthCheckAt: checkedAt });
|
|
420
|
-
output = await withTimeout(
|
|
421
|
-
healthCheck,
|
|
422
|
-
policy.healthTimeoutMs,
|
|
423
|
-
`${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
|
|
424
|
-
);
|
|
425
|
-
await writeStatus({
|
|
426
|
-
state: "ready",
|
|
427
|
-
lastHealthSuccessAt: new Date().toISOString(),
|
|
428
|
-
consecutiveHealthFailures: 0,
|
|
429
|
-
restartAttempts: 0,
|
|
430
|
-
restartRequested: false,
|
|
431
|
-
nextRestartAt: null,
|
|
432
|
-
message: output?.message || "Daemon health check passed"
|
|
433
|
-
});
|
|
434
|
-
output ||= { ok: true };
|
|
435
|
-
} else if (operation === "recover") {
|
|
436
|
-
const recovered = typeof recover === "function"
|
|
437
|
-
? await withTimeout(recover, policy.healthTimeoutMs, `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`)
|
|
438
|
-
: false;
|
|
439
|
-
output = { recovered: recovered !== false };
|
|
440
|
-
} else {
|
|
441
|
-
lastActivity = Date.now();
|
|
442
|
-
output = await processJob(job.payload, { emit, jobId: job.id });
|
|
443
|
-
await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
|
|
444
|
-
lastActivity = Date.now();
|
|
445
|
-
}
|
|
446
|
-
sequence += 1;
|
|
447
|
-
await persistTerminal(job, daemonFrame(job.id, "completed", sequence, { output }));
|
|
448
|
-
} catch (error) {
|
|
449
|
-
if (error?.code === "DAEMON_OPERATION_TIMEOUT") acceptingWork = false;
|
|
450
|
-
const current = await readJson(paths.statusFile, {});
|
|
451
|
-
await writeStatus({
|
|
452
|
-
...(operation === "health" ? {
|
|
453
|
-
state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
|
|
454
|
-
consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
|
|
455
|
-
} : {}),
|
|
456
|
-
lastError: {
|
|
457
|
-
at: new Date().toISOString(),
|
|
458
|
-
phase: operation || "job",
|
|
459
|
-
message: error?.message || String(error),
|
|
460
|
-
...(error?.code ? { code: error.code } : {})
|
|
461
|
-
},
|
|
462
|
-
message: error?.message || String(error)
|
|
463
|
-
});
|
|
464
|
-
sequence += 1;
|
|
465
|
-
await persistTerminal(job, daemonFrame(job.id, "failed", sequence, {
|
|
466
|
-
error: error?.message || String(error),
|
|
467
|
-
code: error?.code || null
|
|
468
|
-
}));
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
async function processQueue() {
|
|
473
|
-
if (processing || exiting || !acceptingWork) {
|
|
474
|
-
processRequested = true;
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
processing = true;
|
|
478
|
-
try {
|
|
479
|
-
do {
|
|
480
|
-
processRequested = false;
|
|
481
|
-
const job = await claimNext();
|
|
482
|
-
if (!job) break;
|
|
483
|
-
await execute(job);
|
|
484
|
-
} while (!exiting && acceptingWork);
|
|
485
|
-
} catch (error) {
|
|
486
|
-
await writeStatus({
|
|
487
|
-
state: "degraded",
|
|
488
|
-
lastError: { at: new Date().toISOString(), phase: "work-loop", message: error?.message || String(error), ...(error?.code ? { code: error.code } : {}) },
|
|
489
|
-
message: error?.message || String(error)
|
|
490
|
-
});
|
|
491
|
-
} finally {
|
|
492
|
-
processing = false;
|
|
493
|
-
if (processRequested && !exiting && acceptingWork) queueMicrotask(() => processQueue().catch(() => {}));
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (process.platform !== "win32") await rm(paths.socketFile, { force: true });
|
|
498
|
-
const server = net.createServer((socket) => {
|
|
499
|
-
socket.setEncoding("utf8");
|
|
500
|
-
let buffer = "";
|
|
501
|
-
const subscribedJobs = new Set();
|
|
502
|
-
const removeSocket = () => {
|
|
503
|
-
for (const jobId of subscribedJobs) subscribers.get(jobId)?.delete(socket);
|
|
504
|
-
};
|
|
505
|
-
socket.on("data", (chunk) => {
|
|
506
|
-
buffer += chunk;
|
|
507
|
-
if (Buffer.byteLength(buffer, "utf8") > ipcLimits.maxFrameBytes && !buffer.includes("\n")) {
|
|
508
|
-
socket.destroy();
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
let newlineIndex = buffer.indexOf("\n");
|
|
512
|
-
while (newlineIndex !== -1) {
|
|
513
|
-
const line = buffer.slice(0, newlineIndex).trim();
|
|
514
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
515
|
-
newlineIndex = buffer.indexOf("\n");
|
|
516
|
-
if (!line) continue;
|
|
517
|
-
let notification;
|
|
518
|
-
try {
|
|
519
|
-
notification = JSON.parse(line);
|
|
520
|
-
} catch {
|
|
521
|
-
socket.destroy();
|
|
522
|
-
return;
|
|
523
|
-
}
|
|
524
|
-
if (notification.version !== DAEMON_PROTOCOL_VERSION
|
|
525
|
-
|| notification.type !== "submit"
|
|
526
|
-
|| typeof notification.jobId !== "string"
|
|
527
|
-
|| notification.capabilityToken !== capabilityToken) {
|
|
528
|
-
socket.destroy();
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
const { jobId } = notification;
|
|
532
|
-
if (!subscribers.has(jobId)) subscribers.set(jobId, new Set());
|
|
533
|
-
subscribers.get(jobId).add(socket);
|
|
534
|
-
subscribedJobs.add(jobId);
|
|
535
|
-
readJson(jobPaths(paths, jobId).result, null).then((result) => {
|
|
536
|
-
if (result?.terminal) return writeSocketFrame(socket, result.terminal, ipcLimits);
|
|
537
|
-
return processQueue();
|
|
538
|
-
}).catch(() => socket.destroy());
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
socket.once("close", removeSocket);
|
|
542
|
-
socket.once("error", removeSocket);
|
|
543
|
-
});
|
|
544
|
-
await new Promise((resolve, reject) => {
|
|
545
|
-
server.once("error", reject);
|
|
546
|
-
server.listen(paths.socketFile, resolve);
|
|
547
|
-
});
|
|
548
|
-
if (process.platform !== "win32") await chmod(paths.socketFile, 0o600);
|
|
549
|
-
|
|
550
|
-
const heartbeatTimer = setInterval(() => {
|
|
551
|
-
writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
|
|
552
|
-
}, policy.heartbeatIntervalMs);
|
|
553
|
-
const idleTimer = idleTimeoutMs > 0 ? setInterval(async () => {
|
|
554
|
-
if (processing || exiting || Date.now() - lastActivity <= idleTimeoutMs) return;
|
|
555
|
-
exiting = true;
|
|
556
|
-
clearInterval(heartbeatTimer);
|
|
557
|
-
clearInterval(idleTimer);
|
|
558
|
-
await beforeExit?.();
|
|
559
|
-
await writeStatus({ state: "stopped", restartRequested: false, nextRestartAt: null, message: "Idle timeout reached" });
|
|
560
|
-
server.close(() => process.exit(0));
|
|
561
|
-
}, Math.min(idleTimeoutMs, 1_000)) : null;
|
|
562
|
-
|
|
563
|
-
processQueue().catch(() => {});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
89
|
return {
|
|
567
90
|
paths,
|
|
568
91
|
registration,
|
|
569
|
-
ensure,
|
|
570
|
-
getPid,
|
|
571
|
-
writeStatus,
|
|
92
|
+
ensure: worker.ensure,
|
|
93
|
+
getPid: worker.getPid,
|
|
94
|
+
writeStatus: worker.writeStatus,
|
|
572
95
|
start,
|
|
573
96
|
stop,
|
|
574
97
|
waitReady,
|
|
575
98
|
ensureReady,
|
|
576
99
|
submit,
|
|
577
|
-
workLoop
|
|
100
|
+
workLoop: worker.workLoop
|
|
578
101
|
};
|
|
579
102
|
}
|