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
@@ -1,86 +1,21 @@
1
- import { execFile } from "node:child_process";
2
1
  import { statfs } from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import process from "node:process";
5
- import { promisify } from "node:util";
6
4
  import { stopManagedDaemon, unregisterManagedDaemon } from "../core/tools/daemon-processes.js";
7
5
  import { getServiceStatus, serviceEntryFile } from "./service-manager.js";
8
- import { arisaHomeDir } from "./paths.js";
6
+ import { arisaHomeDir } from "../platform/paths.js";
9
7
  import { renderTextReport, reportRow, wrapReportText } from "./report-format.js";
8
+ import {
9
+ isArisaServiceProcess as matchesArisaServiceProcess,
10
+ isDaemonProcess,
11
+ listSystemProcesses,
12
+ terminateProcess
13
+ } from "./process-inspection.js";
10
14
 
11
- const execFileAsync = promisify(execFile);
12
-
13
- function parsePosixProcesses(output) {
14
- return String(output || "")
15
- .split("\n")
16
- .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
17
- .filter(Boolean)
18
- .map((match) => ({ pid: Number(match[1]), command: match[2] }));
19
- }
20
-
21
- function parseWindowsProcesses(output) {
22
- const parsed = JSON.parse(output || "[]");
23
- const records = Array.isArray(parsed) ? parsed : [parsed];
24
- return records
25
- .filter((record) => record?.ProcessId && record?.CommandLine)
26
- .map((record) => ({ pid: Number(record.ProcessId), command: String(record.CommandLine) }));
27
- }
28
-
29
- export async function listSystemProcesses({ timeoutMs, platform = process.platform } = {}) {
30
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
31
- throw new Error("Doctor process inspection requires a positive timeoutMs");
32
- }
33
- if (platform === "win32") {
34
- const { stdout } = await execFileAsync("powershell.exe", [
35
- "-NoProfile",
36
- "-Command",
37
- "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
38
- ], { timeout: timeoutMs, windowsHide: true });
39
- return parseWindowsProcesses(stdout);
40
- }
41
- const { stdout } = await execFileAsync("ps", ["-ww", "-axo", "pid=,command="], { timeout: timeoutMs });
42
- return parsePosixProcesses(stdout);
43
- }
15
+ export { listSystemProcesses, terminateProcess } from "./process-inspection.js";
44
16
 
