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.
Files changed (33) hide show
  1. package/README.md +9 -0
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +10 -0
  4. package/src/core/config/config-defaults.js +28 -1
  5. package/src/core/tasks/task-runner.js +17 -2
  6. package/src/core/tasks/task-store.js +80 -14
  7. package/src/core/tools/tool-registry.js +20 -5
  8. package/src/core/tools/weighted-resource-governor.js +153 -0
  9. package/src/index.js +20 -0
  10. package/src/official-tools.lock.json +57 -43
  11. package/src/runtime/create-app.js +1 -1
  12. package/src/runtime/create-headless-app.js +2 -2
  13. package/src/runtime/paths.js +4 -0
  14. package/src/runtime/service-manager.js +3 -1
  15. package/src/runtime/service-supervisor.js +98 -0
  16. package/src/transport/telegram/bot.js +77 -9
  17. package/src/transport/telegram/chat-queue.js +13 -2
  18. package/src/transport/telegram/reply-topic-routing.js +111 -0
  19. package/src/transport/telegram/task-dispatcher.js +26 -3
  20. package/src/transport/telegram/telegram-session-bridge.js +7 -0
  21. package/src/transport/telegram/workspace-topic-store.js +228 -0
  22. package/test/chat-queue.test.js +32 -0
  23. package/test/model-selection.test.js +9 -1
  24. package/test/official-tool-dependencies.test.js +1 -1
  25. package/test/official-tool-installer.test.js +2 -0
  26. package/test/paths.test.js +8 -0
  27. package/test/service-manager.test.js +48 -0
  28. package/test/task-store.test.js +56 -4
  29. package/test/telegram-reply-topic-routing.test.js +94 -0
  30. package/test/telegram-task-dispatcher.test.js +51 -0
  31. package/test/telegram-workspace-topic-store.test.js +124 -0
  32. package/test/tool-registry-run.test.js +41 -0
  33. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.1.60",
3
+ "version": "5.1.65",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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
- async function runClaimedTask(task) {
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
- for (const task of tasks) {
234
- if (due.length >= limit) break;
235
- if (task.status !== "pending") continue;
236
- if (!task.runAt || Number.isNaN(Date.parse(task.runAt))) continue;
237
- if (Date.parse(task.runAt) > now) continue;
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 = new Date(now).toISOString();
241
- task.updatedAt = task.startedAt;
242
- due.push(structuredClone(task));
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 uncertainAt = new Date().toISOString();
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
- const skills = await this.resolveSkills(name);
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
  }