arisa 4.2.8 → 4.3.2

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.
@@ -7,22 +7,106 @@ import {
7
7
  readJson,
8
8
  startManagedDaemon,
9
9
  stopManagedDaemon,
10
+ writeDaemonStatus,
10
11
  writeJson
11
12
  } from "./daemon-processes.js";
13
+ import { loadDaemonPolicy } from "./daemon-policy.js";
12
14
 
13
- export function createDaemonRuntime({ toolName, entryPath, beforeStart = null }) {
14
- const paths = daemonPaths(toolName);
15
+ const CONTROL_FIELD = "__daemon";
15
16
 
16
- async function ensure() {
17
- await mkdir(paths.commandsDir, { recursive: true });
17
+ function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ async function withTimeout(work, timeoutMs, message) {
22
+ let timer;
23
+ try {
24
+ return await Promise.race([
25
+ Promise.resolve().then(work),
26
+ new Promise((_, reject) => {
27
+ timer = setTimeout(() => {
28
+ const error = new Error(message);
29
+ error.code = "DAEMON_OPERATION_TIMEOUT";
30
+ reject(error);
31
+ }, timeoutMs);
32
+ })
33
+ ]);
34
+ } finally {
35
+ clearTimeout(timer);
18
36
  }
37
+ }
19
38
 
20
- function jobPaths(id) {
21
- return {
22
- request: path.join(paths.commandsDir, `${id}.request.json`),
23
- processing: path.join(paths.commandsDir, `${id}.processing.json`),
24
- result: path.join(paths.commandsDir, `${id}.result.json`)
25
- };
39
+ function jobPaths(paths, id) {
40
+ return {
41
+ request: path.join(paths.commandsDir, `${id}.request.json`),
42
+ processing: path.join(paths.commandsDir, `${id}.processing.json`),
43
+ result: path.join(paths.commandsDir, `${id}.result.json`)
44
+ };
45
+ }
46
+
47
+ async function waitForResult(paths, id, { timeoutMs, intervalMs }) {
48
+ const files = jobPaths(paths, id);
49
+ const startedAt = Date.now();
50
+ while (Date.now() - startedAt < timeoutMs) {
51
+ const result = await readJson(files.result, null);
52
+ if (result) {
53
+ await unlink(files.result).catch(() => {});
54
+ if (!result.ok) {
55
+ const error = new Error(result.error || `${paths.toolName} daemon job failed`);
56
+ if (result.code) error.code = result.code;
57
+ throw error;
58
+ }
59
+ return result.output || {};
60
+ }
61
+ await sleep(intervalMs);
62
+ }
63
+ const error = new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`);
64
+ error.code = "DAEMON_JOB_TIMEOUT";
65
+ throw error;
66
+ }
67
+
68
+ async function enqueue(paths, payload, { control = false, timeoutMs, intervalMs }) {
69
+ await mkdir(paths.commandsDir, { recursive: true });
70
+ const id = `${control ? "control" : "job"}-${crypto.randomUUID()}`;
71
+ await writeJson(jobPaths(paths, id).request, { id, ...payload });
72
+ return waitForResult(paths, id, { timeoutMs, intervalMs });
73
+ }
74
+
75
+ export async function submitDaemonControl(record, operation, { timeoutMs } = {}) {
76
+ const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
77
+ const policy = await loadDaemonPolicy();
78
+ return enqueue(paths, {
79
+ [CONTROL_FIELD]: { operation }
80
+ }, {
81
+ control: true,
82
+ timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
83
+ intervalMs: policy.queuePollIntervalMs
84
+ });
85
+ }
86
+
87
+ export function isDaemonReady(status, pid, policy, now = Date.now()) {
88
+ if (status.state !== "ready" || !isProcessAlive(pid)) return false;
89
+ const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
90
+ const healthAt = new Date(status.lastHealthSuccessAt || 0).getTime();
91
+ if (!heartbeatAt || now - heartbeatAt > policy.heartbeatStaleMs) return false;
92
+ if (!healthAt || now - healthAt > policy.healthIntervalMs + policy.healthTimeoutMs) return false;
93
+ return true;
94
+ }
95
+
96
+ export function createDaemonRuntime({
97
+ toolName,
98
+ entryPath,
99
+ scope = { type: "global" },
100
+ startupContext = {},
101
+ beforeStart = null,
102
+ autoStart = true
103
+ }) {
104
+ const paths = daemonPaths({ toolName, scope });
105
+ const registration = { toolName, entryPath, scope: paths.scope, startupContext, autoStart };
106
+ let statusWrite = Promise.resolve();
107
+
108
+ async function ensure() {
109
+ await mkdir(paths.commandsDir, { recursive: true });
26
110
  }
27
111
 
28
112
  async function getPid() {
@@ -30,60 +114,72 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
30
114
  }
31
115
 
32
116
  async function writeStatus(patch) {
33
- const current = await readJson(paths.statusFile, {});
34
- await writeJson(paths.statusFile, { ...current, ...patch, updatedAt: new Date().toISOString() });
117
+ statusWrite = statusWrite.catch(() => {}).then(() => writeDaemonStatus(paths, patch));
118
+ return statusWrite;
35
119
  }
36
120
 
37
121
  async function start() {
38
122
  return startManagedDaemon({
39
- toolName,
40
- entryPath,
123
+ ...registration,
41
124
  beforeStart
42
125
  });
43
126
  }
44
127
 
45
128
  async function stop() {
46
- await stopManagedDaemon(toolName);
129
+ await stopManagedDaemon({ toolName, scope: paths.scope });
47
130
  }
48
131
 
49
- async function waitReady({ timeoutMs = 120000, readyStates = ["ready"] } = {}) {
132
+ async function waitReady({ timeoutMs } = {}) {
133
+ const policy = await loadDaemonPolicy();
134
+ const effectiveTimeoutMs = timeoutMs ?? policy.startupTimeoutMs;
50
135
  const startTime = Date.now();
51
- while (Date.now() - startTime < timeoutMs) {
136
+ while (Date.now() - startTime < effectiveTimeoutMs) {
52
137
  const status = await readJson(paths.statusFile, {});
53
138
  const pid = await getPid();
54
- if (readyStates.includes(status.state) && isProcessAlive(pid)) return status;
55
- if (status.state === "error") throw new Error(status.message || `${toolName} daemon failed`);
56
- await new Promise((resolve) => setTimeout(resolve, 500));
139
+ if (isDaemonReady(status, pid, policy)) return status;
140
+ if (status.state === "failed") throw new Error(status.message || `${toolName} daemon failed`);
141
+ await sleep(policy.queuePollIntervalMs);
57
142
  }
58
- throw new Error(`${toolName} daemon was not ready after ${timeoutMs}ms`);
143
+ throw new Error(`${toolName} daemon was not ready after ${effectiveTimeoutMs}ms`);
59
144
  }
60
145
 
61
- async function submit(payload, { timeoutMs = 180000, readyTimeoutMs = 120000 } = {}) {
62
- await start();
63
- await waitReady({ timeoutMs: readyTimeoutMs });
64
- const id = crypto.randomUUID();
65
- const files = jobPaths(id);
66
- await writeJson(files.request, { id, ...payload });
146
+ async function ensureReady({ timeoutMs } = {}) {
147
+ const policy = await loadDaemonPolicy();
148
+ const status = await readJson(paths.statusFile, {});
149
+ const pid = await getPid();
150
+ if (isDaemonReady(status, pid, policy)) return status;
151
+ await submitDaemonControl(registration, "health", {
152
+ timeoutMs: timeoutMs ?? policy.healthTimeoutMs
153
+ });
154
+ return waitReady({ timeoutMs: timeoutMs ?? policy.startupTimeoutMs });
155
+ }
67
156
 
68
- const startTime = Date.now();
69
- while (Date.now() - startTime < timeoutMs) {
70
- const result = await readJson(files.result, null);
71
- if (result) {
72
- await unlink(files.result).catch(() => {});
73
- if (!result.ok) throw new Error(result.error || `${toolName} job failed`);
74
- return result.output || {};
75
- }
76
- await new Promise((resolve) => setTimeout(resolve, 250));
77
- }
78
- throw new Error(`${toolName} job timed out after ${timeoutMs}ms`);
157
+ async function submit(payload, {
158
+ timeoutMs,
159
+ readyTimeoutMs,
160
+ requireReady = true
161
+ } = {}) {
162
+ const policy = await loadDaemonPolicy();
163
+ await start();
164
+ if (requireReady) await ensureReady({ timeoutMs: readyTimeoutMs });
165
+ return enqueue(paths, payload, {
166
+ timeoutMs: timeoutMs ?? policy.startupTimeoutMs,
167
+ intervalMs: policy.queuePollIntervalMs
168
+ });
79
169
  }
80
170
 
81
171
  async function claimNext() {
82
172
  await ensure();
83
- const files = (await readdir(paths.commandsDir)).filter((file) => file.endsWith(".request.json"));
173
+ const files = (await readdir(paths.commandsDir))
174
+ .filter((file) => file.endsWith(".request.json"))
175
+ .sort((a, b) => {
176
+ const aControl = a.startsWith("control-") ? 0 : 1;
177
+ const bControl = b.startsWith("control-") ? 0 : 1;
178
+ return aControl - bControl || a.localeCompare(b);
179
+ });
84
180
  for (const file of files) {
85
181
  const id = file.replace(/\.request\.json$/, "");
86
- const item = jobPaths(id);
182
+ const item = jobPaths(paths, id);
87
183
  try {
88
184
  await rename(item.request, item.processing);
89
185
  return { id, ...item, payload: await readJson(item.processing, null) };
@@ -98,33 +194,152 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
98
194
  }
99
195
 
100
196
  async function fail(job, error) {
101
- await writeJson(job.result, { ok: false, error: error?.message || String(error) });
197
+ await writeJson(job.result, {
198
+ ok: false,
199
+ error: error?.message || String(error),
200
+ code: error?.code || null
201
+ });
102
202
  await unlink(job.processing).catch(() => {});
103
203
  }
104
204
 
105
- async function workLoop({ processJob, idleTimeoutMs = 0, intervalMs = 250 }) {
205
+ async function workLoop({
206
+ processJob,
207
+ healthCheck,
208
+ recover = null,
209
+ beforeExit = null,
210
+ idleTimeoutMs = 0
211
+ }) {
212
+ if (typeof healthCheck !== "function") {
213
+ throw new Error(`${toolName} daemon must declare healthCheck`);
214
+ }
215
+ const policy = await loadDaemonPolicy();
216
+ const intervalMs = policy.queuePollIntervalMs;
106
217
  let lastActivity = Date.now();
107
- setInterval(async () => {
218
+ let processing = false;
219
+ let exiting = false;
220
+ let acceptingWork = true;
221
+
222
+ await ensure();
223
+ await writeStatus({
224
+ state: "starting",
225
+ pid: process.pid,
226
+ heartbeatAt: new Date().toISOString(),
227
+ supportsRecovery: typeof recover === "function",
228
+ message: "Daemon work loop started; waiting for health check"
229
+ });
230
+
231
+ const heartbeatTimer = setInterval(() => {
232
+ writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
233
+ }, policy.heartbeatIntervalMs);
234
+
235
+ const workTimer = setInterval(async () => {
236
+ if (processing || exiting || !acceptingWork) return;
237
+ processing = true;
108
238
  try {
109
239
  const job = await claimNext();
110
240
  if (job) {
111
- lastActivity = Date.now();
241
+ const operation = job.payload?.[CONTROL_FIELD]?.operation;
112
242
  try {
113
- await complete(job, await processJob(job.payload));
243
+ if (operation === "health") {
244
+ const checkedAt = new Date().toISOString();
245
+ await writeStatus({ lastHealthCheckAt: checkedAt });
246
+ const output = await withTimeout(
247
+ healthCheck,
248
+ policy.healthTimeoutMs,
249
+ `${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
250
+ );
251
+ await writeStatus({
252
+ state: "ready",
253
+ lastHealthSuccessAt: new Date().toISOString(),
254
+ consecutiveHealthFailures: 0,
255
+ restartAttempts: 0,
256
+ restartRequested: false,
257
+ nextRestartAt: null,
258
+ message: output?.message || "Daemon health check passed"
259
+ });
260
+ await complete(job, output || { ok: true });
261
+ } else if (operation === "recover") {
262
+ const recovered = typeof recover === "function"
263
+ ? await withTimeout(
264
+ recover,
265
+ policy.healthTimeoutMs,
266
+ `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`
267
+ )
268
+ : false;
269
+ await complete(job, { recovered: recovered !== false });
270
+ } else {
271
+ lastActivity = Date.now();
272
+ const output = await processJob(job.payload);
273
+ await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
274
+ await complete(job, output);
275
+ lastActivity = Date.now();
276
+ }
114
277
  } catch (error) {
278
+ if (error?.code === "DAEMON_OPERATION_TIMEOUT") {
279
+ acceptingWork = false;
280
+ }
281
+ const current = await readJson(paths.statusFile, {});
282
+ await writeStatus({
283
+ ...(operation === "health"
284
+ ? {
285
+ state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
286
+ consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
287
+ }
288
+ : {}),
289
+ lastError: {
290
+ at: new Date().toISOString(),
291
+ phase: operation || "job",
292
+ message: error?.message || String(error)
293
+ },
294
+ message: error?.message || String(error)
295
+ });
115
296
  await fail(job, error);
116
297
  }
117
- lastActivity = Date.now();
118
298
  }
119
299
  if (idleTimeoutMs > 0 && Date.now() - lastActivity > idleTimeoutMs) {
120
- await writeStatus({ state: "stopped", message: "Idle timeout reached" });
300
+ exiting = true;
301
+ clearInterval(heartbeatTimer);
302
+ clearInterval(workTimer);
303
+ if (beforeExit) await beforeExit();
304
+ await writeStatus({
305
+ state: "stopped",
306
+ restartRequested: false,
307
+ nextRestartAt: null,
308
+ message: "Idle timeout reached"
309
+ });
121
310
  process.exit(0);
122
311
  }
123
312
  } catch (error) {
124
- await writeStatus({ state: "error", message: error?.message || String(error) });
313
+ await writeStatus({
314
+ state: "degraded",
315
+ lastError: {
316
+ at: new Date().toISOString(),
317
+ phase: "work-loop",
318
+ message: error?.message || String(error)
319
+ },
320
+ message: error?.message || String(error)
321
+ });
322
+ } finally {
323
+ processing = false;
125
324
  }