45
17
  function isArisaServiceProcess(record) {
46
- return record.command.includes(serviceEntryFile)
47
- && /(?:^|\s)--service-runner(?:\s|$)/.test(record.command);
48
- }
49
-
50
- function isDaemonProcess(record, daemon) {
51
- return record.command.includes(daemon.entryPath)
52
- && /(?:^|\s)daemon(?:\s|$)/.test(record.command);
53
- }
54
-
55
- function sleep(ms) {
56
- return new Promise((resolve) => setTimeout(resolve, ms));
57
- }
58
-
59
- function isAlive(pid) {
60
- try {
61
- process.kill(pid, 0);
62
- return true;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
- export async function terminateProcess(pid, { forceAfterMs } = {}) {
69
- if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
70
- throw new Error(`Refusing to terminate invalid doctor target: ${pid}`);
71
- }
72
- if (!Number.isFinite(forceAfterMs) || forceAfterMs <= 0) {
73
- throw new Error("Doctor process cleanup requires a positive forceAfterMs");
74
- }
75
- if (!isAlive(pid)) return false;
76
- process.kill(pid, "SIGTERM");
77
- const startedAt = Date.now();
78
- while (Date.now() - startedAt < forceAfterMs) {
79
- if (!isAlive(pid)) return true;
80
- await sleep(Math.min(100, forceAfterMs));
81
- }
82
- if (isAlive(pid)) process.kill(pid, "SIGKILL");
83
- return true;
18
+ return matchesArisaServiceProcess(record, serviceEntryFile);
84
19
  }
85
20
 
86
21
  function daemonLabel(record) {
@@ -314,7 +249,8 @@ export async function runDoctor({
314
249
  unregisterDaemon = unregisterManagedDaemon,
315
250
  inspectResources = inspectSystemResources,
316
251
  inspectInfrastructure = null,
317
- inspectToolDependencies = null
252
+ inspectToolDependencies = null,
253
+ supervisorPid = Number.parseInt(process.env.ARISA_SUPERVISOR_PID || "", 10)
318
254
  }) {
319
255
  assertDoctorPolicy(doctorPolicy);
320
256
  const runtime = await agentManager.getRuntimeDiagnostic();
@@ -362,7 +298,9 @@ export async function runDoctor({
362
298
 
363
299
  const processByPid = new Map(processes.map((record) => [record.pid, record]));
364
300
  const currentService = await serviceStatus();
365
- if (currentService.running && currentService.pid !== process.pid) {
301
+ const belongsToCurrentService = currentService.pid === process.pid
302
+ || (Number.isSafeInteger(supervisorPid) && currentService.pid === supervisorPid);
303
+ if (currentService.running && !belongsToCurrentService) {
366
304
  const registered = processByPid.get(currentService.pid);
367
305
  if (registered && isArisaServiceProcess(registered)) {
368
306
  try {
@@ -383,6 +321,18 @@ export async function runDoctor({
383
321
  }
384
322
  for (const result of report.daemons) {
385
323
  const label = daemonLabel(result.record);
324
+ if (result.outcome === "obsolete-removed") {
325
+ report.repairs.push(`Removed obsolete daemon ${label}: ${result.reason}.`);
326
+ continue;
327
+ }
328
+ if (result.outcome === "obsolete-unverified") {
329
+ report.attention.push(`${label} is obsolete, but its live process identity could not be verified.`);
330
+ continue;
331
+ }
332
+ if (result.outcome === "obsolete-cleanup-error") {
333
+ report.attention.push(`${label} obsolete daemon cleanup failed: ${result.error || "unknown error"}.`);
334
+ continue;
335
+ }
386
336
  if (result.outcome === "stale-registration") {
387
337
  const pid = result.diagnostic?.pid;
388
338
  const registered = pid ? processByPid.get(pid) : null;
@@ -1,5 +1,5 @@
1
1
  import { rm } from "node:fs/promises";
2
- import { arisaHomeDir } from "./paths.js";
2
+ import { arisaHomeDir } from "../platform/paths.js";
3
3
 
4
4
  export async function flushArisaHome() {
5
5
  await rm(arisaHomeDir, { recursive: true, force: true });
@@ -1,7 +1,7 @@
1
1
  import net from "node:net";
2
2
  import path from "node:path";
3
3
  import { chmod, mkdir, unlink } from "node:fs/promises";
4
- import { arisaIpcSocketFile } from "../paths.js";
4
+ import { arisaIpcSocketFile } from "../../platform/paths.js";
5
5
 
6
6
  function writeResponse(socket, response) {
7
7
  socket.write(`${JSON.stringify(response)}\n`);
@@ -1,6 +1,6 @@
1
1
  import { open, readFile, stat } from "node:fs/promises";
2
2
  import { cliLogConfig } from "../core/config/config-defaults.js";
3
- import { ensureArisaHome, serviceLogFile } from "./paths.js";
3
+ import { ensureArisaHome, serviceLogFile } from "../platform/paths.js";
4
4
 
5
5
  const readChunkSize = 64 * 1024;
6
6
 
@@ -0,0 +1,43 @@
1
+ import { purgeManagedDaemon } from "../core/tools/daemon-processes.js";
2
+ import { isDaemonProcess, listSystemProcesses, terminateProcess } from "./process-inspection.js";
3
+
4
+ export async function reapObsoleteDaemon({
5
+ record,
6
+ diagnostic,
7
+ reason,
8
+ timeoutMs,
9
+ stopTimeoutMs,
10
+ inspectProcesses = listSystemProcesses,
11
+ stopProcess = terminateProcess,
12
+ purgeDaemon = purgeManagedDaemon
13
+ } = {}) {
14
+ const pid = diagnostic?.pid;
15
+ if (pid) {
16
+ let processes;
17
+ try {
18
+ processes = await inspectProcesses({ timeoutMs });
19
+ } catch (error) {
20
+ return {
21
+ record,
22
+ outcome: "obsolete-unverified",
23
+ reason,
24
+ diagnostic,
25
+ error: `process inspection failed: ${error instanceof Error ? error.message : String(error)}`
26
+ };
27
+ }
28
+ const processRecord = processes.find((item) => item.pid === pid);
29
+ if (!processRecord || !isDaemonProcess(processRecord, record)) {
30
+ return { record, outcome: "obsolete-unverified", reason, diagnostic };
31
+ }
32
+ await stopProcess(pid, { forceAfterMs: stopTimeoutMs });
33
+ }
34
+
35
+ await purgeDaemon({ toolName: record.toolName, scope: record.scope });
36
+ return {
37
+ record,
38
+ outcome: "obsolete-removed",
39
+ reason,
40
+ diagnostic,
41
+ stoppedPid: pid || null
42
+ };
43
+ }
@@ -0,0 +1,20 @@
1
+ import { writeFile } from "node:fs/promises";
2
+
3
+ const defaultCoreOomScoreAdjust = -900;
4
+
5
+ export async function protectCoreFromOom({
6
+ platform = process.platform,
7
+ score = defaultCoreOomScoreAdjust,
8
+ writeScore = (value) => writeFile("/proc/self/oom_score_adj", `${value}\n`, "utf8"),
9
+ logger
10
+ } = {}) {
11
+ if (platform !== "linux") return false;
12
+ try {
13
+ await writeScore(score);
14
+ logger?.log("service", `core OOM priority protected at ${score}`);
15
+ return true;
16
+ } catch (error) {
17
+ logger?.log("service", `core OOM priority could not be lowered: ${error?.code || error?.message || error}`);
18
+ return false;
19
+ }
20
+ }
@@ -1,151 +1,3 @@
1
- import { mkdir } from "node:fs/promises";
2
- import crypto from "node:crypto";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
-
7
- export const arisaPackageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
- export const arisaHomeDir = process.env.ARISA_HOME
9
- ? path.resolve(process.env.ARISA_HOME)
10
- : path.join(os.homedir(), ".arisa");
11
- export const stateDir = path.join(arisaHomeDir, "state");
12
- export const configFile = path.join(stateDir, "config.json");
13
- export const piAuthFile = path.join(stateDir, "pi-auth.json");
14
- export const servicePidFile = path.join(stateDir, "arisa.pid");
15
- export const serviceLogFile = path.join(stateDir, "arisa.log");
16
- export const restartReceiptFile = path.join(stateDir, "restart-receipt.json");
17
- export const sessionStartOperationalNotesFile = path.join(stateDir, "session-start-operational-notes.json");
18
- export function createIpcSocketPath({ homeDir = arisaHomeDir, platform = process.platform } = {}) {
19
- if (platform === "win32") {
20
- const suffix = crypto.createHash("sha256").update(homeDir).digest("hex").slice(0, 16);
21
- return `\\\\.\\pipe\\arisa-${suffix}`;
22
- }
23
- return path.join(homeDir, "state", "arisa.sock");
24
- }
25
-
26
- export const arisaIpcSocketFile = createIpcSocketPath();
27
- export const tasksFile = path.join(stateDir, "tasks.json");
28
- export const toolsDir = path.join(arisaHomeDir, "tools");
29
- export const chatsDir = path.join(arisaHomeDir, "chats");
30
- export const toolStateDir = path.join(stateDir, "tools");
31
-
32
- export function requireToolName(toolName) {
33
- const name = String(toolName ?? "");
34
- if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
35
- throw new Error(`Invalid tool name: ${name || "empty"}`);
36
- }
37
- return name;
38
- }
39
-
40
- export function getChatDir(chatId) {
41
- return path.join(chatsDir, String(chatId));
42
- }
43
-
44
- export function getChatArtifactsDir(chatId) {
45
- return path.join(getChatDir(chatId), "artifacts");
46
- }
47
-
48
- export function getChatArtifactsIndexFile(chatId) {
49
- return path.join(getChatDir(chatId), "state", "artifacts.json");
50
- }
51
-
52
- export function getChatSessionSeedFile(chatId) {
53
- return path.join(getChatDir(chatId), "state", "session-seed.jsonl");
54
- }
55
-
56
- export function getChatLegacyConversationHistoryFile(chatId) {
57
- return path.join(getChatDir(chatId), "state", "conversation.jsonl");
58
- }
59
-
60
- export function getChatToolUsageFile(chatId) {
61
- return path.join(getChatDir(chatId), "state", "tool-usage.json");
62
- }
63
-
64
- export function getChatToolResourceNotesFile(chatId) {
65
- return path.join(getChatDir(chatId), "state", "tool-resource-notes.json");
66
- }
67
-
68
- export function getChatTelegramWorkspacesFile(chatId) {
69
- return path.join(getChatDir(chatId), "state", "telegram-workspaces.json");
70
- }
71
-
72
- export function getChatToolStateDir(chatId, toolName) {
73
- return path.join(getChatDir(chatId), "state", "tools", requireToolName(toolName));
74
- }
75
-
76
- export function normalizeDaemonScope(scope = { type: "global" }) {
77
- if (!scope || scope === "global" || scope.type === "global") {
78
- return { type: "global" };
79
- }
80
- if (scope === "chat") {
81
- throw new Error("chat daemon scope requires chatId");
82
- }
83
- if (scope.type !== "chat") {
84
- throw new Error(`Unsupported daemon scope: ${scope.type || scope}`);
85
- }
86
- const chatId = String(scope.chatId ?? "").trim();
87
- if (!/^-?\d+$/.test(chatId)) {
88
- throw new Error(`Invalid chat daemon scope: ${chatId || "missing chatId"}`);
89
- }
90
- return { type: "chat", chatId };
91
- }
92
-
93
- export function getDaemonInstanceId(scope = { type: "global" }) {
94
- const normalized = normalizeDaemonScope(scope);
95
- return normalized.type === "global" ? "global" : `chat:${normalized.chatId}`;
96
- }
97
-
98
- export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
99
- const normalized = normalizeDaemonScope(scope);
100
- return normalized.type === "global"
101
- ? getToolStateDir(toolName)
102
- : path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
103
- }
104
-
105
- export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
106
- if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
107
- throw new Error(`Invalid Pi session revision: ${sessionRevision}`);
108
- }
109
- const sessionsDir = path.join(getChatDir(chatId), "state", "pi-sessions");
110
- return sessionRevision === 0
111
- ? sessionsDir
112
- : path.join(sessionsDir, String(sessionRevision));
113
- }
114
-
115
- export function getToolDir(toolName) {
116
- return path.join(toolsDir, requireToolName(toolName));
117
- }
118
-
119
- export function getToolConfigPath(toolName) {
120
- return path.join(getToolDir(toolName), "config.js");
121
- }
122
-
123
- export function getChatConfigDir(chatId) {
124
- return path.join(getChatDir(chatId), "config");
125
- }
126
-
127
- export function getChatTmpDir(chatId) {
128
- return path.join(getChatDir(chatId), "tmp");
129
- }
130
-
131
- export function getChatToolConfigPath(chatId, toolName) {
132
- return path.join(getChatConfigDir(chatId), "tools", requireToolName(toolName), "config.js");
133
- }
134
-
135
- export function getToolStateDir(toolName) {
136
- return path.join(toolStateDir, requireToolName(toolName));
137
- }
138
-
139
- export function getToolTmpDir(toolName) {
140
- return path.join(getToolStateDir(toolName), "tmp");
141
- }
142
-
143
- export function getChatToolTmpDir(chatId, toolName) {
144
- return path.join(getChatTmpDir(chatId), "tools", toolName);
145
- }
146
-
147
- export async function ensureArisaHome() {
148
- await mkdir(stateDir, { recursive: true });
149
- await mkdir(toolsDir, { recursive: true });
150
- await mkdir(chatsDir, { recursive: true });
151
- }
1
+ // Public compatibility facade for tools and integrations.
2
+ // Core and runtime internals import the neutral platform implementation directly.
3
+ export * from "../platform/paths.js";
@@ -0,0 +1,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import process from "node:process";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ function parsePosixProcesses(output) {
8
+ return String(output || "")
9
+ .split("\n")
10
+ .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
11
+ .filter(Boolean)
12
+ .map((match) => ({ pid: Number(match[1]), command: match[2] }));
13
+ }
14
+
15
+ function parseWindowsProcesses(output) {
16
+ const parsed = JSON.parse(output || "[]");
17
+ const records = Array.isArray(parsed) ? parsed : [parsed];
18
+ return records
19
+ .filter((record) => record?.ProcessId && record?.CommandLine)
20
+ .map((record) => ({ pid: Number(record.ProcessId), command: String(record.CommandLine) }));
21
+ }
22
+
23
+ export async function listSystemProcesses({ timeoutMs, platform = process.platform } = {}) {
24
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
25
+ throw new Error("Process inspection requires a positive timeoutMs");
26
+ }
27
+ if (platform === "win32") {
28
+ const { stdout } = await execFileAsync("powershell.exe", [
29
+ "-NoProfile",
30
+ "-Command",
31
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
32
+ ], { timeout: timeoutMs, windowsHide: true });
33
+ return parseWindowsProcesses(stdout);
34
+ }
35
+ const { stdout } = await execFileAsync("ps", ["-ww", "-axo", "pid=,command="], { timeout: timeoutMs });
36
+ return parsePosixProcesses(stdout);
37
+ }
38
+
39
+ export function isArisaServiceProcess(record, entryFile) {
40
+ return record.command.includes(entryFile)
41
+ && /(?:^|\s)--service-runner(?:\s|$)/.test(record.command);
42
+ }
43
+
44
+ export function isDaemonProcess(record, daemon) {
45
+ return record.command.includes(daemon.entryPath)
46
+ && /(?:^|\s)daemon(?:\s|$)/.test(record.command);
47
+ }
48
+
49
+ function sleep(ms) {
50
+ return new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
52
+
53
+ function isAlive(pid) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export async function terminateProcess(pid, { forceAfterMs } = {}) {
63
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid) {
64
+ throw new Error(`Refusing to terminate invalid process target: ${pid}`);
65
+ }
66
+ if (!Number.isFinite(forceAfterMs) || forceAfterMs <= 0) {
67
+ throw new Error("Process cleanup requires a positive forceAfterMs");
68
+ }
69
+ if (!isAlive(pid)) return false;
70
+ process.kill(pid, "SIGTERM");
71
+ const startedAt = Date.now();
72
+ while (Date.now() - startedAt < forceAfterMs) {
73
+ if (!isAlive(pid)) return true;
74
+ await sleep(Math.min(100, forceAfterMs));
75
+ }
76
+ if (isAlive(pid)) process.kill(pid, "SIGKILL");
77
+ return true;
78
+ }
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process";
3
3
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { arisaPackageDir, restartReceiptFile } from "./paths.js";
6
+ import { arisaPackageDir, restartReceiptFile } from "../platform/paths.js";
7
7
 
8
8
  const execFileAsync = promisify(execFile);
9
9
 
@@ -2,7 +2,7 @@ import { open, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { spawn } from "node:child_process";
3
3
  import process from "node:process";
4
4
  import { fileURLToPath } from "node:url";
5
- import { ensureArisaHome, serviceLogFile, servicePidFile } from "./paths.js";
5
+ import { ensureArisaHome, serviceLogFile, servicePidFile } from "../platform/paths.js";
6
6
 
7
7
  export const serviceEntryFile = fileURLToPath(new URL("../index.js", import.meta.url));
8
8
 
@@ -22,6 +22,7 @@ export function createServiceSupervisor({
22
22
  restartBackoffMaxMs = 60_000,
23
23
  stableRuntimeMs = 60_000,
24
24
  logger,
25
+ onUnexpectedExit = null,
25
26
  spawnProcess = spawn,
26
27
  wait = sleep
27
28
  }) {
@@ -65,6 +66,19 @@ export function createServiceSupervisor({
65
66
  }
66
67
 
67
68
  const delay = Math.min(maximumBackoffMs, initialBackoffMs * (2 ** (consecutiveFailures - 1)));
69
+ try {
70
+ await onUnexpectedExit?.({
71
+ occurredAt: new Date().toISOString(),
72
+ runtimeMs,
73
+ restartDelayMs: delay,
74
+ consecutiveFailures,
75
+ code: outcome.code ?? null,
76
+ signal: outcome.signal || null,
77
+ detail
78
+ });
79
+ } catch (error) {
80
+ logger?.error("service", `worker recovery report failed: ${errorMessage(error)}`);
81
+ }
68
82
  logger?.log("service", `restarting worker in ${delay}ms`);
69
83
  await Promise.race([
70
84
  wait(delay),
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { applyConfigDefaults } from "../core/config/config-defaults.js";
5
5
  import { installLockedOfficialTool } from "../core/tools/official-tool-installer.js";
6
6
  import { createHeadlessApp } from "./create-headless-app.js";
7
- import { arisaPackageDir } from "./paths.js";
7
+ import { arisaPackageDir } from "../platform/paths.js";
8
8
  import { readPackageVersion, readRecentLogLines, followLogFile } from "./log-viewer.js";
9
9
  import { parseSlaveBootstrapUrl } from "./slave-bootstrap-url.js";
10
10
  import { withSecureRequestFile } from "./secure-request-file.js";
@@ -8,7 +8,8 @@ import {
8
8
  writeDaemonStatus
9
9
  } from "../core/tools/daemon-processes.js";
10
10
  import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
11
- import { ensureArisaHome } from "./paths.js";
11
+ import { ensureArisaHome } from "../platform/paths.js";
12
+ import { reapObsoleteDaemon } from "./obsolete-daemon-reaper.js";
12
13
 
13
14
  async function fileExists(file) {
14
15
  try {
@@ -58,7 +59,12 @@ function validateRegistration(record, toolRegistry) {
58
59
  };
59
60
  }
60
61
 
61
- export function createToolProcessSupervisor({ logger, policy, toolRegistry } = {}) {
62
+ export function createToolProcessSupervisor({
63
+ logger,
64
+ policy,
65
+ toolRegistry,
66
+ obsoleteDaemonReaper = reapObsoleteDaemon
67
+ } = {}) {
62
68
  let running = false;
63
69
  let timer = null;
64
70
  let reconciliation = null;
@@ -75,13 +81,34 @@ export function createToolProcessSupervisor({ logger, policy, toolRegistry } = {
75
81
  for (const registeredRecord of await listRegisteredDaemons()) {
76
82
  const validation = validateRegistration(registeredRecord, toolRegistry);
77
83
  if (!validation.valid) {
78
- logger?.log("tools", `skipping stale daemon registration ${registeredRecord.toolName} (${registeredRecord.instanceId || "global"}): ${validation.reason}`);
79
- results.push({
80
- record: registeredRecord,
81
- outcome: "stale-registration",
82
- reason: validation.reason,
83
- diagnostic: await readDaemonDiagnostic(registeredRecord)
84
- });
84
+ const diagnostic = await readDaemonDiagnostic(registeredRecord);
85
+ try {
86
+ const result = await obsoleteDaemonReaper({
87
+ record: registeredRecord,
88
+ diagnostic,
89
+ reason: validation.reason,
90
+ timeoutMs: daemonPolicy.healthTimeoutMs,
91
+ stopTimeoutMs: daemonPolicy.stopTimeoutMs
92
+ });
93
+ results.push(result);
94
+ const key = `${registeredRecord.toolName}:${registeredRecord.instanceId || "global"}`;
95
+ const message = result.outcome === "obsolete-removed"
96
+ ? `removed obsolete daemon ${registeredRecord.toolName} (${registeredRecord.instanceId || "global"}): ${validation.reason}`
97
+ : `left obsolete daemon ${registeredRecord.toolName} (${registeredRecord.instanceId || "global"}) untouched because process identity could not be verified`;
98
+ if (reportedDiagnostics.get(key) !== message) {
99
+ reportedDiagnostics.set(key, message);
100
+ logger?.log("tools", message);
101
+ }
102
+ } catch (error) {
103
+ logger?.error?.("tools", `obsolete daemon cleanup failed for ${registeredRecord.toolName}: ${error?.message || error}`);
104
+ results.push({
105
+ record: registeredRecord,
106
+ outcome: "obsolete-cleanup-error",
107
+ reason: validation.reason,
108
+ diagnostic,
109
+ error: error?.message || String(error)
110
+ });
111
+ }
85
112
  continue;
86
113
  }
87
114
  const record = validation.record;