arisa 5.1.68 → 5.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +28 -6
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +123 -8
  4. package/src/core/agent/agent-session-lifecycle.js +108 -8
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/core-tools.js +1 -1
  7. package/src/core/agent/pi-auth-login.js +1 -1
  8. package/src/core/agent/pi-capability-tools.js +7 -4
  9. package/src/core/agent/pi-runtime.js +1 -1
  10. package/src/core/agent/runtime-context.js +1 -1
  11. package/src/core/agent/session-history-reader.js +168 -0
  12. package/src/core/agent/session-preload-migration.js +162 -0
  13. package/src/core/agent/session-rotation.js +29 -0
  14. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/artifacts/artifact-store.js +1 -1
  17. package/src/core/capabilities/capability-service.js +4 -3
  18. package/src/core/config/config-defaults.js +59 -1
  19. package/src/core/config/config-store.js +1 -1
  20. package/src/core/conversation/session-seed-store.js +1 -1
  21. package/src/core/tasks/task-runner.js +8 -4
  22. package/src/core/tasks/task-store.js +48 -5
  23. package/src/core/tools/daemon-client.js +180 -0
  24. package/src/core/tools/daemon-processes.js +26 -3
  25. package/src/core/tools/daemon-protocol.js +72 -0
  26. package/src/core/tools/daemon-runtime.js +13 -490
  27. package/src/core/tools/daemon-worker.js +310 -0
  28. package/src/core/tools/ipc-client.js +2 -2
  29. package/src/core/tools/memory-pressure.js +56 -0
  30. package/src/core/tools/official-tool-installer.js +1 -1
  31. package/src/core/tools/tool-config.js +1 -1
  32. package/src/core/tools/tool-process-output.js +100 -0
  33. package/src/core/tools/tool-process-runner.js +175 -0
  34. package/src/core/tools/tool-registry.js +131 -191
  35. package/src/core/tools/tool-resource-note-store.js +1 -1
  36. package/src/core/tools/tool-usage-store.js +1 -1
  37. package/src/core/tools/weighted-resource-governor.js +189 -38
  38. package/src/index.js +14 -2
  39. package/src/official-tools.lock.json +526 -78
  40. package/src/platform/paths.js +152 -0
  41. package/src/runtime/bootstrap-cli.js +121 -0
  42. package/src/runtime/bootstrap-config.js +97 -0
  43. package/src/runtime/bootstrap-telegram.js +325 -0
  44. package/src/runtime/bootstrap.js +6 -543
  45. package/src/runtime/doctor.js +26 -76
  46. package/src/runtime/flush.js +1 -1
  47. package/src/runtime/ipc/ipc-server.js +1 -1
  48. package/src/runtime/log-viewer.js +1 -1
  49. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  50. package/src/runtime/oom-protection.js +20 -0
  51. package/src/runtime/paths.js +3 -151
  52. package/src/runtime/process-inspection.js +78 -0
  53. package/src/runtime/restart-receipt.js +1 -1
  54. package/src/runtime/service-manager.js +1 -1
  55. package/src/runtime/service-supervisor.js +14 -0
  56. package/src/runtime/slave-cli.js +1 -1
  57. package/src/runtime/tool-process-supervisor.js +36 -9
  58. package/src/runtime/tui.js +200 -0
  59. package/src/runtime/update-manager.js +1 -1
  60. package/src/runtime/worker-recovery-report.js +142 -0
  61. package/src/transport/telegram/bot.js +45 -321
  62. package/src/transport/telegram/chat-queue.js +6 -2
  63. package/src/transport/telegram/prompt-builders.js +8 -3
  64. package/src/transport/telegram/task-dispatcher.js +36 -11
  65. package/src/transport/telegram/telegram-prompt-controller.js +359 -0
  66. package/src/transport/telegram/workspace-topic-store.js +1 -1
  67. package/test/agent-session-lifecycle.test.js +92 -0
  68. package/test/agent-turn-coordinator.test.js +45 -0
  69. package/test/architecture-boundaries.test.js +29 -0
  70. package/test/bootstrap.test.js +65 -0
  71. package/test/context-and-task-bounds.test.js +2 -1
  72. package/test/daemon-process-invocation.test.js +27 -0
  73. package/test/daemon-runtime.test.js +38 -5
  74. package/test/doctor.test.js +41 -0
  75. package/test/memory-pressure.test.js +41 -0
  76. package/test/model-selection.test.js +11 -1
  77. package/test/obsolete-daemon-reaper.test.js +61 -0
  78. package/test/official-tool-dependencies.test.js +7 -2
  79. package/test/official-tool-installer.test.js +18 -1
  80. package/test/oom-protection.test.js +32 -0
  81. package/test/paths.test.js +7 -0
  82. package/test/pi-compaction.test.js +30 -0
  83. package/test/service-manager.test.js +6 -1
  84. package/test/session-history-reader.test.js +84 -0
  85. package/test/session-preload-migration.test.js +120 -0
  86. package/test/session-rotation.test.js +110 -0
  87. package/test/task-store.test.js +32 -0
  88. package/test/telegram-prompt-controller.test.js +82 -0
  89. package/test/telegram-task-dispatcher.test.js +66 -5
  90. package/test/telegram-text-artifact.test.js +30 -0
  91. package/test/tool-registry-run.test.js +117 -4
  92. package/test/tui.test.js +41 -0
  93. package/test/weighted-resource-governor.test.js +125 -5
  94. package/test/worker-heap-circuit-breaker.test.js +79 -0
  95. package/test/worker-recovery-report.test.js +69 -0
  96. package/test/worker-tool-fanout.test.js +79 -0
  97. package/test-fixtures/fake-daemon.js +5 -0
@@ -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
- capacities: Object.freeze({ orchestrator: 1 })
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 "../../runtime/paths.js";
3
+ import { configFile } from "../../platform/paths.js";
4
4
  import { applyConfigDefaults } from "./config-defaults.js";
5
5
 
6
6
  export function prepareConfigForSave(config) {
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import {
4
4
  getChatLegacyConversationHistoryFile,
5
5
  getChatSessionSeedFile
6
- } from "../../runtime/paths.js";
6
+ } from "../../platform/paths.js";
7
7
 
8
8
  function parseRecords(contents) {
9
9
  return String(contents || "")
@@ -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 retryOptions = { retryable: error?.retryable !== false };
41
- if (error?.outcomeUncertain === true) retryOptions.outcomeUncertain = true;
42
- const updated = await taskStore.retryOrFail(task.id, error, retryOptions);
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 "../../runtime/paths.js";
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 === "pending"
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
- task.runAt = new Date(now + retryDelayMs(task)).toISOString();
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 "../../runtime/paths.js";
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
- child = spawn(process.execPath, [entryPath, "daemon"], {
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
+ }