126
325
  }, intervalMs);
326
+
327
+ submitDaemonControl(registration, "health", {
328
+ timeoutMs: policy.healthTimeoutMs
329
+ }).catch(() => {});
127
330
  }
128
331
 
129
- return { paths, ensure, getPid, writeStatus, start, stop, waitReady, submit, workLoop };
332
+ return {
333
+ paths,
334
+ registration,
335
+ ensure,
336
+ getPid,
337
+ writeStatus,
338
+ start,
339
+ stop,
340
+ waitReady,
341
+ ensureReady,
342
+ submit,
343
+ workLoop
344
+ };
130
345
  }
@@ -5,8 +5,10 @@ import { stdin as input, stdout as output } from "node:process";
5
5
  import { spawn } from "node:child_process";
6
6
  import { Bot } from "grammy";
7
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
8
- import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
8
+ import { createPiRuntime, formatPiModelOption, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
+ import { applyConfigDefaults, telegramConfigDefaults } from "../core/config/config-defaults.js";
9
10
  import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
11
+ import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
10
12
  import { configFile, ensureArisaHome } from "./paths.js";
11
13
 
12
14
  const ARISA_BANNER = [
@@ -28,7 +30,7 @@ async function exists(file) {
28
30
  }
29
31
 
30
32
  function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
31
- return {
33
+ return applyConfigDefaults({
32
34
  telegram: {
33
35
  token: telegramApiKey,
34
36
  maxChatIds: telegramMaxChatIds,
@@ -41,7 +43,7 @@ function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [
41
43
  apiKey: piApiKey
42
44
  },
43
45
  createdAt: new Date().toISOString()
44
- };
46
+ });
45
47
  }
46
48
 
47
49
  function sortBootstrapProviders(providers) {
@@ -99,30 +101,6 @@ function parseYesNo(value, fallback = true) {
99
101
  return null;
100
102
  }
101
103
 
102
- function buildPagedInlineKeyboard(action, items, { page = 0, pageSize = 8 } = {}) {
103
- const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
104
- const currentPage = Math.max(0, Math.min(pageCount - 1, page));
105
- const startIndex = currentPage * pageSize;
106
- const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
107
- text: item.text,
108
- callback_data: `${action}:${startIndex + index}`
109
- }]));
110
-
111
- if (pageCount > 1) {
112
- const navigation = [];
113
- if (currentPage > 0) {
114
- navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
115
- }
116
- navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
117
- if (currentPage < pageCount - 1) {
118
- navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
119
- }
120
- rows.push(navigation);
121
- }
122
-
123
- return { inline_keyboard: rows };
124
- }
125
-
126
104
  function getIncomingChatMeta(ctx) {
127
105
  return {
128
106
  languageCode: ctx.from?.language_code || "",
@@ -137,11 +115,6 @@ function formatProviderOption(item) {
137
115
  return `${item.provider} (${item.modelCount} models, ${authLabel})`;
138
116
  }
139
117
 
140
- function formatModelOption(model) {
141
- const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
142
- return capabilities ? `${model.id} [${capabilities}]` : model.id;
143
- }
144
-
145
118
  function selectPiLoginOption(options = []) {
146
119
  return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
147
120
  || options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
@@ -214,7 +187,7 @@ async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
214
187
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
215
188
  console.log(`\nAvailable models for ${selectedProvider.provider}:`);
216
189
  models.forEach((model, index) => {
217
- console.log(`${index + 1}. ${formatModelOption(model)}`);
190
+ console.log(`${index + 1}. ${formatPiModelOption(model)}`);
218
191
  });
219
192
 
220
193
  const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
@@ -325,14 +298,20 @@ async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
325
298
  const askProvider = async (ctx = null, page = 0) => {
326
299
  state = "provider";
327
300
  await showSetupPrompt(ctx, "Select the Pi provider Arisa should use:", {
328
- reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), { page })
301
+ reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), {
302
+ page,
303
+ pageSize: telegramConfigDefaults.modelPickerPageSize
304
+ })
329
305
  });
