arisa 5.1.60 → 5.1.65
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/README.md +9 -0
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +10 -0
- package/src/core/config/config-defaults.js +28 -1
- package/src/core/tasks/task-runner.js +17 -2
- package/src/core/tasks/task-store.js +80 -14
- 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 +57 -43
- package/src/runtime/create-app.js +1 -1
- package/src/runtime/create-headless-app.js +2 -2
- 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 +77 -9
- package/src/transport/telegram/chat-queue.js +13 -2
- package/src/transport/telegram/reply-topic-routing.js +111 -0
- package/src/transport/telegram/task-dispatcher.js +26 -3
- package/src/transport/telegram/telegram-session-bridge.js +7 -0
- package/src/transport/telegram/workspace-topic-store.js +228 -0
- package/test/chat-queue.test.js +32 -0
- package/test/model-selection.test.js +9 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/official-tool-installer.test.js +2 -0
- package/test/paths.test.js +8 -0
- package/test/service-manager.test.js +48 -0
- package/test/task-store.test.js +56 -4
- package/test/telegram-reply-topic-routing.test.js +94 -0
- package/test/telegram-task-dispatcher.test.js +51 -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
package/README.md
CHANGED
|
@@ -61,6 +61,11 @@ The result is a toolset shaped by how you actually use the assistant, not by def
|
|
|
61
61
|
- while a chat is busy, concurrent text steers Pi's active run by default
|
|
62
62
|
- set `telegram.busyMessageMode` to `"queue"` to keep concurrent text messages in order; override one chat with `telegram.chatMeta[chatId].busyMessageMode`
|
|
63
63
|
- media and normalized audio stay queued, and failed steering falls back to the ordered queue
|
|
64
|
+
- an owner-workspace General topic may route only the assistant's visible reply to a matching topic; the incoming message and General session remain unchanged, and ambiguous replies stay in General
|
|
65
|
+
- reply classification never runs in private chats or outside General
|
|
66
|
+
- each owner workspace keeps a dynamic, chat-scoped topic registry; Arisa learns topic creation, rename, close, and reopen events, and records the context of topics it creates or initializes
|
|
67
|
+
- when a substantial theme recurs in General without a matching topic, Arisa may occasionally propose a new one; creation still requires explicit user confirmation
|
|
68
|
+
- legacy `replyTopics` configuration is imported once into the dynamic registry and then removed
|
|
64
69
|
|
|
65
70
|
### Tool model
|
|
66
71
|
No tools ship with the core. All installed tools live under `~/.arisa/tools/<tool-name>`, whether they come from the [official catalog](https://github.com/clasen/Arisa/tree/main/tools), from another source the user chooses, or are created by the agent itself.
|
|
@@ -176,6 +181,10 @@ arisa --silent # run without verbose logs
|
|
|
176
181
|
|
|
177
182
|
Authorized Telegram chats can run the same safe service lifecycle with `/restart`.
|
|
178
183
|
|
|
184
|
+
Background mode runs the Telegram/Pi worker under a lightweight supervisor. Unexpected worker exits use bounded exponential restart backoff. Scheduled agent tasks are serialized FIFO per conversation while different conversations remain independent; execution deadlines default to 15 minutes for scheduled prompts and 5 minutes for agent events. A timed-out turn is marked outcome-uncertain and is never replayed automatically. These policies can be overridden with `service.workerRestart*` and `tasks.*TimeoutMs` in the Arisa config.
|
|
185
|
+
|
|
186
|
+
Tools may declare weighted execution resources in their manifest, for example `"execution": { "resourceClass": "browser", "weight": 1 }`. Runs sharing a declared class queue fairly once they reach its capacity; undeclared lightweight tools remain unconstrained. The default capacity is two per declared class. Override it with `toolExecution.defaultCapacity`, `toolExecution.capacities`, and `toolExecution.maxQueuedPerClass`. Arisa logs queue waits and new worker RSS peaks for operational measurement.
|
|
187
|
+
|
|
179
188
|
Runtime model override (current process only):
|
|
180
189
|
|
|
181
190
|
```bash
|
package/package.json
CHANGED
|
@@ -199,6 +199,16 @@ export class AgentManager {
|
|
|
199
199
|
this.sessionLifecycle.closeCached(String(chatId));
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
async abortSession(chatId) {
|
|
203
|
+
const sessionKey = String(chatId);
|
|
204
|
+
const context = this.sessions.get(sessionKey);
|
|
205
|
+
try {
|
|
206
|
+
await context?.session?.abort?.();
|
|
207
|
+
} finally {
|
|
208
|
+
await this.sessionLifecycle.closeCached(sessionKey);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
202
212
|
getRuntimeDiagnostic() {
|
|
203
213
|
return this.sessionLifecycle.getDiagnostic();
|
|
204
214
|
}
|
|
@@ -16,6 +16,12 @@ export const daemonConfigDefaults = Object.freeze({
|
|
|
16
16
|
ipcFrameBytes: 1_048_576
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
export const toolExecutionConfigDefaults = Object.freeze({
|
|
20
|
+
defaultCapacity: 2,
|
|
21
|
+
maxQueuedPerClass: 100,
|
|
22
|
+
capacities: Object.freeze({})
|
|
23
|
+
});
|
|
24
|
+
|
|
19
25
|
export const telegramConfigDefaults = Object.freeze({
|
|
20
26
|
modelPickerPageSize: 8,
|
|
21
27
|
busyMessageMode: "steer",
|
|
@@ -37,7 +43,16 @@ export const cliLogConfig = Object.freeze({
|
|
|
37
43
|
|
|
38
44
|
export const serviceConfigDefaults = Object.freeze({
|
|
39
45
|
shutdownTimeoutMs: 15_000,
|
|
40
|
-
shutdownPollIntervalMs: 100
|
|
46
|
+
shutdownPollIntervalMs: 100,
|
|
47
|
+
workerRestartLimit: 3,
|
|
48
|
+
workerRestartBackoffMs: 2_000,
|
|
49
|
+
workerRestartBackoffMaxMs: 60_000,
|
|
50
|
+
workerStableRuntimeMs: 60_000
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const taskConfigDefaults = Object.freeze({
|
|
54
|
+
agentTimeoutMs: 15 * 60_000,
|
|
55
|
+
eventTimeoutMs: 5 * 60_000
|
|
41
56
|
});
|
|
42
57
|
|
|
43
58
|
export const piConfigDefaults = Object.freeze({
|
|
@@ -70,6 +85,14 @@ export function applyConfigDefaults(config) {
|
|
|
70
85
|
...telegramConfigDefaults,
|
|
71
86
|
...(config.telegram || {})
|
|
72
87
|
},
|
|
88
|
+
toolExecution: {
|
|
89
|
+
...toolExecutionConfigDefaults,
|
|
90
|
+
...(config.toolExecution || {}),
|
|
91
|
+
capacities: {
|
|
92
|
+
...toolExecutionConfigDefaults.capacities,
|
|
93
|
+
...(config.toolExecution?.capacities || {})
|
|
94
|
+
}
|
|
95
|
+
},
|
|
73
96
|
doctor: {
|
|
74
97
|
...doctorConfigDefaults,
|
|
75
98
|
...(config.doctor || {})
|
|
@@ -78,6 +101,10 @@ export function applyConfigDefaults(config) {
|
|
|
78
101
|
...serviceConfigDefaults,
|
|
79
102
|
...(config.service || {})
|
|
80
103
|
},
|
|
104
|
+
tasks: {
|
|
105
|
+
...taskConfigDefaults,
|
|
106
|
+
...(config.tasks || {})
|
|
107
|
+
},
|
|
81
108
|
pi: {
|
|
82
109
|
...piConfigDefaults,
|
|
83
110
|
...configuredPi,
|
|
@@ -10,7 +10,7 @@ export class NonRetryableTaskError extends Error {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function createTaskRunner({ taskStore, dispatch, onTerminalFailure, logger, claimLimit = 10 }) {
|
|
13
|
+
export function createTaskRunner({ taskStore, dispatch, laneKey = (task) => task.id, onTerminalFailure, logger, claimLimit = 10 }) {
|
|
14
14
|
if (!taskStore || typeof dispatch !== "function") {
|
|
15
15
|
throw new Error("Task runner requires taskStore and dispatch");
|
|
16
16
|
}
|
|
@@ -27,8 +27,11 @@ export function createTaskRunner({ taskStore, dispatch, onTerminalFailure, logge
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const lanes = new Map();
|
|
31
|
+
|
|
32
|
+
async function executeClaimedTask(task) {
|
|
31
33
|
try {
|
|
34
|
+
await taskStore.markExecutionStarted?.(task.id);
|
|
32
35
|
await dispatch(task);
|
|
33
36
|
await taskStore.complete(task.id);
|
|
34
37
|
logger?.log("tasks", `task ${task.id} completed after confirmed execution`);
|
|
@@ -44,6 +47,18 @@ export function createTaskRunner({ taskStore, dispatch, onTerminalFailure, logge
|
|
|
44
47
|
}
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
function runClaimedTask(task) {
|
|
51
|
+
const key = String(laneKey(task));
|
|
52
|
+
const previous = lanes.get(key) || Promise.resolve();
|
|
53
|
+
const running = previous.catch(() => {}).then(() => executeClaimedTask(task));
|
|
54
|
+
lanes.set(key, running);
|
|
55
|
+
running.then(
|
|
56
|
+
() => { if (lanes.get(key) === running) lanes.delete(key); },
|
|
57
|
+
() => { if (lanes.get(key) === running) lanes.delete(key); }
|
|
58
|
+
);
|
|
59
|
+
return running;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
async function dispatchDueTasks() {
|
|
48
63
|
const tasks = await taskStore.claimDue(claimLimit);
|
|
49
64
|
return Promise.all(tasks.map(runClaimedTask));
|
|
@@ -144,6 +144,9 @@ function normalizeTask(task, defaults = {}) {
|
|
|
144
144
|
...(defaults.source || {}),
|
|
145
145
|
...(task.source || {})
|
|
146
146
|
},
|
|
147
|
+
...(task.startedAt ? { startedAt: task.startedAt } : {}),
|
|
148
|
+
...(task.claimedAt ? { claimedAt: task.claimedAt } : {}),
|
|
149
|
+
...(task.executionStartedAt ? { executionStartedAt: task.executionStartedAt } : {}),
|
|
147
150
|
attempts: task.attempts || 0,
|
|
148
151
|
retry: task.retry || defaults.retry
|
|
149
152
|
});
|
|
@@ -228,21 +231,37 @@ export class TaskStore {
|
|
|
228
231
|
async claimDue(limit = 10) {
|
|
229
232
|
return this.mutate(async (tasks) => {
|
|
230
233
|
const now = Date.now();
|
|
231
|
-
const due =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
234
|
+
const due = tasks
|
|
235
|
+
.filter((task) => task.status === "pending"
|
|
236
|
+
&& task.runAt
|
|
237
|
+
&& !Number.isNaN(Date.parse(task.runAt))
|
|
238
|
+
&& Date.parse(task.runAt) <= now)
|
|
239
|
+
.sort((left, right) => Date.parse(left.runAt) - Date.parse(right.runAt)
|
|
240
|
+
|| Date.parse(left.createdAt || 0) - Date.parse(right.createdAt || 0)
|
|
241
|
+
|| String(left.id).localeCompare(String(right.id)))
|
|
242
|
+
.slice(0, limit);
|
|
243
|
+
|
|
244
|
+
const claimedAt = new Date(now).toISOString();
|
|
245
|
+
for (const task of due) {
|
|
238
246
|
task.status = "running";
|
|
239
247
|
task.attempts += 1;
|
|
240
|
-
task.startedAt =
|
|
241
|
-
task.
|
|
242
|
-
|
|
248
|
+
task.startedAt = claimedAt;
|
|
249
|
+
task.claimedAt = claimedAt;
|
|
250
|
+
delete task.executionStartedAt;
|
|
251
|
+
task.updatedAt = claimedAt;
|
|
243
252
|
}
|
|
244
253
|
|
|
245
|
-
return { result: due, changed: due.length > 0 };
|
|
254
|
+
return { result: structuredClone(due), changed: due.length > 0 };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async markExecutionStarted(taskId) {
|
|
259
|
+
return this.mutate(async (tasks) => {
|
|
260
|
+
const task = tasks.find((item) => item.id === taskId);
|
|
261
|
+
if (!task || task.status !== "running") return { result: null, changed: false };
|
|
262
|
+
task.executionStartedAt = new Date().toISOString();
|
|
263
|
+
task.updatedAt = task.executionStartedAt;
|
|
264
|
+
return { result: structuredClone(task) };
|
|
246
265
|
});
|
|
247
266
|
}
|
|
248
267
|
|
|
@@ -255,12 +274,29 @@ export class TaskStore {
|
|
|
255
274
|
for (const task of tasks) {
|
|
256
275
|
if (task.status === "running") {
|
|
257
276
|
const interruptedAt = new Date(now).toISOString();
|
|
277
|
+
if (task.claimedAt && !task.executionStartedAt) {
|
|
278
|
+
task.status = "pending";
|
|
279
|
+
task.attempts = Math.max(0, Number(task.attempts || 0) - 1);
|
|
280
|
+
task.lastError = "execution interrupted before start";
|
|
281
|
+
task.lastFailedAt = interruptedAt;
|
|
282
|
+
task.updatedAt = interruptedAt;
|
|
283
|
+
task.lastClaimedAt = task.claimedAt;
|
|
284
|
+
delete task.startedAt;
|
|
285
|
+
delete task.claimedAt;
|
|
286
|
+
recovered.push(structuredClone(task));
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
258
289
|
task.lastError = "execution interrupted before confirmation";
|
|
259
290
|
task.lastFailedAt = interruptedAt;
|
|
260
291
|
task.updatedAt = interruptedAt;
|
|
292
|
+
if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
|
|
293
|
+
if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
|
|
294
|
+
delete task.claimedAt;
|
|
295
|
+
delete task.executionStartedAt;
|
|
261
296
|
if (task.kind === "poll_tool") {
|
|
262
297
|
task.status = "pending";
|
|
263
298
|
task.runAt = new Date(now + retryDelayMs(task)).toISOString();
|
|
299
|
+
delete task.startedAt;
|
|
264
300
|
} else {
|
|
265
301
|
const nextRunAt = computeNextRunAt(task, now);
|
|
266
302
|
if (nextRunAt) {
|
|
@@ -268,6 +304,7 @@ export class TaskStore {
|
|
|
268
304
|
task.runAt = nextRunAt;
|
|
269
305
|
task.attempts = 0;
|
|
270
306
|
task.lastOutcome = "outcome_uncertain";
|
|
307
|
+
delete task.startedAt;
|
|
271
308
|
} else {
|
|
272
309
|
task.status = "outcome_uncertain";
|
|
273
310
|
task.error = task.lastError;
|
|
@@ -294,10 +331,14 @@ export class TaskStore {
|
|
|
294
331
|
const nextRunAt = computeNextRunAt(task, now);
|
|
295
332
|
task.lastCompletedAt = completedAt;
|
|
296
333
|
task.lastRunAt = completedAt;
|
|
334
|
+
if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
|
|
335
|
+
if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
|
|
297
336
|
delete task.lastError;
|
|
298
337
|
delete task.error;
|
|
299
338
|
delete task.lastOutcome;
|
|
300
339
|
delete task.consecutiveFailures;
|
|
340
|
+
delete task.claimedAt;
|
|
341
|
+
delete task.executionStartedAt;
|
|
301
342
|
if (nextRunAt) {
|
|
302
343
|
task.status = "pending";
|
|
303
344
|
task.runAt = nextRunAt;
|
|
@@ -319,12 +360,28 @@ export class TaskStore {
|
|
|
319
360
|
if (!task) return { result: null, changed: false };
|
|
320
361
|
const message = error instanceof Error ? error.message : String(error);
|
|
321
362
|
if (outcomeUncertain) {
|
|
322
|
-
const
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
const uncertainAt = new Date(now).toISOString();
|
|
365
|
+
const nextRunAt = computeNextRunAt(task, now);
|
|
366
|
+
task.lastError = message;
|
|
367
|
+
task.lastFailedAt = uncertainAt;
|
|
368
|
+
task.updatedAt = uncertainAt;
|
|
369
|
+
if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
|
|
370
|
+
if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
|
|
371
|
+
delete task.claimedAt;
|
|
372
|
+
delete task.executionStartedAt;
|
|
373
|
+
if (nextRunAt) {
|
|
374
|
+
task.status = "pending";
|
|
375
|
+
task.runAt = nextRunAt;
|
|
376
|
+
task.attempts = 0;
|
|
377
|
+
task.lastOutcome = "outcome_uncertain";
|
|
378
|
+
delete task.startedAt;
|
|
379
|
+
delete task.error;
|
|
380
|
+
return { result: { ...structuredClone(task), terminalFailure: true } };
|
|
381
|
+
}
|
|
323
382
|
task.status = "outcome_uncertain";
|
|
324
383
|
task.error = message;
|
|
325
|
-
task.lastError = message;
|
|
326
384
|
task.uncertainAt = uncertainAt;
|
|
327
|
-
task.updatedAt = uncertainAt;
|
|
328
385
|
compactTerminalTask(task);
|
|
329
386
|
return { result: structuredClone(task) };
|
|
330
387
|
}
|
|
@@ -343,7 +400,11 @@ export class TaskStore {
|
|
|
343
400
|
task.lastFailedAt = failedAt;
|
|
344
401
|
task.consecutiveFailures = Number(task.consecutiveFailures || 0) + 1;
|
|
345
402
|
task.updatedAt = failedAt;
|
|
403
|
+
if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
|
|
404
|
+
if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
|
|
346
405
|
delete task.startedAt;
|
|
406
|
+
delete task.claimedAt;
|
|
407
|
+
delete task.executionStartedAt;
|
|
347
408
|
delete task.error;
|
|
348
409
|
return { result: { ...structuredClone(task), terminalFailure: true } };
|
|
349
410
|
}
|
|
@@ -354,6 +415,11 @@ export class TaskStore {
|
|
|
354
415
|
task.lastError = message;
|
|
355
416
|
task.lastFailedAt = new Date(now).toISOString();
|
|
356
417
|
task.updatedAt = task.lastFailedAt;
|
|
418
|
+
if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
|
|
419
|
+
if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
|
|
420
|
+
delete task.startedAt;
|
|
421
|
+
delete task.claimedAt;
|
|
422
|
+
delete task.executionStartedAt;
|
|
357
423
|
return { result: structuredClone(task) };
|
|
358
424
|
});
|
|
359
425
|
}
|
|
@@ -11,6 +11,7 @@ import { daemonConfigDefaults } from "../config/config-defaults.js";
|
|
|
11
11
|
import { SkillRegistry } from "../skills/skill-registry.js";
|
|
12
12
|
import { ToolUsageStore } from "./tool-usage-store.js";
|
|
13
13
|
import { inspectToolDependencies, normalizeToolDependencies } from "./tool-dependencies.js";
|
|
14
|
+
import { normalizeToolExecution, WeightedResourceGovernor } from "./weighted-resource-governor.js";
|
|
14
15
|
|
|
15
16
|
function toolEnv() {
|
|
16
17
|
return { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir, ARISA_IPC_SOCKET: arisaIpcSocketFile };
|
|
@@ -272,7 +273,9 @@ export class ToolRegistry {
|
|
|
272
273
|
resolveOfficialToolNames = readOfficialToolNames,
|
|
273
274
|
helpTimeoutMs = defaultToolHelpTimeoutMs,
|
|
274
275
|
runTimeoutMs = defaultToolRunTimeoutMs,
|
|
275
|
-
killGraceMs = defaultToolKillGraceMs
|
|
276
|
+
killGraceMs = defaultToolKillGraceMs,
|
|
277
|
+
executionPolicy,
|
|
278
|
+
executionGovernor
|
|
276
279
|
} = {}) {
|
|
277
280
|
this.logger = logger;
|
|
278
281
|
this.helpTimeoutMs = positiveDuration(helpTimeoutMs, defaultToolHelpTimeoutMs);
|
|
@@ -282,6 +285,10 @@ export class ToolRegistry {
|
|
|
282
285
|
this.skillRegistry = new SkillRegistry();
|
|
283
286
|
this.usageStore = usageStore;
|
|
284
287
|
this.resolveOfficialToolNames = resolveOfficialToolNames;
|
|
288
|
+
this.executionGovernor = executionGovernor || new WeightedResourceGovernor({
|
|
289
|
+
policy: executionPolicy,
|
|
290
|
+
logger
|
|
291
|
+
});
|
|
285
292
|
}
|
|
286
293
|
|
|
287
294
|
async buildSnapshot() {
|
|
@@ -308,6 +315,7 @@ export class ToolRegistry {
|
|
|
308
315
|
snapshot.set(manifest.name, {
|
|
309
316
|
...manifest,
|
|
310
317
|
toolDependencies: normalizeToolDependencies(manifest.toolDependencies),
|
|
318
|
+
execution: normalizeToolExecution(manifest.execution),
|
|
311
319
|
category: normalizeCategory(manifest.category),
|
|
312
320
|
keywords: normalizeKeywords(manifest.keywords),
|
|
313
321
|
skillHints,
|
|
@@ -450,6 +458,10 @@ export class ToolRegistry {
|
|
|
450
458
|
return inspectToolDependencies(this.tools, name);
|
|
451
459
|
}
|
|
452
460
|
|
|
461
|
+
executionDiagnostic() {
|
|
462
|
+
return this.executionGovernor.snapshot();
|
|
463
|
+
}
|
|
464
|
+
|
|
453
465
|
async usage(chatId) {
|
|
454
466
|
const [counts, officialNames] = await Promise.all([
|
|
455
467
|
this.usageStore.counts(chatId),
|
|
@@ -475,14 +487,16 @@ export class ToolRegistry {
|
|
|
475
487
|
await this.usageStore.record(chatId, name).catch((error) => {
|
|
476
488
|
this.logger?.error("tools", `could not record ${name} usage: ${error?.message || String(error)}`);
|
|
477
489
|
});
|
|
478
|
-
this.logger?.log("tools", `running ${name}`);
|
|
479
490
|
const tmpDir = chatId != null ? getChatToolTmpDir(chatId, name) : getToolTmpDir(name);
|
|
480
|
-
await mkdir(tmpDir, { recursive: true });
|
|
481
491
|
const requestFile = path.join(tmpDir, `.request-${Date.now()}-${randomUUID()}.json`);
|
|
482
|
-
|
|
483
|
-
const enrichedRequest = { ...request, chatId, skills };
|
|
492
|
+
let lease = null;
|
|
484
493
|
let result;
|
|
485
494
|
try {
|
|
495
|
+
lease = await this.executionGovernor.acquire(tool.execution, name);
|
|
496
|
+
this.logger?.log("tools", `running ${name}`);
|
|
497
|
+
await mkdir(tmpDir, { recursive: true });
|
|
498
|
+
const skills = await this.resolveSkills(name);
|
|
499
|
+
const enrichedRequest = { ...request, chatId, skills };
|
|
486
500
|
if (tool.daemon?.protocol === "arisa-daemon-v1") {
|
|
487
501
|
const scope = tool.daemon.scope === "chat"
|
|
488
502
|
? { type: "chat", chatId }
|
|
@@ -538,6 +552,7 @@ export class ToolRegistry {
|
|
|
538
552
|
error: error?.message || `Invalid tool response for ${name}`
|
|
539
553
|
});
|
|
540
554
|
} finally {
|
|
555
|
+
lease?.release();
|
|
541
556
|
await unlink(requestFile).catch(() => {});
|
|
542
557
|
await rmdir(tmpDir).catch(() => {});
|
|
543
558
|
if (chatId != null) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const defaultCapacity = 2;
|
|
2
|
+
const defaultMaxQueuedPerClass = 100;
|
|
3
|
+
|
|
4
|
+
function positiveInteger(value, fallback) {
|
|
5
|
+
const parsed = Number(value);
|
|
6
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function resourceClassName(value) {
|
|
10
|
+
const name = String(value || "").trim();
|
|
11
|
+
if (!name) return "";
|
|
12
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
|
13
|
+
throw new Error(`Invalid tool execution resource class: ${name}`);
|
|
14
|
+
}
|
|
15
|
+
return name;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeToolExecution(execution) {
|
|
19
|
+
if (execution == null) return null;
|
|
20
|
+
if (!execution || typeof execution !== "object" || Array.isArray(execution)) {
|
|
21
|
+
throw new Error("Tool execution policy must be an object");
|
|
22
|
+
}
|
|
23
|
+
const resourceClass = resourceClassName(execution.resourceClass);
|
|
24
|
+
if (!resourceClass) throw new Error("Tool execution resourceClass is required");
|
|
25
|
+
const weight = positiveInteger(execution.weight, 0);
|
|
26
|
+
if (!weight) throw new Error("Tool execution weight must be a positive integer");
|
|
27
|
+
return { resourceClass, weight };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeToolExecutionPolicy(policy = {}) {
|
|
31
|
+
const configuredCapacities = policy?.capacities && typeof policy.capacities === "object" && !Array.isArray(policy.capacities)
|
|
32
|
+
? policy.capacities
|
|
33
|
+
: {};
|
|
34
|
+
const capacities = {};
|
|
35
|
+
for (const [name, value] of Object.entries(configuredCapacities)) {
|
|
36
|
+
capacities[resourceClassName(name)] = positiveInteger(value, defaultCapacity);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
defaultCapacity: positiveInteger(policy?.defaultCapacity, defaultCapacity),
|
|
40
|
+
maxQueuedPerClass: positiveInteger(policy?.maxQueuedPerClass, defaultMaxQueuedPerClass),
|
|
41
|
+
capacities
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function noopLease() {
|
|
46
|
+
return { waitedMs: 0, release() {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class WeightedResourceGovernor {
|
|
50
|
+
constructor({ policy, logger, now = () => Date.now(), memoryUsage = () => process.memoryUsage() } = {}) {
|
|
51
|
+
this.policy = normalizeToolExecutionPolicy(policy);
|
|
52
|
+
this.logger = logger;
|
|
53
|
+
this.now = now;
|
|
54
|
+
this.memoryUsage = memoryUsage;
|
|
55
|
+
this.resources = new Map();
|
|
56
|
+
this.peakRssBytes = 0;
|
|
57
|
+
this.lastLoggedRssBytes = 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
capacityFor(resourceClass) {
|
|
61
|
+
return this.policy.capacities[resourceClass] || this.policy.defaultCapacity;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stateFor(resourceClass) {
|
|
65
|
+
let state = this.resources.get(resourceClass);
|
|
66
|
+
if (!state) {
|
|
67
|
+
state = { activeWeight: 0, queue: [] };
|
|
68
|
+
this.resources.set(resourceClass, state);
|
|
69
|
+
}
|
|
70
|
+
return state;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
observeMemory(resourceClass) {
|
|
74
|
+
const rss = Number(this.memoryUsage()?.rss) || 0;
|
|
75
|
+
if (rss <= this.peakRssBytes) return;
|
|
76
|
+
this.peakRssBytes = rss;
|
|
77
|
+
const logStepBytes = 8 * 1024 * 1024;
|
|
78
|
+
if (this.lastLoggedRssBytes && rss - this.lastLoggedRssBytes < logStepBytes) return;
|
|
79
|
+
this.lastLoggedRssBytes = rss;
|
|
80
|
+
this.logger?.log("tools", `worker RSS peak ${Math.ceil(rss / 1024 / 1024)} MiB while using ${resourceClass}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
createLease(resourceClass, weight, waitedMs) {
|
|
84
|
+
let released = false;
|
|
85
|
+
return {
|
|
86
|
+
waitedMs,
|
|
87
|
+
release: () => {
|
|
88
|
+
if (released) return;
|
|
89
|
+
released = true;
|
|
90
|
+
const state = this.stateFor(resourceClass);
|
|
91
|
+
state.activeWeight = Math.max(0, state.activeWeight - weight);
|
|
92
|
+
this.observeMemory(resourceClass);
|
|
93
|
+
this.drain(resourceClass);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
grant(resourceClass, request) {
|
|
99
|
+
const state = this.stateFor(resourceClass);
|
|
100
|
+
state.activeWeight += request.weight;
|
|
101
|
+
const waitedMs = Math.max(0, this.now() - request.queuedAt);
|
|
102
|
+
this.observeMemory(resourceClass);
|
|
103
|
+
if (waitedMs > 0) {
|
|
104
|
+
this.logger?.log("tools", `${request.label} acquired ${resourceClass} capacity after ${waitedMs}ms`);
|
|
105
|
+
}
|
|
106
|
+
request.resolve(this.createLease(resourceClass, request.weight, waitedMs));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
drain(resourceClass) {
|
|
110
|
+
const state = this.stateFor(resourceClass);
|
|
111
|
+
const capacity = this.capacityFor(resourceClass);
|
|
112
|
+
while (state.queue.length) {
|
|
113
|
+
const next = state.queue[0];
|
|
114
|
+
if (state.activeWeight + next.weight > capacity) break;
|
|
115
|
+
state.queue.shift();
|
|
116
|
+
this.grant(resourceClass, next);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
acquire(execution, label = "tool") {
|
|
121
|
+
if (!execution) return Promise.resolve(noopLease());
|
|
122
|
+
const resourceClass = execution.resourceClass;
|
|
123
|
+
const capacity = this.capacityFor(resourceClass);
|
|
124
|
+
const weight = Math.min(execution.weight, capacity);
|
|
125
|
+
const state = this.stateFor(resourceClass);
|
|
126
|
+
if (!state.queue.length && state.activeWeight + weight <= capacity) {
|
|
127
|
+
return new Promise((resolve) => this.grant(resourceClass, {
|
|
128
|
+
weight,
|
|
129
|
+
label,
|
|
130
|
+
queuedAt: this.now(),
|
|
131
|
+
resolve
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
if (state.queue.length >= this.policy.maxQueuedPerClass) {
|
|
135
|
+
throw new Error(`Tool execution queue is full for resource class ${resourceClass}`);
|
|
136
|
+
}
|
|
137
|
+
this.logger?.log("tools", `${label} queued for ${resourceClass} capacity (${state.activeWeight}/${capacity})`);
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
state.queue.push({ weight, label, queuedAt: this.now(), resolve });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
snapshot() {
|
|
144
|
+
return {
|
|
145
|
+
peakRssBytes: this.peakRssBytes,
|
|
146
|
+
resources: Object.fromEntries([...this.resources.entries()].map(([name, state]) => [name, {
|
|
147
|
+
capacity: this.capacityFor(name),
|
|
148
|
+
activeWeight: state.activeWeight,
|
|
149
|
+
queued: state.queue.length
|
|
150
|
+
}]))
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { applyRuntimeOverrides, createApp } from "./runtime/create-app.js";
|
|
|
5
5
|
import { loadConfig } from "./core/config/config-store.js";
|
|
6
6
|
import { createLogger } from "./runtime/logger.js";
|
|
7
7
|
import { getServiceStatus, handoffServiceRestart, registerServiceProcess, restartService, serviceEntryFile, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
|
|
8
|
+
import { createServiceSupervisor } from "./runtime/service-supervisor.js";
|
|
8
9
|
import { flushArisaHome } from "./runtime/flush.js";
|
|
9
10
|
import { readPackageVersion, showServiceLogs } from "./runtime/log-viewer.js";
|
|
10
11
|
import { arisaPackageDir } from "./runtime/paths.js";
|
|
@@ -19,6 +20,7 @@ const command = cli.positionals[0] || "run";
|
|
|
19
20
|
const forceBootstrap = Boolean(cli.flags.bootstrap);
|
|
20
21
|
const verbose = !cli.flags.silent;
|
|
21
22
|
const serviceRunner = Boolean(cli.flags["service-runner"]);
|
|
23
|
+
const serviceWorker = Boolean(cli.flags["service-worker"]);
|
|
22
24
|
const slaveCommand = command === "slave";
|
|
23
25
|
const slaveServiceRunner = slaveCommand && serviceRunner;
|
|
24
26
|
const runtimeOverrides = toNestedOverrides(cli.nestedFlags);
|
|
@@ -233,6 +235,24 @@ async function main() {
|
|
|
233
235
|
|
|
234
236
|
if (serviceRunner) {
|
|
235
237
|
await registerServiceProcess();
|
|
238
|
+
const persistedConfig = await loadConfig();
|
|
239
|
+
const workerArgs = [serviceEntryFile, "--service-worker", ...toServiceRunnerArgs(cli.nestedFlags)];
|
|
240
|
+
if (!verbose) workerArgs.push("--silent");
|
|
241
|
+
const supervisor = createServiceSupervisor({
|
|
242
|
+
command: process.execPath,
|
|
243
|
+
args: workerArgs,
|
|
244
|
+
restartLimit: persistedConfig.service.workerRestartLimit,
|
|
245
|
+
restartBackoffMs: persistedConfig.service.workerRestartBackoffMs,
|
|
246
|
+
restartBackoffMaxMs: persistedConfig.service.workerRestartBackoffMaxMs,
|
|
247
|
+
stableRuntimeMs: persistedConfig.service.workerStableRuntimeMs,
|
|
248
|
+
logger
|
|
249
|
+
});
|
|
250
|
+
activeApp = supervisor;
|
|
251
|
+
await supervisor.start();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (serviceWorker) {
|
|
236
256
|
await runForeground();
|
|
237
257
|
return;
|
|
238
258
|
}
|