arisa 4.2.6 → 4.3.0
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 +15 -0
- package/README.md +3 -0
- package/package.json +2 -2
- package/pnpm-workspace.yaml +6 -0
- package/src/core/artifacts/artifact-store.js +32 -2
- package/src/core/config/config-defaults.js +25 -0
- package/src/core/config/config-store.js +4 -3
- package/src/core/tools/daemon-health.js +185 -0
- package/src/core/tools/daemon-policy.js +10 -0
- package/src/core/tools/daemon-processes.js +269 -44
- package/src/core/tools/daemon-runtime.js +264 -49
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +1 -1
- package/src/runtime/paths.js +29 -0
- package/src/runtime/tool-process-supervisor.js +38 -26
- package/test/artifact-store.test.js +39 -2
- package/test/daemon-catalog-conformance.test.js +28 -0
- package/test/daemon-runtime.test.js +188 -0
- package/test-fixtures/fake-daemon.js +47 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const homeDir = await mkdtemp(path.join(os.tmpdir(), "arisa-daemon-test-"));
|
|
9
|
+
process.env.ARISA_HOME = homeDir;
|
|
10
|
+
|
|
11
|
+
const policy = {
|
|
12
|
+
supervisorIntervalMs: 20,
|
|
13
|
+
heartbeatIntervalMs: 20,
|
|
14
|
+
heartbeatStaleMs: 150,
|
|
15
|
+
healthIntervalMs: 100,
|
|
16
|
+
healthTimeoutMs: 1_000,
|
|
17
|
+
healthRetryLimit: 1,
|
|
18
|
+
healthRetryBackoffMs: 10,
|
|
19
|
+
restartLimit: 2,
|
|
20
|
+
restartBackoffMs: 20,
|
|
21
|
+
restartBackoffMaxMs: 40,
|
|
22
|
+
startupTimeoutMs: 2_000,
|
|
23
|
+
stopTimeoutMs: 300,
|
|
24
|
+
queuePollIntervalMs: 10
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
await mkdir(path.join(homeDir, "state"), { recursive: true });
|
|
28
|
+
await writeFile(
|
|
29
|
+
path.join(homeDir, "state", "config.json"),
|
|
30
|
+
`${JSON.stringify({ daemons: policy }, null, 2)}\n`,
|
|
31
|
+
"utf8"
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
daemonPaths,
|
|
36
|
+
isProcessAlive,
|
|
37
|
+
readJson,
|
|
38
|
+
stopManagedDaemon
|
|
39
|
+
} = await import("../src/core/tools/daemon-processes.js");
|
|
40
|
+
const {
|
|
41
|
+
createDaemonRuntime,
|
|
42
|
+
isDaemonReady
|
|
43
|
+
} = await import("../src/core/tools/daemon-runtime.js");
|
|
44
|
+
const { createToolProcessSupervisor } = await import("../src/runtime/tool-process-supervisor.js");
|
|
45
|
+
|
|
46
|
+
const fixtureEntry = fileURLToPath(new URL("../test-fixtures/fake-daemon.js", import.meta.url));
|
|
47
|
+
|
|
48
|
+
function runtimeFor(scope, options = {}) {
|
|
49
|
+
return createDaemonRuntime({
|
|
50
|
+
toolName: "fake-daemon",
|
|
51
|
+
entryPath: fixtureEntry,
|
|
52
|
+
scope,
|
|
53
|
+
startupContext: options.startupContext || { health: "ok" },
|
|
54
|
+
autoStart: options.autoStart ?? false
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitFor(check, timeoutMs = 3_000) {
|
|
59
|
+
const startedAt = Date.now();
|
|
60
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
61
|
+
const result = await check();
|
|
62
|
+
if (result) return result;
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Condition was not met after ${timeoutMs}ms`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test.after(async () => {
|
|
69
|
+
for (const scope of [{ type: "global" }, { type: "chat", chatId: "101" }, { type: "chat", chatId: "202" }]) {
|
|
70
|
+
await stopManagedDaemon({ toolName: "fake-daemon", scope }).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("runs health through the queue before accepting jobs", async () => {
|
|
76
|
+
const runtime = runtimeFor({ type: "global" });
|
|
77
|
+
const output = await runtime.submit({ value: "hello" }, { timeoutMs: 1_000 });
|
|
78
|
+
assert.deepEqual(output, { echo: "hello" });
|
|
79
|
+
|
|
80
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
81
|
+
const pid = await runtime.getPid();
|
|
82
|
+
assert.equal(status.state, "ready");
|
|
83
|
+
assert.ok(status.heartbeatAt);
|
|
84
|
+
assert.ok(status.lastHealthSuccessAt);
|
|
85
|
+
assert.ok(status.lastSuccessfulJobAt);
|
|
86
|
+
assert.equal(isDaemonReady(status, pid, policy), true);
|
|
87
|
+
|
|
88
|
+
await assert.rejects(
|
|
89
|
+
() => runtime.submit({ action: "fail" }, { timeoutMs: 1_000 }),
|
|
90
|
+
/synthetic job failure/
|
|
91
|
+
);
|
|
92
|
+
const failedStatus = await readJson(runtime.paths.statusFile, {});
|
|
93
|
+
assert.equal(failedStatus.lastSuccessfulJobAt, status.lastSuccessfulJobAt);
|
|
94
|
+
assert.equal(failedStatus.lastError.phase, "job");
|
|
95
|
+
|
|
96
|
+
await runtime.stop();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("isolates daemon process files and context by chat scope", async () => {
|
|
100
|
+
const first = runtimeFor({ type: "chat", chatId: "101" });
|
|
101
|
+
const second = runtimeFor({ type: "chat", chatId: "202" });
|
|
102
|
+
await first.submit({ value: "first" }, { timeoutMs: 2_000 });
|
|
103
|
+
await second.submit({ value: "second" }, { timeoutMs: 2_000 });
|
|
104
|
+
|
|
105
|
+
const firstPid = await first.getPid();
|
|
106
|
+
const secondPid = await second.getPid();
|
|
107
|
+
assert.notEqual(firstPid, secondPid);
|
|
108
|
+
assert.notEqual(first.paths.root, second.paths.root);
|
|
109
|
+
|
|
110
|
+
const firstMeta = JSON.parse(await readFile(first.paths.metaFile, "utf8"));
|
|
111
|
+
const secondMeta = JSON.parse(await readFile(second.paths.metaFile, "utf8"));
|
|
112
|
+
assert.deepEqual(firstMeta.scope, { type: "chat", chatId: "101" });
|
|
113
|
+
assert.deepEqual(secondMeta.scope, { type: "chat", chatId: "202" });
|
|
114
|
+
assert.deepEqual(firstMeta.startupContext, { health: "ok" });
|
|
115
|
+
|
|
116
|
+
await Promise.all([first.stop(), second.stop()]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("rejects stale readiness even when the pid is alive", async () => {
|
|
120
|
+
const status = {
|
|
121
|
+
state: "ready",
|
|
122
|
+
heartbeatAt: new Date(Date.now() - policy.heartbeatStaleMs - 1).toISOString(),
|
|
123
|
+
lastHealthSuccessAt: new Date().toISOString()
|
|
124
|
+
};
|
|
125
|
+
assert.equal(isDaemonReady(status, process.pid, policy), false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("does not auto-start an intentionally stopped on-demand daemon", async () => {
|
|
129
|
+
const runtime = runtimeFor({ type: "global" }, { autoStart: false });
|
|
130
|
+
await runtime.submit({ value: "once" }, { timeoutMs: 2_000 });
|
|
131
|
+
await runtime.stop();
|
|
132
|
+
|
|
133
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
134
|
+
await supervisor.start();
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, policy.supervisorIntervalMs * 3));
|
|
136
|
+
assert.equal(isProcessAlive(await runtime.getPid()), false);
|
|
137
|
+
assert.equal((await readJson(runtime.paths.statusFile, {})).state, "stopped");
|
|
138
|
+
await supervisor.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("external supervisor recovers internally before restarting", async () => {
|
|
142
|
+
const runtime = runtimeFor({ type: "global" }, {
|
|
143
|
+
autoStart: true,
|
|
144
|
+
startupContext: { health: "fail", recover: true }
|
|
145
|
+
});
|
|
146
|
+
await runtime.start();
|
|
147
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
148
|
+
await supervisor.start();
|
|
149
|
+
|
|
150
|
+
const status = await waitFor(async () => {
|
|
151
|
+
const current = await readJson(runtime.paths.statusFile, {});
|
|
152
|
+
return current.state === "ready" ? current : null;
|
|
153
|
+
});
|
|
154
|
+
assert.equal(status.state, "ready");
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
JSON.parse(await readFile(path.join(runtime.paths.root, "recovered.json"), "utf8")),
|
|
157
|
+
{ recovered: true }
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
await supervisor.stop();
|
|
161
|
+
await runtime.stop();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("external supervisor recreates a dead process with the same context", async () => {
|
|
165
|
+
const scope = { type: "chat", chatId: "101" };
|
|
166
|
+
const runtime = runtimeFor(scope, {
|
|
167
|
+
autoStart: true,
|
|
168
|
+
startupContext: { health: "ok", marker: "preserved" }
|
|
169
|
+
});
|
|
170
|
+
await runtime.submit({ value: "before" }, { timeoutMs: 1_000 });
|
|
171
|
+
const oldPid = await runtime.getPid();
|
|
172
|
+
process.kill(oldPid, "SIGKILL");
|
|
173
|
+
await waitFor(() => !isProcessAlive(oldPid));
|
|
174
|
+
|
|
175
|
+
const supervisor = createToolProcessSupervisor({ policy });
|
|
176
|
+
await supervisor.start();
|
|
177
|
+
const newPid = await waitFor(async () => {
|
|
178
|
+
const pid = (await readJson(daemonPaths({ toolName: "fake-daemon", scope }).pidFile, {})).pid;
|
|
179
|
+
const status = await readJson(runtime.paths.statusFile, {});
|
|
180
|
+
return pid && pid !== oldPid && status.state === "ready" ? pid : null;
|
|
181
|
+
});
|
|
182
|
+
assert.notEqual(newPid, oldPid);
|
|
183
|
+
const meta = await readJson(runtime.paths.metaFile, {});
|
|
184
|
+
assert.deepEqual(meta.startupContext, { health: "ok", marker: "preserved" });
|
|
185
|
+
|
|
186
|
+
await supervisor.stop();
|
|
187
|
+
await runtime.stop();
|
|
188
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import {
|
|
3
|
+
readDaemonLaunchContext,
|
|
4
|
+
writeJson
|
|
5
|
+
} from "../src/core/tools/daemon-processes.js";
|
|
6
|
+
import { createDaemonRuntime } from "../src/core/tools/daemon-runtime.js";
|
|
7
|
+
|
|
8
|
+
const toolName = "fake-daemon";
|
|
9
|
+
const entryPath = fileURLToPath(import.meta.url);
|
|
10
|
+
const launch = await readDaemonLaunchContext({ expectedToolName: toolName });
|
|
11
|
+
const runtime = createDaemonRuntime({
|
|
12
|
+
toolName,
|
|
13
|
+
entryPath,
|
|
14
|
+
scope: launch.scope,
|
|
15
|
+
startupContext: launch.startupContext,
|
|
16
|
+
autoStart: launch.autoStart
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function healthCheck() {
|
|
20
|
+
if (launch.startupContext.health === "fail") {
|
|
21
|
+
throw new Error("synthetic health failure");
|
|
22
|
+
}
|
|
23
|
+
if (launch.startupContext.health === "hang") {
|
|
24
|
+
await new Promise(() => {});
|
|
25
|
+
}
|
|
26
|
+
return { message: "synthetic health passed" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function recover() {
|
|
30
|
+
if (!launch.startupContext.recover) return false;
|
|
31
|
+
await writeJson(`${runtime.paths.root}/recovered.json`, { recovered: true });
|
|
32
|
+
launch.startupContext.health = "ok";
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.argv[2] !== "daemon") {
|
|
37
|
+
throw new Error("fake daemon only supports the daemon command");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await runtime.workLoop({
|
|
41
|
+
healthCheck,
|
|
42
|
+
recover,
|
|
43
|
+
processJob: async (payload) => {
|
|
44
|
+
if (payload.action === "fail") throw new Error("synthetic job failure");
|
|
45
|
+
return { echo: payload.value ?? null };
|
|
46
|
+
}
|
|
47
|
+
});
|