330
306
  };
331
307
 
332
308
  const askModel = async (ctx = null, page = 0) => {
333
309
  state = "model";
334
310
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
335
- const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatModelOption(model) })), { page });
311
+ const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatPiModelOption(model) })), {
312
+ page,
313
+ pageSize: telegramConfigDefaults.modelPickerPageSize
314
+ });
336
315
  keyboard.inline_keyboard.push([{ text: "Back to providers", callback_data: "back:provider" }]);
337
316
  await showSetupPrompt(ctx, `Select the model for ${selectedProvider.provider}:`, {
338
317
  reply_markup: keyboard
@@ -98,7 +98,7 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
98
98
  }
99
99
 
100
100
  const artifactStore = new ArtifactStore();
101
- const toolProcessSupervisor = createToolProcessSupervisor({ logger });
101
+ const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons });
102
102
  const toolRegistry = new ToolRegistry({ logger });
103
103
  const taskStore = new TaskStore();
104
104
  await toolRegistry.load();
@@ -43,8 +43,43 @@ export function getChatToolStateDir(chatId, toolName) {
43
43
  return path.join(getChatDir(chatId), "state", "tools", toolName);
44
44
  }
45
45
 
46
- export function getChatPiSessionsDir(chatId) {
47
- return path.join(getChatDir(chatId), "state", "pi-sessions");
46
+ export function normalizeDaemonScope(scope = { type: "global" }) {
47
+ if (!scope || scope === "global" || scope.type === "global") {
48
+ return { type: "global" };
49
+ }
50
+ if (scope === "chat") {
51
+ throw new Error("chat daemon scope requires chatId");
52
+ }
53
+ if (scope.type !== "chat") {
54
+ throw new Error(`Unsupported daemon scope: ${scope.type || scope}`);
55
+ }
56
+ const chatId = String(scope.chatId ?? "").trim();
57
+ if (!/^-?\d+$/.test(chatId)) {
58
+ throw new Error(`Invalid chat daemon scope: ${chatId || "missing chatId"}`);
59
+ }
60
+ return { type: "chat", chatId };
61
+ }
62
+
63
+ export function getDaemonInstanceId(scope = { type: "global" }) {
64
+ const normalized = normalizeDaemonScope(scope);
65
+ return normalized.type === "global" ? "global" : `chat:${normalized.chatId}`;
66
+ }
67
+
68
+ export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
69
+ const normalized = normalizeDaemonScope(scope);
70
+ return normalized.type === "global"
71
+ ? getToolStateDir(toolName)
72
+ : path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
73
+ }
74
+
75
+ export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
76
+ if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
77
+ throw new Error(`Invalid Pi session revision: ${sessionRevision}`);
78
+ }
79
+ const sessionsDir = path.join(getChatDir(chatId), "state", "pi-sessions");
80
+ return sessionRevision === 0
81
+ ? sessionsDir
82
+ : path.join(sessionsDir, String(sessionRevision));
48
83
  }
