arisa 5.1.49 → 5.1.64
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 +0 -2
- package/README.md +9 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +49 -489
- package/src/core/agent/agent-session-lifecycle.js +181 -0
- package/src/core/agent/pi-capability-tools.js +183 -0
- package/src/core/artifacts/artifact-store.js +73 -17
- package/src/core/capabilities/capability-service.js +340 -0
- package/src/core/config/config-defaults.js +28 -1
- package/src/core/tasks/task-routing.js +7 -0
- package/src/core/tasks/task-runner.js +68 -0
- package/src/core/tasks/task-store.js +382 -92
- package/src/core/tools/tool-output-materializer.js +5 -5
- package/src/core/tools/tool-registry.js +20 -5
- package/src/core/tools/weighted-resource-governor.js +153 -0
- package/src/index.js +20 -0
- package/src/official-tools.lock.json +62 -45
- package/src/runtime/arisa-capabilities.js +51 -242
- package/src/runtime/create-app.js +11 -2
- package/src/runtime/create-headless-app.js +7 -4
- package/src/runtime/paths.js +4 -0
- package/src/runtime/service-manager.js +3 -1
- package/src/runtime/service-supervisor.js +98 -0
- package/src/transport/telegram/bot.js +186 -374
- package/src/transport/telegram/chat-queue.js +83 -6
- package/src/transport/telegram/prompt-builders.js +9 -0
- package/src/transport/telegram/reply-topic-routing.js +111 -0
- package/src/transport/telegram/task-dispatcher.js +96 -36
- package/src/transport/telegram/telegram-auth-controller.js +180 -0
- package/src/transport/telegram/telegram-session-bridge.js +177 -0
- package/src/transport/telegram/telegram-tools-command.js +28 -0
- package/src/transport/telegram/telegram-workspace-controller.js +66 -0
- package/src/transport/telegram/workspace-topic-store.js +228 -0
- package/test/agent-session-lifecycle.test.js +58 -0
- package/test/artifact-store.test.js +38 -2
- package/test/capabilities-security.test.js +58 -0
- package/test/chat-queue.test.js +32 -0
- package/test/context-and-task-bounds.test.js +76 -1
- package/test/device-code-message.test.js +9 -0
- package/test/media-caption.test.js +1 -1
- package/test/model-selection.test.js +9 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/paths.test.js +8 -0
- package/test/pi-capability-tools.test.js +65 -0
- package/test/service-manager.test.js +48 -0
- package/test/session-start-operational-notes.test.js +1 -1
- package/test/task-idempotency.test.js +40 -0
- package/test/task-routing.test.js +62 -0
- package/test/task-store.test.js +231 -7
- package/test/telegram-reply-topic-routing.test.js +94 -0
- package/test/telegram-task-dispatcher.test.js +150 -23
- package/test/telegram-text-artifact.test.js +13 -2
- package/test/telegram-tools-command.test.js +47 -0
- package/test/telegram-workspace-topic-store.test.js +124 -0
- package/test/tool-registry-run.test.js +41 -0
- package/test/weighted-resource-governor.test.js +95 -0
|
@@ -1,251 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
throw new Error(`${fieldName} is required`);
|
|
28
|
-
}
|
|
29
|
-
return value;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function normalizeArgs(args) {
|
|
33
|
-
if (args == null) return {};
|
|
34
|
-
if (typeof args !== "object" || Array.isArray(args)) {
|
|
35
|
-
throw new Error("args must be an object");
|
|
36
|
-
}
|
|
37
|
-
return args;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function normalizeLimit(limit) {
|
|
41
|
-
const value = Number(limit);
|
|
42
|
-
if (!Number.isInteger(value) || value <= 0) return 20;
|
|
43
|
-
return Math.min(value, 100);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function normalizeAcknowledgement(value) {
|
|
47
|
-
if (value == null || value === "") return "";
|
|
48
|
-
const acknowledgement = requireString(value, "acknowledgement").trim();
|
|
49
|
-
if (acknowledgement.length > 500) throw new Error("acknowledgement must be at most 500 characters");
|
|
50
|
-
return acknowledgement;
|
|
51
|
-
}
|
|
1
|
+
import { createCapabilityService } from "../core/capabilities/capability-service.js";
|
|
2
|
+
|
|
3
|
+
const ipcMethods = new Set([
|
|
4
|
+
"tools.list",
|
|
5
|
+
"tools.help",
|
|
6
|
+
"tools.skills",
|
|
7
|
+
"tools.setConfig",
|
|
8
|
+
"tools.setResourceNote",
|
|
9
|
+
"tools.getResourceNote",
|
|
10
|
+
"tools.run",
|
|
11
|
+
"tools.installOfficial",
|
|
12
|
+
"artifacts.createText",
|
|
13
|
+
"artifacts.listRecent",
|
|
14
|
+
"artifacts.get",
|
|
15
|
+
"artifacts.deliver",
|
|
16
|
+
"tasks.add",
|
|
17
|
+
"tasks.list",
|
|
18
|
+
"tasks.cancel",
|
|
19
|
+
"tasks.cancelAll",
|
|
20
|
+
"agent.enqueueEvent",
|
|
21
|
+
"paths.getChatToolStateDir",
|
|
22
|
+
"paths.getToolStateDir",
|
|
23
|
+
"paths.getChatToolTmpDir",
|
|
24
|
+
"paths.getToolTmpDir",
|
|
25
|
+
"paths.getChatArtifactsDir"
|
|
26
|
+
]);
|
|
52
27
|
|
|
53
28
|
export function createArisaCapabilities({
|
|
29
|
+
capabilityService,
|
|
54
30
|
artifactStore,
|
|
55
31
|
taskStore,
|
|
56
32
|
toolRegistry,
|
|
57
33
|
agentManager,
|
|
58
|
-
resourceNotes
|
|
59
|
-
installOfficialTool
|
|
34
|
+
resourceNotes,
|
|
35
|
+
installOfficialTool,
|
|
36
|
+
logger
|
|
60
37
|
} = {}) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await toolRegistry.load();
|
|
81
|
-
return toolRegistry.setConfig(
|
|
82
|
-
requireString(params.name, "name"),
|
|
83
|
-
requireString(params.field, "field"),
|
|
84
|
-
requireString(params.value, "value"),
|
|
85
|
-
requireChatId(chatId, method)
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (method === "tools.setResourceNote") {
|
|
90
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
91
|
-
return resourceNotes.set(
|
|
92
|
-
scopedChatId,
|
|
93
|
-
scopedToolName,
|
|
94
|
-
requireString(params.resourceId, "resourceId"),
|
|
95
|
-
String(params.note ?? "")
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (method === "tools.getResourceNote") {
|
|
100
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
101
|
-
return {
|
|
102
|
-
toolName: scopedToolName,
|
|
103
|
-
resourceId: requireString(params.resourceId, "resourceId"),
|
|
104
|
-
note: await resourceNotes.get(scopedChatId, scopedToolName, params.resourceId)
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (method === "tools.run") {
|
|
109
|
-
if (!agentManager?.runTool) {
|
|
110
|
-
throw new Error("tools.run requires agentManager");
|
|
111
|
-
}
|
|
112
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
113
|
-
const targetToolName = requireString(params.name, "name");
|
|
114
|
-
const chatArtifactStore = artifactStore.forChat(scopedChatId);
|
|
115
|
-
const artifact = params.artifactId
|
|
116
|
-
? await chatArtifactStore.get(requireString(params.artifactId, "artifactId"))
|
|
117
|
-
: null;
|
|
118
|
-
if (params.artifactId && !artifact) {
|
|
119
|
-
throw new Error(`Artifact not found: ${params.artifactId}`);
|
|
38
|
+
const service = capabilityService || createCapabilityService({
|
|
39
|
+
artifactStore,
|
|
40
|
+
taskStore,
|
|
41
|
+
toolRegistry,
|
|
42
|
+
toolExecutor: agentManager,
|
|
43
|
+
resourceNotes,
|
|
44
|
+
installOfficialTool,
|
|
45
|
+
logger
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
dispatch: ({ method, toolName, chatId = null, params = {} } = {}) => service.execute({
|
|
50
|
+
method,
|
|
51
|
+
actorToolName: toolName,
|
|
52
|
+
chatId,
|
|
53
|
+
params,
|
|
54
|
+
context: {
|
|
55
|
+
allowedMethods: ipcMethods,
|
|
56
|
+
unknownMethodLabel: "IPC"
|
|
120
57
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
name: targetToolName,
|
|
124
|
-
request: {
|
|
125
|
-
artifact,
|
|
126
|
-
text: params.text,
|
|
127
|
-
resourceId: params.resourceId,
|
|
128
|
-
args: normalizeArgs(params.args)
|
|
129
|
-
},
|
|
130
|
-
chatId: scopedChatId
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (method === "tools.installOfficial") {
|
|
135
|
-
const name = requireString(params.name, "name");
|
|
136
|
-
if (params.confirmName !== name) {
|
|
137
|
-
throw new Error("tools.installOfficial requires confirmName equal to name");
|
|
138
|
-
}
|
|
139
|
-
const installed = await installOfficialTool(name);
|
|
140
|
-
await toolRegistry.load();
|
|
141
|
-
return installed;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (method === "artifacts.createText") {
|
|
145
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
146
|
-
return artifactStore.forChat(scopedChatId).createText({
|
|
147
|
-
text: requireString(params.text, "text"),
|
|
148
|
-
mimeType: params.mimeType || "text/plain",
|
|
149
|
-
source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId },
|
|
150
|
-
metadata: params.metadata || {}
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (method === "artifacts.listRecent") {
|
|
155
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
156
|
-
return artifactStore.forChat(scopedChatId).listRecent(normalizeLimit(params.limit));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (method === "artifacts.get") {
|
|
160
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
161
|
-
return artifactStore.forChat(scopedChatId).get(requireString(params.artifactId, "artifactId"));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (method === "artifacts.deliver") {
|
|
165
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
166
|
-
const artifactId = requireString(params.artifactId, "artifactId");
|
|
167
|
-
const artifact = await artifactStore.forChat(scopedChatId).get(artifactId);
|
|
168
|
-
if (!artifact?.path) throw new Error(`Artifact not found or has no file: ${artifactId}`);
|
|
169
|
-
if (!agentManager?.deliverArtifact) throw new Error("artifact delivery is unavailable");
|
|
170
|
-
return agentManager.deliverArtifact({
|
|
171
|
-
chatId: scopedChatId,
|
|
172
|
-
artifact,
|
|
173
|
-
caption: params.caption,
|
|
174
|
-
method: params.method
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (method === "tasks.add") {
|
|
179
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
180
|
-
return taskStore.add(params.task || {}, {
|
|
181
|
-
payload: { chatId: scopedChatId },
|
|
182
|
-
source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId }
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (method === "tasks.list") {
|
|
187
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
188
|
-
return taskStore.list({
|
|
189
|
-
chatId: scopedChatId,
|
|
190
|
-
status: params.status || undefined,
|
|
191
|
-
kind: params.kind || undefined
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (method === "tasks.cancel") {
|
|
196
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
197
|
-
const taskId = requireString(params.taskId, "taskId");
|
|
198
|
-
const task = await taskStore.get(taskId);
|
|
199
|
-
if (!task) return null;
|
|
200
|
-
if (String(task.payload?.chatId) !== String(scopedChatId)) {
|
|
201
|
-
throw new Error("task does not belong to chatId");
|
|
202
|
-
}
|
|
203
|
-
return taskStore.cancel(taskId);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (method === "tasks.cancelAll") {
|
|
207
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
208
|
-
return taskStore.cancelAll({ chatId: scopedChatId });
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (method === "agent.enqueueEvent") {
|
|
212
|
-
const scopedChatId = requireChatId(chatId, method);
|
|
213
|
-
const resourceId = String(params.resourceId || "").trim();
|
|
214
|
-
return taskStore.add({
|
|
215
|
-
kind: "agent_event",
|
|
216
|
-
payload: {
|
|
217
|
-
prompt: requireString(params.prompt, "prompt"),
|
|
218
|
-
resourceId,
|
|
219
|
-
acknowledgement: normalizeAcknowledgement(params.acknowledgement)
|
|
220
|
-
}
|
|
221
|
-
}, {
|
|
222
|
-
payload: { chatId: scopedChatId },
|
|
223
|
-
source: { type: "tool", toolName: scopedToolName, chatId: scopedChatId, resourceId }
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
if (method === "paths.getChatToolStateDir") {
|
|
228
|
-
return getChatToolStateDir(requireChatId(chatId, method), scopedToolName);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (method === "paths.getToolStateDir") {
|
|
232
|
-
return getToolStateDir(scopedToolName);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (method === "paths.getChatToolTmpDir") {
|
|
236
|
-
return getChatToolTmpDir(requireChatId(chatId, method), scopedToolName);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (method === "paths.getToolTmpDir") {
|
|
240
|
-
return getToolTmpDir(scopedToolName);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (method === "paths.getChatArtifactsDir") {
|
|
244
|
-
return getChatArtifactsDir(requireChatId(chatId, method));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
throw new Error(`unknown IPC method: ${method}`);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return { dispatch };
|
|
58
|
+
})
|
|
59
|
+
};
|
|
251
60
|
}
|
|
@@ -3,6 +3,7 @@ import { ArtifactStore } from "../core/artifacts/artifact-store.js";
|
|
|
3
3
|
import { ToolRegistry } from "../core/tools/tool-registry.js";
|
|
4
4
|
import { TaskStore } from "../core/tasks/task-store.js";
|
|
5
5
|
import { AgentManager } from "../core/agent/agent-manager.js";
|
|
6
|
+
import { createCapabilityService } from "../core/capabilities/capability-service.js";
|
|
6
7
|
import { getErrorMessage, getPiAuthIssue } from "../core/agent/auth-flow.js";
|
|
7
8
|
import { createTelegramBot } from "../transport/telegram/bot.js";
|
|
8
9
|
import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
|
|
@@ -113,14 +114,22 @@ export async function createApp({ logger, runtimeOverrides, requestRestart } = {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
const artifactStore = new ArtifactStore();
|
|
116
|
-
const toolRegistry = new ToolRegistry({ logger });
|
|
117
|
+
const toolRegistry = new ToolRegistry({ logger, executionPolicy: config.toolExecution });
|
|
117
118
|
const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons, toolRegistry });
|
|
118
119
|
const taskStore = new TaskStore();
|
|
119
120
|
await toolRegistry.load();
|
|
120
121
|
logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
|
|
121
122
|
|
|
122
123
|
const agentManager = new AgentManager({ config, artifactStore, toolRegistry, taskStore, logger });
|
|
123
|
-
const
|
|
124
|
+
const capabilityService = createCapabilityService({
|
|
125
|
+
artifactStore,
|
|
126
|
+
taskStore,
|
|
127
|
+
toolRegistry,
|
|
128
|
+
toolExecutor: agentManager,
|
|
129
|
+
logger
|
|
130
|
+
});
|
|
131
|
+
agentManager.setCapabilityService(capabilityService);
|
|
132
|
+
const arisaCapabilities = createArisaCapabilities({ capabilityService });
|
|
124
133
|
const ipcServer = createIpcServer({ capabilities: arisaCapabilities, logger });
|
|
125
134
|
const bot = await createTelegramBot({
|
|
126
135
|
config,
|
|
@@ -3,6 +3,7 @@ import { loadConfig } from "../core/config/config-store.js";
|
|
|
3
3
|
import { TaskStore } from "../core/tasks/task-store.js";
|
|
4
4
|
import { ToolRegistry } from "../core/tools/tool-registry.js";
|
|
5
5
|
import { createDaemonRuntime } from "../core/tools/daemon-runtime.js";
|
|
6
|
+
import { createCapabilityService } from "../core/capabilities/capability-service.js";
|
|
6
7
|
import { createArisaCapabilities } from "./arisa-capabilities.js";
|
|
7
8
|
import { createHeadlessToolExecutor } from "./headless-tool-executor.js";
|
|
8
9
|
import { createIpcServer } from "./ipc/ipc-server.js";
|
|
@@ -13,7 +14,7 @@ export async function createHeadlessApp({
|
|
|
13
14
|
configLoader = loadConfig,
|
|
14
15
|
artifactStoreFactory = () => new ArtifactStore(),
|
|
15
16
|
taskStoreFactory = () => new TaskStore(),
|
|
16
|
-
toolRegistryFactory = () => new ToolRegistry(
|
|
17
|
+
toolRegistryFactory = (options) => new ToolRegistry(options),
|
|
17
18
|
supervisorFactory = (options) => createToolProcessSupervisor(options),
|
|
18
19
|
capabilitiesFactory = (options) => createArisaCapabilities(options),
|
|
19
20
|
ipcServerFactory = (options) => createIpcServer(options)
|
|
@@ -22,16 +23,18 @@ export async function createHeadlessApp({
|
|
|
22
23
|
const config = await configLoader();
|
|
23
24
|
const artifactStore = artifactStoreFactory();
|
|
24
25
|
const taskStore = taskStoreFactory();
|
|
25
|
-
const toolRegistry = toolRegistryFactory();
|
|
26
|
+
const toolRegistry = toolRegistryFactory({ logger, executionPolicy: config.toolExecution });
|
|
26
27
|
await toolRegistry.load();
|
|
27
28
|
const toolProcessSupervisor = supervisorFactory({ logger, policy: config.daemons, toolRegistry });
|
|
28
29
|
const toolExecutor = createHeadlessToolExecutor({ artifactStore, taskStore, toolRegistry });
|
|
29
|
-
const
|
|
30
|
+
const capabilityService = createCapabilityService({
|
|
30
31
|
artifactStore,
|
|
31
32
|
taskStore,
|
|
32
33
|
toolRegistry,
|
|
33
|
-
|
|
34
|
+
toolExecutor,
|
|
35
|
+
logger
|
|
34
36
|
});
|
|
37
|
+
const capabilities = capabilitiesFactory({ capabilityService, agentManager: toolExecutor });
|
|
35
38
|
const ipcServer = ipcServerFactory({ capabilities, logger });
|
|
36
39
|
const autoStartedDaemons = [];
|
|
37
40
|
|
package/src/runtime/paths.js
CHANGED
|
@@ -65,6 +65,10 @@ export function getChatToolResourceNotesFile(chatId) {
|
|
|
65
65
|
return path.join(getChatDir(chatId), "state", "tool-resource-notes.json");
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export function getChatTelegramWorkspacesFile(chatId) {
|
|
69
|
+
return path.join(getChatDir(chatId), "state", "telegram-workspaces.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
export function getChatToolStateDir(chatId, toolName) {
|
|
69
73
|
return path.join(getChatDir(chatId), "state", "tools", requireToolName(toolName));
|
|
70
74
|
}
|
|
@@ -78,7 +78,9 @@ export async function handoffServiceRestart({ verbose = true, cliArgs = [] } = {
|
|
|
78
78
|
} = {}) {
|
|
79
79
|
await ensureHome();
|
|
80
80
|
const status = await getStatus();
|
|
81
|
-
|
|
81
|
+
const supervisorPid = Number.parseInt(environment.ARISA_SUPERVISOR_PID || "", 10);
|
|
82
|
+
const isActiveWorker = Number.isSafeInteger(supervisorPid) && supervisorPid === status.pid;
|
|
83
|
+
if (!status.running || (status.pid !== currentPid && !isActiveWorker)) {
|
|
82
84
|
throw new Error("Service restart handoff requires the active background service process");
|
|
83
85
|
}
|
|
84
86
|
const logHandle = await openLog(serviceLogFile, "a");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function sleep(ms) {
|
|
4
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function errorMessage(error) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
12
|
+
const parsed = Number(value);
|
|
13
|
+
return Number.isSafeInteger(parsed) && parsed >= minimum ? Math.min(parsed, maximum) : fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createServiceSupervisor({
|
|
17
|
+
command,
|
|
18
|
+
args,
|
|
19
|
+
env = process.env,
|
|
20
|
+
restartLimit = 3,
|
|
21
|
+
restartBackoffMs = 2_000,
|
|
22
|
+
restartBackoffMaxMs = 60_000,
|
|
23
|
+
stableRuntimeMs = 60_000,
|
|
24
|
+
logger,
|
|
25
|
+
spawnProcess = spawn,
|
|
26
|
+
wait = sleep
|
|
27
|
+
}) {
|
|
28
|
+
const maximumRestarts = boundedInteger(restartLimit, 3, 0, 10);
|
|
29
|
+
const initialBackoffMs = boundedInteger(restartBackoffMs, 2_000, 1, 60_000);
|
|
30
|
+
const maximumBackoffMs = boundedInteger(restartBackoffMaxMs, 60_000, initialBackoffMs, 5 * 60_000);
|
|
31
|
+
const stableAfterMs = boundedInteger(stableRuntimeMs, 60_000, 1_000, 60 * 60_000);
|
|
32
|
+
let child = null;
|
|
33
|
+
let stopping = false;
|
|
34
|
+
let wakeBackoff = null;
|
|
35
|
+
let resolveDone;
|
|
36
|
+
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
37
|
+
|
|
38
|
+
async function run() {
|
|
39
|
+
let consecutiveFailures = 0;
|
|
40
|
+
while (!stopping) {
|
|
41
|
+
const startedAt = Date.now();
|
|
42
|
+
child = spawnProcess(command, args, {
|
|
43
|
+
stdio: "inherit",
|
|
44
|
+
env: { ...env, ARISA_SUPERVISOR_PID: String(process.pid) }
|
|
45
|
+
});
|
|
46
|
+
logger?.log("service", `worker started (pid ${child.pid})`);
|
|
47
|
+
|
|
48
|
+
const outcome = await new Promise((resolve) => {
|
|
49
|
+
child.once("error", (error) => resolve({ error }));
|
|
50
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
51
|
+
});
|
|
52
|
+
child = null;
|
|
53
|
+
if (stopping) break;
|
|
54
|
+
|
|
55
|
+
const runtimeMs = Date.now() - startedAt;
|
|
56
|
+
if (runtimeMs >= stableAfterMs) consecutiveFailures = 0;
|
|
57
|
+
consecutiveFailures += 1;
|
|
58
|
+
const detail = outcome.error
|
|
59
|
+
? errorMessage(outcome.error)
|
|
60
|
+
: `code=${outcome.code ?? "null"} signal=${outcome.signal || "none"}`;
|
|
61
|
+
logger?.error("service", `worker exited unexpectedly (${detail})`);
|
|
62
|
+
if (consecutiveFailures > maximumRestarts) {
|
|
63
|
+
logger?.error("service", `worker restart limit reached after ${consecutiveFailures} consecutive exits`);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const delay = Math.min(maximumBackoffMs, initialBackoffMs * (2 ** (consecutiveFailures - 1)));
|
|
68
|
+
logger?.log("service", `restarting worker in ${delay}ms`);
|
|
69
|
+
await Promise.race([
|
|
70
|
+
wait(delay),
|
|
71
|
+
new Promise((resolve) => { wakeBackoff = resolve; })
|
|
72
|
+
]);
|
|
73
|
+
wakeBackoff = null;
|
|
74
|
+
}
|
|
75
|
+
resolveDone();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async start() {
|
|
80
|
+
run().catch((error) => {
|
|
81
|
+
logger?.error("service", `supervisor failed: ${errorMessage(error)}`);
|
|
82
|
+
resolveDone();
|
|
83
|
+
});
|
|
84
|
+
return done;
|
|
85
|
+
},
|
|
86
|
+
async stop() {
|
|
87
|
+
stopping = true;
|
|
88
|
+
wakeBackoff?.();
|
|
89
|
+
if (child) {
|
|
90
|
+
const activeChild = child;
|
|
91
|
+
const exited = new Promise((resolve) => activeChild.once("exit", resolve));
|
|
92
|
+
activeChild.kill("SIGTERM");
|
|
93
|
+
await exited;
|
|
94
|
+
}
|
|
95
|
+
resolveDone();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|