arisa 4.3.4 → 5.0.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.
- package/AGENTS.md +18 -17
- package/README.md +30 -9
- package/package.json +6 -2
- package/pnpm-workspace.yaml +1 -0
- package/src/core/agent/agent-manager.js +288 -29
- package/src/core/agent/auth-flow.js +12 -8
- package/src/core/agent/model-selection.js +54 -14
- package/src/core/agent/model-speed.js +59 -0
- package/src/core/config/config-defaults.js +56 -4
- package/src/core/config/config-store.js +5 -1
- package/src/core/conversation/conversation-history-store.js +142 -0
- package/src/core/tasks/task-store.js +16 -0
- package/src/core/tools/daemon-health.js +11 -2
- package/src/core/tools/daemon-processes.js +92 -2
- package/src/core/tools/daemon-runtime.js +4 -2
- package/src/core/tools/ipc-client.js +15 -3
- package/src/core/tools/tool-registry.js +27 -0
- package/src/index.js +61 -6
- package/src/runtime/arisa-capabilities.js +45 -1
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +47 -11
- package/src/runtime/doctor.js +307 -0
- package/src/runtime/log-viewer.js +165 -0
- package/src/runtime/paths.js +4 -1
- package/src/runtime/service-manager.js +106 -8
- package/src/runtime/tool-process-supervisor.js +107 -10
- package/src/transport/telegram/bot.js +533 -99
- package/src/transport/telegram/model-picker.js +28 -2
- package/test/agent-tool-policy.test.js +26 -1
- package/test/auth-flow.test.js +28 -2
- package/test/capabilities-security.test.js +37 -0
- package/test/context-and-task-bounds.test.js +279 -0
- package/test/daemon-runtime.test.js +130 -2
- package/test/dependency-warnings.test.js +17 -0
- package/test/doctor.test.js +90 -0
- package/test/log-viewer.test.js +90 -0
- package/test/model-selection.test.js +125 -2
- package/test/paths.test.js +8 -0
- package/test/pi-compaction.test.js +43 -0
- package/test/service-manager.test.js +234 -0
- package/test/task-store.test.js +31 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { cliLogConfig } from "../core/config/config-defaults.js";
|
|
3
|
+
import { ensureArisaHome, serviceLogFile } from "./paths.js";
|
|
4
|
+
|
|
5
|
+
const readChunkSize = 64 * 1024;
|
|
6
|
+
|
|
7
|
+
async function getFileState(logFile) {
|
|
8
|
+
try {
|
|
9
|
+
const details = await stat(logFile);
|
|
10
|
+
return { size: details.size, ino: details.ino };
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error?.code === "ENOENT") return { size: 0, ino: null };
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readRecentLogLines(logFile, lineCount) {
|
|
18
|
+
if (!Number.isSafeInteger(lineCount) || lineCount < 1) {
|
|
19
|
+
throw new Error("Log line count must be a positive integer");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const state = await getFileState(logFile);
|
|
23
|
+
if (state.size === 0) return { text: "", endsWithNewline: false, ...state };
|
|
24
|
+
|
|
25
|
+
const handle = await open(logFile, "r");
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let position = state.size;
|
|
28
|
+
let newlineCount = 0;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
while (position > 0 && newlineCount <= lineCount) {
|
|
32
|
+
const length = Math.min(readChunkSize, position);
|
|
33
|
+
position -= length;
|
|
34
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
35
|
+
const { bytesRead } = await handle.read(chunk, 0, length, position);
|
|
36
|
+
const content = chunk.subarray(0, bytesRead);
|
|
37
|
+
chunks.unshift(content);
|
|
38
|
+
for (const byte of content) {
|
|
39
|
+
if (byte === 0x0a) newlineCount += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
await handle.close();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const content = Buffer.concat(chunks).toString("utf8");
|
|
47
|
+
const endsWithNewline = content.endsWith("\n");
|
|
48
|
+
const lines = content.split("\n");
|
|
49
|
+
if (endsWithNewline) lines.pop();
|
|
50
|
+
return {
|
|
51
|
+
text: lines.slice(-lineCount).join("\n"),
|
|
52
|
+
endsWithNewline,
|
|
53
|
+
...state
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readAppendedBytes(logFile, position, size) {
|
|
58
|
+
if (size <= position) return "";
|
|
59
|
+
const handle = await open(logFile, "r");
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let cursor = position;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
while (cursor < size) {
|
|
65
|
+
const length = Math.min(readChunkSize, size - cursor);
|
|
66
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
67
|
+
const { bytesRead } = await handle.read(chunk, 0, length, cursor);
|
|
68
|
+
if (bytesRead === 0) break;
|
|
69
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
70
|
+
cursor += bytesRead;
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function waitForNextPoll(intervalMs, signal) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
if (signal?.aborted) {
|
|
82
|
+
resolve();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const finish = () => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
signal?.removeEventListener("abort", finish);
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(finish, intervalMs);
|
|
91
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function followLogFile({
|
|
96
|
+
logFile,
|
|
97
|
+
initialSize,
|
|
98
|
+
initialIno,
|
|
99
|
+
write,
|
|
100
|
+
signal,
|
|
101
|
+
pollIntervalMs = cliLogConfig.followPollIntervalMs
|
|
102
|
+
}) {
|
|
103
|
+
let position = initialSize;
|
|
104
|
+
let ino = initialIno;
|
|
105
|
+
|
|
106
|
+
while (!signal?.aborted) {
|
|
107
|
+
await waitForNextPoll(pollIntervalMs, signal);
|
|
108
|
+
if (signal?.aborted) break;
|
|
109
|
+
|
|
110
|
+
const state = await getFileState(logFile);
|
|
111
|
+
if (state.ino !== ino || state.size < position) {
|
|
112
|
+
position = 0;
|
|
113
|
+
ino = state.ino;
|
|
114
|
+
}
|
|
115
|
+
if (state.size === position) continue;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const appended = await readAppendedBytes(logFile, position, state.size);
|
|
119
|
+
if (appended) write(appended);
|
|
120
|
+
position = state.size;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code !== "ENOENT") throw error;
|
|
123
|
+
position = 0;
|
|
124
|
+
ino = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function showServiceLogs({
|
|
130
|
+
version,
|
|
131
|
+
follow = true,
|
|
132
|
+
output = process.stdout,
|
|
133
|
+
signal
|
|
134
|
+
} = {}) {
|
|
135
|
+
if (!version) throw new Error("Arisa version is required");
|
|
136
|
+
await ensureArisaHome();
|
|
137
|
+
|
|
138
|
+
const snapshot = await readRecentLogLines(serviceLogFile, cliLogConfig.recentLines);
|
|
139
|
+
output.write(`Arisa v${version} | Recent logs\n`);
|
|
140
|
+
output.write(`${follow ? "Following new logs; press Ctrl+C to exit." : "Log follow disabled."}\n\n`);
|
|
141
|
+
if (snapshot.text) {
|
|
142
|
+
output.write(snapshot.text);
|
|
143
|
+
if (snapshot.endsWithNewline) output.write("\n");
|
|
144
|
+
} else {
|
|
145
|
+
output.write("No logs yet.\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!follow) return;
|
|
149
|
+
await followLogFile({
|
|
150
|
+
logFile: serviceLogFile,
|
|
151
|
+
initialSize: snapshot.size,
|
|
152
|
+
initialIno: snapshot.ino,
|
|
153
|
+
write: (content) => output.write(content),
|
|
154
|
+
signal
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function readPackageVersion() {
|
|
159
|
+
const packageFile = new URL("../../package.json", import.meta.url);
|
|
160
|
+
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
|
|
161
|
+
if (typeof packageJson.version !== "string" || !packageJson.version) {
|
|
162
|
+
throw new Error("Arisa package version is missing");
|
|
163
|
+
}
|
|
164
|
+
return packageJson.version;
|
|
165
|
+
}
|
package/src/runtime/paths.js
CHANGED
|
@@ -39,6 +39,10 @@ export function getChatArtifactsIndexFile(chatId) {
|
|
|
39
39
|
return path.join(getChatDir(chatId), "state", "artifacts.json");
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export function getChatConversationHistoryFile(chatId) {
|
|
43
|
+
return path.join(getChatDir(chatId), "state", "conversation.jsonl");
|
|
44
|
+
}
|
|
45
|
+
|
|
42
46
|
export function getChatToolStateDir(chatId, toolName) {
|
|
43
47
|
return path.join(getChatDir(chatId), "state", "tools", toolName);
|
|
44
48
|
}
|
|
@@ -119,4 +123,3 @@ export async function ensureArisaHome() {
|
|
|
119
123
|
await mkdir(toolsDir, { recursive: true });
|
|
120
124
|
await mkdir(chatsDir, { recursive: true });
|
|
121
125
|
}
|
|
122
|
-
|
|
@@ -4,14 +4,24 @@ import process from "node:process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { ensureArisaHome, serviceLogFile, servicePidFile } from "./paths.js";
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
export const serviceEntryFile = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
8
8
|
|
|
9
9
|
function isProcessRunning(pid) {
|
|
10
10
|
try {
|
|
11
11
|
process.kill(pid, 0);
|
|
12
12
|
return true;
|
|
13
|
-
} catch {
|
|
14
|
-
return
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return error?.code !== "ESRCH";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requirePositiveTiming(value, name) {
|
|
23
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
24
|
+
throw new Error(`Service restart requires a positive ${name}`);
|
|
15
25
|
}
|
|
16
26
|
}
|
|
17
27
|
|
|
@@ -19,7 +29,7 @@ async function readPid() {
|
|
|
19
29
|
try {
|
|
20
30
|
const raw = await readFile(servicePidFile, "utf8");
|
|
21
31
|
const pid = Number.parseInt(raw.trim(), 10);
|
|
22
|
-
return Number.
|
|
32
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
23
33
|
} catch {
|
|
24
34
|
return null;
|
|
25
35
|
}
|
|
@@ -44,7 +54,7 @@ export async function startService({ verbose = true, cliArgs = [] } = {}) {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const logHandle = await open(serviceLogFile, "a");
|
|
47
|
-
const args = [
|
|
57
|
+
const args = [serviceEntryFile, "--service-runner", ...cliArgs];
|
|
48
58
|
if (!verbose) args.push("--silent");
|
|
49
59
|
|
|
50
60
|
const child = spawn(process.execPath, args, {
|
|
@@ -58,6 +68,36 @@ export async function startService({ verbose = true, cliArgs = [] } = {}) {
|
|
|
58
68
|
return { ok: true, pid: child.pid, logFile: serviceLogFile };
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
export async function handoffServiceRestart({ verbose = true, cliArgs = [] } = {}, {
|
|
72
|
+
ensureHome = ensureArisaHome,
|
|
73
|
+
getStatus = getServiceStatus,
|
|
74
|
+
openLog = open,
|
|
75
|
+
spawnProcess = spawn,
|
|
76
|
+
environment = process.env,
|
|
77
|
+
currentPid = process.pid
|
|
78
|
+
} = {}) {
|
|
79
|
+
await ensureHome();
|
|
80
|
+
const status = await getStatus();
|
|
81
|
+
if (!status.running || status.pid !== currentPid) {
|
|
82
|
+
throw new Error("Service restart handoff requires the active background service process");
|
|
83
|
+
}
|
|
84
|
+
const logHandle = await openLog(serviceLogFile, "a");
|
|
85
|
+
const args = [serviceEntryFile, "restart", ...cliArgs];
|
|
86
|
+
if (!verbose) args.push("--silent");
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const child = spawnProcess(process.execPath, args, {
|
|
90
|
+
detached: true,
|
|
91
|
+
stdio: ["ignore", logHandle.fd, logHandle.fd],
|
|
92
|
+
env: environment
|
|
93
|
+
});
|
|
94
|
+
child.unref();
|
|
95
|
+
return { ok: true, pid: child.pid, logFile: serviceLogFile };
|
|
96
|
+
} finally {
|
|
97
|
+
await logHandle.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
export async function stopService() {
|
|
62
102
|
const status = await getServiceStatus();
|
|
63
103
|
if (!status.running) {
|
|
@@ -66,14 +106,72 @@ export async function stopService() {
|
|
|
66
106
|
|
|
67
107
|
try {
|
|
68
108
|
process.kill(status.pid, "SIGTERM");
|
|
69
|
-
} catch {
|
|
70
|
-
|
|
71
|
-
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code === "ESRCH") {
|
|
111
|
+
await rm(servicePidFile, { force: true }).catch(() => {});
|
|
112
|
+
return { ok: false, reason: "not-running", pid: status.pid };
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
72
115
|
}
|
|
73
116
|
|
|
74
117
|
return { ok: true, pid: status.pid };
|
|
75
118
|
}
|
|
76
119
|
|
|
120
|
+
export async function waitForServiceStop({
|
|
121
|
+
pid,
|
|
122
|
+
timeoutMs,
|
|
123
|
+
pollIntervalMs,
|
|
124
|
+
getStatus = getServiceStatus,
|
|
125
|
+
wait = sleep
|
|
126
|
+
}) {
|
|
127
|
+
requirePositiveTiming(timeoutMs, "shutdownTimeoutMs");
|
|
128
|
+
requirePositiveTiming(pollIntervalMs, "shutdownPollIntervalMs");
|
|
129
|
+
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
132
|
+
const status = await getStatus();
|
|
133
|
+
if (!status.running || status.pid !== pid) return;
|
|
134
|
+
await wait(pollIntervalMs);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
throw new Error(`Arisa process ${pid} did not stop within ${timeoutMs}ms`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function restartService({
|
|
141
|
+
verbose = true,
|
|
142
|
+
cliArgs = [],
|
|
143
|
+
shutdownTimeoutMs,
|
|
144
|
+
shutdownPollIntervalMs
|
|
145
|
+
} = {}, {
|
|
146
|
+
stop = stopService,
|
|
147
|
+
getStatus = getServiceStatus,
|
|
148
|
+
start = startService,
|
|
149
|
+
sleep: wait = sleep
|
|
150
|
+
} = {}) {
|
|
151
|
+
requirePositiveTiming(shutdownTimeoutMs, "shutdownTimeoutMs");
|
|
152
|
+
requirePositiveTiming(shutdownPollIntervalMs, "shutdownPollIntervalMs");
|
|
153
|
+
|
|
154
|
+
const stopped = await stop();
|
|
155
|
+
if (!stopped.ok && stopped.reason !== "not-running") return stopped;
|
|
156
|
+
|
|
157
|
+
if (stopped.ok) {
|
|
158
|
+
await waitForServiceStop({
|
|
159
|
+
pid: stopped.pid,
|
|
160
|
+
timeoutMs: shutdownTimeoutMs,
|
|
161
|
+
pollIntervalMs: shutdownPollIntervalMs,
|
|
162
|
+
getStatus,
|
|
163
|
+
wait
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const started = await start({ verbose, cliArgs });
|
|
168
|
+
return {
|
|
169
|
+
...started,
|
|
170
|
+
previousPid: stopped.ok ? stopped.pid : null,
|
|
171
|
+
wasRunning: stopped.ok
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
77
175
|
export async function unregisterServiceProcess() {
|
|
78
176
|
await rm(servicePidFile, { force: true }).catch(() => {});
|
|
79
177
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { superviseDaemon } from "../core/tools/daemon-health.js";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
daemonPaths,
|
|
6
|
+
listRegisteredDaemons,
|
|
7
|
+
readDaemonDiagnostic,
|
|
8
|
+
writeDaemonStatus
|
|
9
|
+
} from "../core/tools/daemon-processes.js";
|
|
4
10
|
import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
|
|
5
11
|
import { ensureArisaHome } from "./paths.js";
|
|
6
12
|
|
|
@@ -13,41 +19,127 @@ async function fileExists(file) {
|
|
|
13
19
|
}
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
function oneLine(value) {
|
|
23
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatDaemonOutcome(record, outcome, diagnostic, policy) {
|
|
27
|
+
const details = [`${record.toolName} (${record.instanceId || "global"}): ${outcome}`];
|
|
28
|
+
if (diagnostic.lastError?.message) {
|
|
29
|
+
details.push(`error[${diagnostic.lastError.phase || "unknown"}]=${oneLine(diagnostic.lastError.message)}`);
|
|
30
|
+
if (diagnostic.lastError.code) details.push(`code=${diagnostic.lastError.code}`);
|
|
31
|
+
} else if (diagnostic.message) {
|
|
32
|
+
details.push(`message=${oneLine(diagnostic.message)}`);
|
|
33
|
+
}
|
|
34
|
+
if (diagnostic.restart.attempts > 0) {
|
|
35
|
+
details.push(`restarts=${diagnostic.restart.attempts}/${policy.restartLimit}`);
|
|
36
|
+
}
|
|
37
|
+
if (diagnostic.restart.nextAt) details.push(`next=${diagnostic.restart.nextAt}`);
|
|
38
|
+
details.push(`action=${diagnostic.disposition}`);
|
|
39
|
+
if (diagnostic.disposition === "requires-attention") details.push(`log=${diagnostic.logFile}`);
|
|
40
|
+
return details.join(" | ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateRegistration(record, toolRegistry) {
|
|
44
|
+
if (!toolRegistry) return { valid: true, record };
|
|
45
|
+
const tool = toolRegistry.get(record.toolName);
|
|
46
|
+
if (!tool) return { valid: false, reason: "tool is no longer installed" };
|
|
47
|
+
if (!tool.daemon) return { valid: false, reason: "tool no longer declares a daemon" };
|
|
48
|
+
const expectedScope = tool.daemon.scope === "chat" ? "chat" : "global";
|
|
49
|
+
if (record.scope?.type !== expectedScope) {
|
|
50
|
+
return { valid: false, reason: `registered ${record.scope?.type || "unknown"} scope does not match manifest ${expectedScope} scope` };
|
|
51
|
+
}
|
|
52
|
+
if (path.resolve(record.entryPath) !== path.resolve(tool.entry)) {
|
|
53
|
+
return { valid: false, reason: "registered entry does not match the installed tool" };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
valid: true,
|
|
57
|
+
record: { ...record, autoStart: Boolean(tool.daemon.autoStart) }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createToolProcessSupervisor({ logger, policy, toolRegistry } = {}) {
|
|
17
62
|
let running = false;
|
|
18
63
|
let timer = null;
|
|
19
64
|
let reconciliation = null;
|
|
20
65
|
let daemonPolicy = policy;
|
|
66
|
+
const reportedDiagnostics = new Map();
|
|
21
67
|
|
|
22
68
|
function reportLoopError(error) {
|
|
23
69
|
logger?.error?.("tools", `daemon supervisor loop failed: ${error?.message || error}`);
|
|
24
70
|
}
|
|
25
71
|
|
|
26
|
-
async function reconcileDaemons() {
|
|
72
|
+
async function reconcileDaemons({ repairFailed = false } = {}) {
|
|
27
73
|
await ensureArisaHome();
|
|
28
|
-
|
|
74
|
+
const results = [];
|
|
75
|
+
for (const registeredRecord of await listRegisteredDaemons()) {
|
|
76
|
+
const validation = validateRegistration(registeredRecord, toolRegistry);
|
|
77
|
+
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
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const record = validation.record;
|
|
29
88
|
if (!(await fileExists(record.entryPath))) {
|
|
30
89
|
logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
|
|
90
|
+
results.push({ record, outcome: "missing-entry", diagnostic: await readDaemonDiagnostic(record) });
|
|
31
91
|
continue;
|
|
32
92
|
}
|
|
33
93
|
try {
|
|
94
|
+
const current = await readDaemonDiagnostic(record);
|
|
95
|
+
if (repairFailed && current.state === "failed") {
|
|
96
|
+
await writeDaemonStatus(daemonPaths(record), {
|
|
97
|
+
state: "restarting",
|
|
98
|
+
pid: null,
|
|
99
|
+
heartbeatAt: null,
|
|
100
|
+
restartAttempts: 0,
|
|
101
|
+
restartRequested: true,
|
|
102
|
+
nextRestartAt: null,
|
|
103
|
+
message: "Manual repair requested by /doctor"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
34
106
|
const outcome = await superviseDaemon(record, daemonPolicy);
|
|
35
|
-
|
|
36
|
-
|
|
107
|
+
const key = `${record.toolName}:${record.instanceId || "global"}`;
|
|
108
|
+
if (outcome === "healthy") {
|
|
109
|
+
reportedDiagnostics.delete(key);
|
|
110
|
+
results.push({ record, outcome, diagnostic: await readDaemonDiagnostic(record) });
|
|
111
|
+
continue;
|
|
37
112
|
}
|
|
113
|
+
const diagnostic = await readDaemonDiagnostic(record);
|
|
114
|
+
results.push({ record, outcome, diagnostic });
|
|
115
|
+
const message = formatDaemonOutcome(record, outcome, diagnostic, daemonPolicy);
|
|
116
|
+
if (reportedDiagnostics.get(key) === message) continue;
|
|
117
|
+
reportedDiagnostics.set(key, message);
|
|
118
|
+
logger?.log("tools", message);
|
|
38
119
|
} catch (error) {
|
|
39
120
|
logger?.error?.("tools", `daemon supervision failed for ${record.toolName}: ${error?.message || error}`);
|
|
121
|
+
results.push({ record, outcome: "error", error: error?.message || String(error) });
|
|
40
122
|
}
|
|
41
123
|
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function runReconciliation(options) {
|
|
128
|
+
while (reconciliation) await reconciliation.catch(() => {});
|
|
129
|
+
const current = reconcileDaemons(options);
|
|
130
|
+
reconciliation = current;
|
|
131
|
+
try {
|
|
132
|
+
return await current;
|
|
133
|
+
} finally {
|
|
134
|
+
if (reconciliation === current) reconciliation = null;
|
|
135
|
+
}
|
|
42
136
|
}
|
|
43
137
|
|
|
44
138
|
async function runLoop() {
|
|
45
|
-
if (!running
|
|
46
|
-
reconciliation = reconcileDaemons();
|
|
139
|
+
if (!running) return;
|
|
47
140
|
try {
|
|
48
|
-
await
|
|
141
|
+
await runReconciliation();
|
|
49
142
|
} finally {
|
|
50
|
-
reconciliation = null;
|
|
51
143
|
if (running) {
|
|
52
144
|
timer = setTimeout(() => {
|
|
53
145
|
runLoop().catch(reportLoopError);
|
|
@@ -70,6 +162,11 @@ export function createToolProcessSupervisor({ logger, policy } = {}) {
|
|
|
70
162
|
clearTimeout(timer);
|
|
71
163
|
timer = null;
|
|
72
164
|
await reconciliation?.catch(() => {});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async repair() {
|
|
168
|
+
daemonPolicy ||= await loadDaemonPolicy();
|
|
169
|
+
return runReconciliation({ repairFailed: true });
|
|
73
170
|
}
|
|
74
171
|
};
|
|
75
172
|
}
|