49
84
 
50
85
  export function getToolDir(toolName) {
@@ -1,11 +1,7 @@
1
- import { access, rm } from "node:fs/promises";
2
- import {
3
- daemonPaths,
4
- isProcessAlive,
5
- listRegisteredDaemons,
6
- readJson,
7
- startManagedDaemon
8
- } from "../core/tools/daemon-processes.js";
1
+ import { access } from "node:fs/promises";
2
+ import { superviseDaemon } from "../core/tools/daemon-health.js";
3
+ import { listRegisteredDaemons } from "../core/tools/daemon-processes.js";
4
+ import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
9
5
  import { ensureArisaHome } from "./paths.js";
10
6
 
11
7
  async function fileExists(file) {
@@ -17,47 +13,63 @@ async function fileExists(file) {
17
13
  }
18
14
  }
19
15
 
20
- export function createToolProcessSupervisor({ logger } = {}) {
16
+ export function createToolProcessSupervisor({ logger, policy } = {}) {
21
17
  let running = false;
18
+ let timer = null;
19
+ let reconciliation = null;
20
+ let daemonPolicy = policy;
21
+
22
+ function reportLoopError(error) {
23
+ logger?.error?.("tools", `daemon supervisor loop failed: ${error?.message || error}`);
24
+ }
22
25
 
23
26
  async function reconcileDaemons() {
24
27
  await ensureArisaHome();
25
28
  for (const record of await listRegisteredDaemons()) {
26
- if (!record.autoStart) continue;
27
29
  if (!(await fileExists(record.entryPath))) {
28
30
  logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
29
31
  continue;
30
32
  }
31
-
32
- const paths = daemonPaths(record.toolName);
33
- const { pid } = await readJson(paths.pidFile, {});
34
- if (isProcessAlive(pid)) {
35
- logger?.log("tools", `adopted managed daemon ${record.toolName} (pid ${pid})`);
36
- continue;
37
- }
38
- if (pid) {
39
- await rm(paths.pidFile, { force: true });
40
- logger?.log("tools", `removed stale daemon pid for ${record.toolName} (${pid})`);
33
+ try {
34
+ const outcome = await superviseDaemon(record, daemonPolicy);
35
+ if (outcome !== "healthy") {
36
+ logger?.log("tools", `${record.toolName} (${record.instanceId || "global"}): ${outcome}`);
37
+ }
38
+ } catch (error) {
39
+ logger?.error?.("tools", `daemon supervision failed for ${record.toolName}: ${error?.message || error}`);
41
40
  }
41
+ }
42
+ }
42
43
 
43
- logger?.log("tools", `starting managed daemon ${record.toolName}`);
44
- await startManagedDaemon({
45
- toolName: record.toolName,
46
- entryPath: record.entryPath
47
- });
44
+ async function runLoop() {
45
+ if (!running || reconciliation) return;
46
+ reconciliation = reconcileDaemons();
47
+ try {
48
+ await reconciliation;
49
+ } finally {
50
+ reconciliation = null;
51
+ if (running) {
52
+ timer = setTimeout(() => {
53
+ runLoop().catch(reportLoopError);
54
+ }, daemonPolicy.supervisorIntervalMs);
55
+ }
48
56
  }
49
57
  }
50
58
 
51
59
  return {
52
60
  async start() {
53
61
  if (running) return;
62
+ daemonPolicy ||= await loadDaemonPolicy();
54
63
  running = true;
55
- await reconcileDaemons();
64
+ runLoop().catch(reportLoopError);
56
65
  },
57
66
 
58
67
  async stop() {
59
68
  if (!running) return;
60
69
  running = false;
70
+ clearTimeout(timer);
71
+ timer = null;
72
+ await reconciliation?.catch(() => {});
61
73
  }
62
74
  };
63
75
  }