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
|
@@ -19,7 +19,17 @@ export const daemonConfigDefaults = Object.freeze({
|
|
|
19
19
|
export const toolExecutionConfigDefaults = Object.freeze({
|
|
20
20
|
defaultCapacity: 2,
|
|
21
21
|
maxQueuedPerClass: 100,
|
|
22
|
-
|
|
22
|
+
maxWorkerRssMb: 384,
|
|
23
|
+
maxSwapUsedPercent: 95,
|
|
24
|
+
initialToolMemoryMb: 384,
|
|
25
|
+
minimumToolMemoryMb: 128,
|
|
26
|
+
maximumToolMemoryMb: 4096,
|
|
27
|
+
systemReserveMb: 128,
|
|
28
|
+
coreReserveMb: 384,
|
|
29
|
+
toolHeapPercent: 65,
|
|
30
|
+
toolMemoryHighPercent: 85,
|
|
31
|
+
toolSwapMaxMb: 128,
|
|
32
|
+
capacities: Object.freeze({ browser: 1, orchestrator: 1 })
|
|
23
33
|
});
|
|
24
34
|
|
|
25
35
|
export const telegramConfigDefaults = Object.freeze({
|
|
@@ -58,6 +68,34 @@ export const taskConfigDefaults = Object.freeze({
|
|
|
58
68
|
export const piConfigDefaults = Object.freeze({
|
|
59
69
|
thinkingLevel: "medium",
|
|
60
70
|
speed: 1,
|
|
71
|
+
sessionCache: Object.freeze({
|
|
72
|
+
maxSessions: 3,
|
|
73
|
+
maxPersistedBytes: 48 * 1024 * 1024
|
|
74
|
+
}),
|
|
75
|
+
sessionRotation: Object.freeze({
|
|
76
|
+
enabled: true,
|
|
77
|
+
compactAtPersistedBytes: 24 * 1024 * 1024,
|
|
78
|
+
maxPersistedBytes: 32 * 1024 * 1024
|
|
79
|
+
}),
|
|
80
|
+
heapCircuitBreaker: Object.freeze({
|
|
81
|
+
enabled: true,
|
|
82
|
+
softPercent: 70,
|
|
83
|
+
criticalPercent: 82,
|
|
84
|
+
waitMs: 15_000,
|
|
85
|
+
pollMs: 500
|
|
86
|
+
}),
|
|
87
|
+
toolFanout: Object.freeze({
|
|
88
|
+
enabled: true,
|
|
89
|
+
maxConcurrent: 2,
|
|
90
|
+
pressureConcurrent: 1,
|
|
91
|
+
serializePercent: 60
|
|
92
|
+
}),
|
|
93
|
+
turnCoordinator: Object.freeze({
|
|
94
|
+
enabled: true,
|
|
95
|
+
backgroundQueueTtlMs: 10 * 60_000,
|
|
96
|
+
interactiveQueueTtlMs: 0,
|
|
97
|
+
maxQueued: 100
|
|
98
|
+
}),
|
|
61
99
|
compaction: Object.freeze({
|
|
62
100
|
enabled: true,
|
|
63
101
|
reserveTokens: 120_000,
|
|
@@ -108,6 +146,26 @@ export function applyConfigDefaults(config) {
|
|
|
108
146
|
pi: {
|
|
109
147
|
...piConfigDefaults,
|
|
110
148
|
...configuredPi,
|
|
149
|
+
sessionCache: {
|
|
150
|
+
...piConfigDefaults.sessionCache,
|
|
151
|
+
...(configuredPi.sessionCache || {})
|
|
152
|
+
},
|
|
153
|
+
sessionRotation: {
|
|
154
|
+
...piConfigDefaults.sessionRotation,
|
|
155
|
+
...(configuredPi.sessionRotation || {})
|
|
156
|
+
},
|
|
157
|
+
heapCircuitBreaker: {
|
|
158
|
+
...piConfigDefaults.heapCircuitBreaker,
|
|
159
|
+
...(configuredPi.heapCircuitBreaker || {})
|
|
160
|
+
},
|
|
161
|
+
toolFanout: {
|
|
162
|
+
...piConfigDefaults.toolFanout,
|
|
163
|
+
...(configuredPi.toolFanout || {})
|
|
164
|
+
},
|
|
165
|
+
turnCoordinator: {
|
|
166
|
+
...piConfigDefaults.turnCoordinator,
|
|
167
|
+
...(configuredPi.turnCoordinator || {})
|
|
168
|
+
},
|
|
111
169
|
compaction: {
|
|
112
170
|
...piConfigDefaults.compaction,
|
|
113
171
|
...(configuredPi.compaction || {})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { configFile } from "../../
|
|
3
|
+
import { configFile } from "../../platform/paths.js";
|
|
4
4
|
import { applyConfigDefaults } from "./config-defaults.js";
|
|
5
5
|
|
|
6
6
|
export function prepareConfigForSave(config) {
|
|
@@ -18,7 +18,8 @@ export function createTaskRunner({ taskStore, dispatch, laneKey = (task) => task
|
|
|
18
18
|
async function reportTerminalFailure(task, updated, error) {
|
|
19
19
|
const isTerminal = updated?.status === "failed"
|
|
20
20
|
|| updated?.status === "outcome_uncertain"
|
|
21
|
-
|| updated?.terminalFailure === true
|
|
21
|
+
|| updated?.terminalFailure === true
|
|
22
|
+
|| updated?.authBlockedNew === true;
|
|
22
23
|
if (!isTerminal || typeof onTerminalFailure !== "function") return;
|
|
23
24
|
try {
|
|
24
25
|
await onTerminalFailure({ task, result: updated, error });
|
|
@@ -37,9 +38,12 @@ export function createTaskRunner({ taskStore, dispatch, laneKey = (task) => task
|
|
|
37
38
|
logger?.log("tasks", `task ${task.id} completed after confirmed execution`);
|
|
38
39
|
return { taskId: task.id, status: "completed" };
|
|
39
40
|
} catch (error) {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const updated = error?.authBlocked === true
|
|
42
|
+
? await taskStore.blockAuth(task.id, error, error.authResolution)
|
|
43
|
+
: await taskStore.retryOrFail(task.id, error, {
|
|
44
|
+
retryable: error?.retryable !== false,
|
|
45
|
+
...(error?.outcomeUncertain === true ? { outcomeUncertain: true } : {})
|
|
46
|
+
});
|
|
43
47
|
const status = updated?.status || "missing";
|
|
44
48
|
logger?.log("tasks", `task ${task.id} ${status}: ${errorMessage(error)}`);
|
|
45
49
|
await reportTerminalFailure(task, updated, error);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
|
-
import { tasksFile } from "../../
|
|
4
|
+
import { tasksFile } from "../../platform/paths.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_RETRY = Object.freeze({
|
|
7
7
|
maxAttempts: 3,
|
|
@@ -170,6 +170,16 @@ function retryDelayMs(task) {
|
|
|
170
170
|
return Math.max(1, Math.round(seconds * 1000));
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
function normalizeAuthResolution(resolution = {}) {
|
|
174
|
+
const value = resolution && typeof resolution === "object" && !Array.isArray(resolution) ? resolution : {};
|
|
175
|
+
const retryAfterSeconds = Math.max(300, boundedPositiveNumber(value.retryAfterSeconds, 3_600, 86_400));
|
|
176
|
+
const probeArgs = value.probeArgs && typeof value.probeArgs === "object" && !Array.isArray(value.probeArgs)
|
|
177
|
+
? structuredClone(value.probeArgs)
|
|
178
|
+
: {};
|
|
179
|
+
if (JSON.stringify(probeArgs).length > 4_096) throw new Error("Authentication probe arguments are too large");
|
|
180
|
+
return { retryAfterSeconds, probeArgs };
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
function failTask(task, error) {
|
|
174
184
|
const failedAt = new Date().toISOString();
|
|
175
185
|
task.status = "failed";
|
|
@@ -232,7 +242,7 @@ export class TaskStore {
|
|
|
232
242
|
return this.mutate(async (tasks) => {
|
|
233
243
|
const now = Date.now();
|
|
234
244
|
const due = tasks
|
|
235
|
-
.filter((task) => task.status
|
|
245
|
+
.filter((task) => ["pending", "blocked_auth"].includes(task.status)
|
|
236
246
|
&& task.runAt
|
|
237
247
|
&& !Number.isNaN(Date.parse(task.runAt))
|
|
238
248
|
&& Date.parse(task.runAt) <= now)
|
|
@@ -275,7 +285,7 @@ export class TaskStore {
|
|
|
275
285
|
if (task.status === "running") {
|
|
276
286
|
const interruptedAt = new Date(now).toISOString();
|
|
277
287
|
if (task.claimedAt && !task.executionStartedAt) {
|
|
278
|
-
task.status = "pending";
|
|
288
|
+
task.status = task.authBlock ? "blocked_auth" : "pending";
|
|
279
289
|
task.attempts = Math.max(0, Number(task.attempts || 0) - 1);
|
|
280
290
|
task.lastError = "execution interrupted before start";
|
|
281
291
|
task.lastFailedAt = interruptedAt;
|
|
@@ -294,8 +304,11 @@ export class TaskStore {
|
|
|
294
304
|
delete task.claimedAt;
|
|
295
305
|
delete task.executionStartedAt;
|
|
296
306
|
if (task.kind === "poll_tool") {
|
|
297
|
-
task.status = "pending";
|
|
298
|
-
|
|
307
|
+
task.status = task.authBlock ? "blocked_auth" : "pending";
|
|
308
|
+
const delayMs = task.authBlock
|
|
309
|
+
? Number(task.authBlock.retryAfterSeconds || 3_600) * 1_000
|
|
310
|
+
: retryDelayMs(task);
|
|
311
|
+
task.runAt = new Date(now + delayMs).toISOString();
|
|
299
312
|
delete task.startedAt;
|
|
300
313
|
} else {
|
|
301
314
|
const nextRunAt = computeNextRunAt(task, now);
|
|
@@ -337,6 +350,7 @@ export class TaskStore {
|
|
|
337
350
|
delete task.error;
|
|
338
351
|
delete task.lastOutcome;
|
|
339
352
|
delete task.consecutiveFailures;
|
|
353
|
+
delete task.authBlock;
|
|
340
354
|
delete task.claimedAt;
|
|
341
355
|
delete task.executionStartedAt;
|
|
342
356
|
if (nextRunAt) {
|
|
@@ -354,6 +368,35 @@ export class TaskStore {
|
|
|
354
368
|
});
|
|
355
369
|
}
|
|
356
370
|
|
|
371
|
+
async blockAuth(taskId, error, resolution = {}) {
|
|
372
|
+
return this.mutate(async (tasks) => {
|
|
373
|
+
const task = tasks.find((item) => item.id === taskId);
|
|
374
|
+
if (!task) return { result: null, changed: false };
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
const checkedAt = new Date(now).toISOString();
|
|
377
|
+
const normalized = normalizeAuthResolution(resolution);
|
|
378
|
+
const wasBlocked = Boolean(task.authBlock);
|
|
379
|
+
task.status = "blocked_auth";
|
|
380
|
+
task.runAt = new Date(now + (normalized.retryAfterSeconds * 1_000)).toISOString();
|
|
381
|
+
task.attempts = 0;
|
|
382
|
+
task.authBlock = {
|
|
383
|
+
firstBlockedAt: task.authBlock?.firstBlockedAt || checkedAt,
|
|
384
|
+
lastCheckedAt: checkedAt,
|
|
385
|
+
...normalized
|
|
386
|
+
};
|
|
387
|
+
task.lastError = error instanceof Error ? error.message : String(error);
|
|
388
|
+
task.lastFailedAt = checkedAt;
|
|
389
|
+
task.updatedAt = checkedAt;
|
|
390
|
+
if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
|
|
391
|
+
if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
|
|
392
|
+
delete task.startedAt;
|
|
393
|
+
delete task.claimedAt;
|
|
394
|
+
delete task.executionStartedAt;
|
|
395
|
+
delete task.error;
|
|
396
|
+
return { result: { ...structuredClone(task), authBlockedNew: !wasBlocked } };
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
357
400
|
async retryOrFail(taskId, error, { retryable = true, outcomeUncertain = false } = {}) {
|
|
358
401
|
return this.mutate(async (tasks) => {
|
|
359
402
|
const task = tasks.find((item) => item.id === taskId);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
4
|
+
import { daemonPaths, readJson, writeJson } from "./daemon-processes.js";
|
|
5
|
+
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
6
|
+
import {
|
|
7
|
+
DAEMON_CONTROL_FIELD,
|
|
8
|
+
DAEMON_PROTOCOL_VERSION,
|
|
9
|
+
daemonJobPaths,
|
|
10
|
+
daemonTerminalResult,
|
|
11
|
+
validateDaemonEvent
|
|
12
|
+
} from "./daemon-protocol.js";
|
|
13
|
+
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readCapability(paths) {
|
|
19
|
+
const token = (await readFile(paths.capabilityFile, "utf8")).trim();
|
|
20
|
+
if (!token) throw new Error(`Invalid daemon capability for ${paths.toolName}`);
|
|
21
|
+
return token;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function connectDaemon(paths, request, { timeoutMs, onEvent, maxFrameBytes }) {
|
|
25
|
+
const startedAt = Date.now();
|
|
26
|
+
const token = await readCapability(paths);
|
|
27
|
+
let lastError;
|
|
28
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
29
|
+
try {
|
|
30
|
+
return await new Promise((resolve, reject) => {
|
|
31
|
+
const socket = net.createConnection(paths.socketFile);
|
|
32
|
+
let buffer = "";
|
|
33
|
+
let sequence = 0;
|
|
34
|
+
let terminalSeen = false;
|
|
35
|
+
let settled = false;
|
|
36
|
+
let submitted = false;
|
|
37
|
+
let timeoutTriggered = false;
|
|
38
|
+
let observerChain = Promise.resolve();
|
|
39
|
+
const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt));
|
|
40
|
+
let cancelTimer;
|
|
41
|
+
const timeoutError = Object.assign(
|
|
42
|
+
new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`),
|
|
43
|
+
{ code: "DAEMON_JOB_TIMEOUT" }
|
|
44
|
+
);
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
timeoutTriggered = true;
|
|
47
|
+
if (submitted && !socket.destroyed && socket.writable) {
|
|
48
|
+
const frame = `${JSON.stringify({
|
|
49
|
+
version: DAEMON_PROTOCOL_VERSION,
|
|
50
|
+
type: "cancel",
|
|
51
|
+
jobId: request.jobId,
|
|
52
|
+
capabilityToken: token
|
|
53
|
+
})}\n`;
|
|
54
|
+
socket.end(frame, () => finish(reject, timeoutError));
|
|
55
|
+
cancelTimer = setTimeout(() => finish(reject, timeoutError), 50);
|
|
56
|
+
cancelTimer.unref?.();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
finish(reject, timeoutError);
|
|
60
|
+
}, remainingMs);
|
|
61
|
+
timer.unref?.();
|
|
62
|
+
|
|
63
|
+
function finish(fn, value) {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
clearTimeout(cancelTimer);
|
|
68
|
+
socket.destroy();
|
|
69
|
+
fn(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
socket.setEncoding("utf8");
|
|
73
|
+
socket.once("connect", () => {
|
|
74
|
+
if (timeoutTriggered) return;
|
|
75
|
+
submitted = true;
|
|
76
|
+
socket.write(`${JSON.stringify({
|
|
77
|
+
version: DAEMON_PROTOCOL_VERSION,
|
|
78
|
+
type: "submit",
|
|
79
|
+
jobId: request.jobId,
|
|
80
|
+
capabilityToken: token
|
|
81
|
+
})}\n`);
|
|
82
|
+
});
|
|
83
|
+
socket.on("data", (chunk) => {
|
|
84
|
+
buffer += chunk;
|
|
85
|
+
if (Buffer.byteLength(buffer, "utf8") > maxFrameBytes && !buffer.includes("\n")) {
|
|
86
|
+
finish(reject, new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
90
|
+
while (newlineIndex !== -1) {
|
|
91
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
92
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
93
|
+
newlineIndex = buffer.indexOf("\n");
|
|
94
|
+
if (!line) continue;
|
|
95
|
+
let frame;
|
|
96
|
+
try {
|
|
97
|
+
frame = JSON.parse(line);
|
|
98
|
+
const validated = validateDaemonEvent(frame, {
|
|
99
|
+
jobId: request.jobId,
|
|
100
|
+
previousSequence: sequence,
|
|
101
|
+
terminalSeen
|
|
102
|
+
});
|
|
103
|
+
sequence = validated.sequence;
|
|
104
|
+
terminalSeen = validated.terminal;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
finish(reject, error);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
observerChain = observerChain.then(() => onEvent?.(frame));
|
|
110
|
+
if (terminalSeen) {
|
|
111
|
+
observerChain.then(() => {
|
|
112
|
+
if (timeoutTriggered) {
|
|
113
|
+
finish(reject, timeoutError);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
finish(resolve, daemonTerminalResult(frame));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
finish(reject, error);
|
|
120
|
+
}
|
|
121
|
+
}, (error) => finish(reject, timeoutTriggered ? timeoutError : error));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
socket.once("error", (error) => finish(reject, error));
|
|
126
|
+
socket.once("close", () => {
|
|
127
|
+
if (!settled) finish(reject, timeoutTriggered
|
|
128
|
+
? timeoutError
|
|
129
|
+
: new Error("Daemon IPC connection closed before terminal result"));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
lastError = error;
|
|
134
|
+
if (!["ENOENT", "ECONNREFUSED"].includes(error?.code)) throw error;
|
|
135
|
+
await sleep(Math.min(25, Math.max(1, timeoutMs - (Date.now() - startedAt))));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const error = new Error(`${paths.toolName} daemon IPC was unavailable after ${timeoutMs}ms`);
|
|
139
|
+
error.code = lastError?.code || "DAEMON_IPC_UNAVAILABLE";
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function enqueueDaemonJob(paths, payload, {
|
|
144
|
+
control = false,
|
|
145
|
+
timeoutMs,
|
|
146
|
+
onEvent,
|
|
147
|
+
jobId,
|
|
148
|
+
maxFrameBytes
|
|
149
|
+
} = {}) {
|
|
150
|
+
await mkdir(paths.commandsDir, { recursive: true });
|
|
151
|
+
const id = jobId || `${control ? "control" : "job"}-${crypto.randomUUID()}`;
|
|
152
|
+
const files = daemonJobPaths(paths, id);
|
|
153
|
+
const existingResult = await readJson(files.result, null);
|
|
154
|
+
if (existingResult?.terminal) {
|
|
155
|
+
await onEvent?.(existingResult.terminal);
|
|
156
|
+
return daemonTerminalResult(existingResult.terminal);
|
|
157
|
+
}
|
|
158
|
+
const existingRequest = await readJson(files.request, null);
|
|
159
|
+
const existingAccepted = await readJson(files.processing, null);
|
|
160
|
+
if (!existingRequest && !existingAccepted) {
|
|
161
|
+
await writeJson(files.request, {
|
|
162
|
+
id,
|
|
163
|
+
status: "queued",
|
|
164
|
+
queuedAt: new Date().toISOString(),
|
|
165
|
+
payload
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return connectDaemon(paths, { jobId: id }, { timeoutMs, onEvent, maxFrameBytes });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function submitDaemonControl(record, operation, { timeoutMs, onEvent } = {}) {
|
|
172
|
+
const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
|
|
173
|
+
const policy = await loadDaemonPolicy();
|
|
174
|
+
return enqueueDaemonJob(paths, { [DAEMON_CONTROL_FIELD]: { operation } }, {
|
|
175
|
+
control: true,
|
|
176
|
+
timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
|
|
177
|
+
onEvent,
|
|
178
|
+
maxFrameBytes: policy.ipcFrameBytes || 1_048_576
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
-
import { closeSync, openSync } from "node:fs";
|
|
3
|
+
import { closeSync, existsSync, openSync } from "node:fs";
|
|
4
4
|
import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
getDaemonInstanceId,
|
|
10
10
|
normalizeDaemonScope,
|
|
11
11
|
toolStateDir
|
|
12
|
-
} from "../../
|
|
12
|
+
} from "../../platform/paths.js";
|
|
13
13
|
import { loadDaemonPolicy } from "./daemon-policy.js";
|
|
14
14
|
|
|
15
15
|
export const DAEMON_STATES = Object.freeze([
|
|
@@ -22,6 +22,21 @@ export const DAEMON_STATES = Object.freeze([
|
|
|
22
22
|
"failed"
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
export function daemonProcessInvocation(entryPath, {
|
|
26
|
+
platform = process.platform,
|
|
27
|
+
nodePath = process.execPath,
|
|
28
|
+
oomAdjustAvailable = existsSync("/usr/bin/choom")
|
|
29
|
+
} = {}) {
|
|
30
|
+
if (platform !== "linux" || !oomAdjustAvailable) {
|
|
31
|
+
return { command: nodePath, args: [entryPath, "daemon"], oomProtected: false };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
command: "/usr/bin/choom",
|
|
35
|
+
args: ["-n", "500", "--", nodePath, entryPath, "daemon"],
|
|
36
|
+
oomProtected: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
function daemonIdentity(toolNameOrOptions, scope) {
|
|
26
41
|
if (typeof toolNameOrOptions === "string") {
|
|
27
42
|
return { toolName: toolNameOrOptions, scope: normalizeDaemonScope(scope) };
|
|
@@ -265,6 +280,13 @@ export async function unregisterManagedDaemon(toolNameOrOptions, { scope } = {})
|
|
|
265
280
|
return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
|
|
266
281
|
}
|
|
267
282
|
|
|
283
|
+
export async function purgeManagedDaemon(toolNameOrOptions, { scope } = {}) {
|
|
284
|
+
const paths = daemonPaths(toolNameOrOptions, scope);
|
|
285
|
+
await unregisterManagedDaemon(toolNameOrOptions, { scope });
|
|
286
|
+
await rm(paths.root, { recursive: true, force: true });
|
|
287
|
+
return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
|
|
288
|
+
}
|
|
289
|
+
|
|
268
290
|
export async function stopManagedDaemon(toolNameOrOptions, {
|
|
269
291
|
scope,
|
|
270
292
|
signal = "SIGTERM",
|
|
@@ -367,7 +389,8 @@ export async function startManagedDaemon({
|
|
|
367
389
|
const out = openSync(paths.logFile, "a");
|
|
368
390
|
let child;
|
|
369
391
|
try {
|
|
370
|
-
|
|
392
|
+
const invocation = daemonProcessInvocation(entryPath);
|
|
393
|
+
child = spawn(invocation.command, invocation.args, {
|
|
371
394
|
detached: false,
|
|
372
395
|
stdio: ["ignore", out, out],
|
|
373
396
|
env: {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const DAEMON_PROTOCOL_VERSION = 1;
|
|
4
|
+
export const DAEMON_EVENT_TYPES = Object.freeze(["accepted", "progress", "chunk", "completed", "failed"]);
|
|
5
|
+
export const DAEMON_CONTROL_FIELD = "__daemon";
|
|
6
|
+
|
|
7
|
+
const TERMINAL_EVENT_TYPES = new Set(["completed", "failed"]);
|
|
8
|
+
|
|
9
|
+
export function daemonJobPaths(paths, id) {
|
|
10
|
+
return {
|
|
11
|
+
request: path.join(paths.commandsDir, `${id}.request.json`),
|
|
12
|
+
processing: path.join(paths.commandsDir, `${id}.processing.json`),
|
|
13
|
+
result: path.join(paths.commandsDir, `${id}.result.json`)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function daemonFrame(jobId, type, sequence, payload = {}) {
|
|
18
|
+
return { version: DAEMON_PROTOCOL_VERSION, jobId, type, sequence, payload };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function daemonTerminalResult(frame) {
|
|
22
|
+
if (frame.type === "failed") {
|
|
23
|
+
const error = new Error(frame.payload?.error || "Daemon job failed");
|
|
24
|
+
if (frame.payload?.code) error.code = frame.payload.code;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
return frame.payload?.output || {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateDaemonEvent(frame, { jobId, previousSequence = 0, terminalSeen = false } = {}) {
|
|
31
|
+
if (!frame || frame.version !== DAEMON_PROTOCOL_VERSION || frame.jobId !== jobId) {
|
|
32
|
+
throw new Error("Invalid daemon event identity or protocol version");
|
|
33
|
+
}
|
|
34
|
+
if (!DAEMON_EVENT_TYPES.includes(frame.type)) throw new Error(`Invalid daemon event type: ${frame.type}`);
|
|
35
|
+
if (!Number.isSafeInteger(frame.sequence) || frame.sequence <= previousSequence) {
|
|
36
|
+
throw new Error(`Invalid daemon event sequence for ${jobId}: ${frame.sequence}`);
|
|
37
|
+
}
|
|
38
|
+
if (terminalSeen) throw new Error(`Daemon job ${jobId} emitted more than one terminal event`);
|
|
39
|
+
return { sequence: frame.sequence, terminal: TERMINAL_EVENT_TYPES.has(frame.type) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function writeDaemonSocketFrame(socket, frame, {
|
|
43
|
+
maxFrameBytes = 1_048_576,
|
|
44
|
+
streamBufferBytes = 1_048_576
|
|
45
|
+
} = {}) {
|
|
46
|
+
if (socket.destroyed || !socket.writable) return false;
|
|
47
|
+
const encoded = `${JSON.stringify(frame)}\n`;
|
|
48
|
+
if (Buffer.byteLength(encoded, "utf8") > maxFrameBytes) {
|
|
49
|
+
throw new Error(`Daemon IPC frame exceeds ${maxFrameBytes} bytes`);
|
|
50
|
+
}
|
|
51
|
+
if (socket.writableLength >= streamBufferBytes) {
|
|
52
|
+
await waitForWritableSocket(socket);
|
|
53
|
+
if (socket.destroyed || !socket.writable) return false;
|
|
54
|
+
}
|
|
55
|
+
if (socket.write(encoded)) return true;
|
|
56
|
+
await waitForWritableSocket(socket);
|
|
57
|
+
return !socket.destroyed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function waitForWritableSocket(socket) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const finish = () => {
|
|
63
|
+
socket.off("drain", finish);
|
|
64
|
+
socket.off("close", finish);
|
|
65
|
+
socket.off("error", finish);
|
|
66
|
+
resolve();
|
|
67
|
+
};
|
|
68
|
+
socket.once("drain", finish);
|
|
69
|
+
socket.once("close", finish);
|
|
70
|
+
socket.once("error", finish);
|
|
71
|
+
});
|
|
72
|
+
}
|