arisa 5.2.7 → 5.2.19

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 (65) hide show
  1. package/README.md +25 -4
  2. package/package.json +10 -9
  3. package/pnpm-workspace.yaml +7 -5
  4. package/src/core/agent/agent-manager.js +92 -18
  5. package/src/core/agent/agent-session-lifecycle.js +29 -6
  6. package/src/core/agent/agent-turn-coordinator.js +143 -0
  7. package/src/core/agent/auth-flow.js +6 -6
  8. package/src/core/agent/model-speed.js +3 -1
  9. package/src/core/agent/pi-auth-login.js +28 -28
  10. package/src/core/agent/pi-capability-tools.js +8 -4
  11. package/src/core/agent/pi-runtime.js +14 -21
  12. package/src/core/agent/session-history-reader.js +168 -0
  13. package/src/core/agent/session-preload-migration.js +162 -0
  14. package/src/core/agent/session-rotation.js +29 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/capabilities/capability-service.js +28 -2
  17. package/src/core/config/config-defaults.js +29 -0
  18. package/src/core/tasks/task-runner.js +8 -4
  19. package/src/core/tasks/task-store.js +48 -4
  20. package/src/core/tools/daemon-processes.js +7 -0
  21. package/src/core/tools/memory-pressure.js +2 -2
  22. package/src/core/tools/tool-registry.js +35 -7
  23. package/src/core/tools/weighted-resource-governor.js +7 -6
  24. package/src/official-tools.lock.json +129 -48
  25. package/src/runtime/bootstrap-cli.js +3 -3
  26. package/src/runtime/bootstrap-telegram.js +7 -7
  27. package/src/runtime/doctor.js +20 -73
  28. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  29. package/src/runtime/process-inspection.js +78 -0
  30. package/src/runtime/slave-cli.js +20 -10
  31. package/src/runtime/slave-service.js +299 -7
  32. package/src/runtime/tool-process-supervisor.js +35 -8
  33. package/src/runtime/tui.js +6 -7
  34. package/src/transport/telegram/bot.js +11 -5
  35. package/src/transport/telegram/chat-queue.js +6 -2
  36. package/src/transport/telegram/model-controls.js +1 -1
  37. package/src/transport/telegram/task-dispatcher.js +67 -15
  38. package/src/transport/telegram/telegram-auth-controller.js +7 -7
  39. package/src/transport/telegram/telegram-prompt-controller.js +17 -4
  40. package/src/transport/telegram/telegram-session-bridge.js +2 -1
  41. package/test/agent-turn-coordinator.test.js +48 -0
  42. package/test/auth-flow.test.js +2 -2
  43. package/test/capabilities-security.test.js +36 -0
  44. package/test/context-and-task-bounds.test.js +2 -1
  45. package/test/daemon-runtime.test.js +2 -4
  46. package/test/doctor.test.js +19 -0
  47. package/test/memory-pressure.test.js +7 -2
  48. package/test/model-selection.test.js +3 -2
  49. package/test/obsolete-daemon-reaper.test.js +61 -0
  50. package/test/official-tool-dependencies.test.js +7 -2
  51. package/test/official-tool-installer.test.js +13 -0
  52. package/test/pi-auth-login.test.js +78 -0
  53. package/test/pi-capability-tools.test.js +3 -0
  54. package/test/pi-compaction.test.js +21 -0
  55. package/test/pi-speed-integration.test.js +176 -0
  56. package/test/session-history-reader.test.js +84 -0
  57. package/test/session-preload-migration.test.js +120 -0
  58. package/test/session-rotation.test.js +110 -0
  59. package/test/slave-cli.test.js +221 -5
  60. package/test/task-store.test.js +34 -0
  61. package/test/telegram-prompt-controller.test.js +2 -1
  62. package/test/telegram-task-dispatcher.test.js +121 -5
  63. package/test/tool-registry-run.test.js +10 -1
  64. package/test/weighted-resource-governor.test.js +28 -0
  65. package/test/worker-tool-fanout.test.js +79 -0
@@ -48,6 +48,17 @@ function normalizeAcknowledgement(value) {
48
48
  return acknowledgement;
49
49
  }
50
50
 
51
+ function recordAgentTaskAuthBlock(execution, toolName, result) {
52
+ if (!execution || execution.blockedAuth || result?.ok !== false || result?.status !== "blocked_auth") return;
53
+ execution.blockedAuth = {
54
+ toolName,
55
+ error: String(result.error || `${toolName} authentication is required`).slice(0, 1_000),
56
+ resolution: result.resolution && typeof result.resolution === "object"
57
+ ? structuredClone(result.resolution)
58
+ : {}
59
+ };
60
+ }
61
+
51
62
  function inferDeliveryMethod(artifact) {
52
63
  if (artifact.kind === "audio" || (artifact.mimeType || "").startsWith("audio/")) return "audio";
53
64
  if (artifact.kind === "image" || (artifact.mimeType || "").startsWith("image/")) return "photo";
@@ -71,11 +82,12 @@ export function selectScheduledTasks(tasks = [], { status, limit = defaultSchedu
71
82
  maxScheduledTaskListLimit
72
83
  );
73
84
  const allTasks = Array.isArray(tasks) ? tasks : [];
85
+ const activeStatuses = new Set(["pending", "running", "blocked_auth"]);
74
86
  const orderedTasks = status
75
87
  ? [...allTasks].reverse()
76
88
  : [
77
- ...allTasks.filter((task) => task.status === "pending" || task.status === "running").reverse(),
78
- ...allTasks.filter((task) => task.status !== "pending" && task.status !== "running").reverse()
89
+ ...allTasks.filter((task) => activeStatuses.has(task.status)).reverse(),
90
+ ...allTasks.filter((task) => !activeStatuses.has(task.status)).reverse()
79
91
  ];
80
92
  const visibleTasks = orderedTasks.slice(0, resolvedLimit);
81
93
  return {
@@ -177,6 +189,19 @@ export function createCapabilityService({
177
189
  if (!toolExecutor?.runTool) throw new Error("tools.run requires toolExecutor");
178
190
  const scopedChatId = requireChatId(chatId, method);
179
191
  const targetToolName = requireString(params.name, "name");
192
+ const blocked = context.agentTaskExecution?.blockedAuth;
193
+ if (blocked) {
194
+ return {
195
+ ok: false,
196
+ status: "blocked_prerequisite",
197
+ error: `Authentication for ${blocked.toolName} is blocking this scheduled task.`,
198
+ resolution: {
199
+ type: "blocked_prerequisite",
200
+ prerequisiteStatus: "blocked_auth",
201
+ toolName: blocked.toolName
202
+ }
203
+ };
204
+ }
180
205
  const chatArtifactStore = artifactStore.forChat(scopedChatId);
181
206
  const artifact = params.artifactId
182
207
  ? await chatArtifactStore.get(requireString(params.artifactId, "artifactId"))
@@ -196,6 +221,7 @@ export function createCapabilityService({
196
221
  chatId: scopedChatId,
197
222
  taskContext: context.taskContext || null
198
223
  });
224
+ recordAgentTaskAuthBlock(context.agentTaskExecution, targetToolName, result);
199
225
  if (params.deliver && result.output?.artifactId) {
200
226
  const generated = await chatArtifactStore.get(result.output.artifactId);
201
227
  if (generated?.path) {
@@ -72,6 +72,11 @@ export const piConfigDefaults = Object.freeze({
72
72
  maxSessions: 3,
73
73
  maxPersistedBytes: 48 * 1024 * 1024
74
74
  }),
75
+ sessionRotation: Object.freeze({
76
+ enabled: true,
77
+ compactAtPersistedBytes: 24 * 1024 * 1024,
78
+ maxPersistedBytes: 32 * 1024 * 1024
79
+ }),
75
80
  heapCircuitBreaker: Object.freeze({
76
81
  enabled: true,
77
82
  softPercent: 70,
@@ -79,6 +84,18 @@ export const piConfigDefaults = Object.freeze({
79
84
  waitMs: 15_000,
80
85
  pollMs: 500
81
86
  }),
87
+ toolFanout: Object.freeze({
88
+ enabled: true,
89
+ maxConcurrent: 2,
90
+ pressureConcurrent: 1,
91
+ serializePercent: 60
92
+ }),
93
+ turnCoordinator: Object.freeze({
94
+ enabled: true,
95
+ backgroundQueueTtlMs: 10 * 60_000,
96
+ interactiveQueueTtlMs: 0,
97
+ maxQueued: 100
98
+ }),
82
99
  compaction: Object.freeze({
83
100
  enabled: true,
84
101
  reserveTokens: 120_000,
@@ -133,10 +150,22 @@ export function applyConfigDefaults(config) {
133
150
  ...piConfigDefaults.sessionCache,
134
151
  ...(configuredPi.sessionCache || {})
135
152
  },
153
+ sessionRotation: {
154
+ ...piConfigDefaults.sessionRotation,
155
+ ...(configuredPi.sessionRotation || {})
156
+ },
136
157
  heapCircuitBreaker: {
137
158
  ...piConfigDefaults.heapCircuitBreaker,
138
159
  ...(configuredPi.heapCircuitBreaker || {})
139
160
  },
161
+ toolFanout: {
162
+ ...piConfigDefaults.toolFanout,
163
+ ...(configuredPi.toolFanout || {})
164
+ },
165
+ turnCoordinator: {
166
+ ...piConfigDefaults.turnCoordinator,
167
+ ...(configuredPi.turnCoordinator || {})
168
+ },
140
169
  compaction: {
141
170
  ...piConfigDefaults.compaction,
142
171
  ...(configuredPi.compaction || {})
@@ -18,7 +18,8 @@ export function createTaskRunner({ taskStore, dispatch, laneKey = (task) => task
18
18
  async function reportTerminalFailure(task, updated, error) {
19
19
  const isTerminal = updated?.status === "failed"
20
20
  || updated?.status === "outcome_uncertain"
21
- || updated?.terminalFailure === true;
21
+ || updated?.terminalFailure === true
22
+ || updated?.authBlockedNew === true;
22
23
  if (!isTerminal || typeof onTerminalFailure !== "function") return;
23
24
  try {
24
25
  await onTerminalFailure({ task, result: updated, error });
@@ -37,9 +38,12 @@ export function createTaskRunner({ taskStore, dispatch, laneKey = (task) => task
37
38
  logger?.log("tasks", `task ${task.id} completed after confirmed execution`);
38
39
  return { taskId: task.id, status: "completed" };
39
40
  } catch (error) {
40
- const retryOptions = { retryable: error?.retryable !== false };
41
- if (error?.outcomeUncertain === true) retryOptions.outcomeUncertain = true;
42
- const updated = await taskStore.retryOrFail(task.id, error, retryOptions);
41
+ const updated = error?.authBlocked === true
42
+ ? await taskStore.blockAuth(task.id, error, error.authResolution)
43
+ : await taskStore.retryOrFail(task.id, error, {
44
+ retryable: error?.retryable !== false,
45
+ ...(error?.outcomeUncertain === true ? { outcomeUncertain: true } : {})
46
+ });
43
47
  const status = updated?.status || "missing";
44
48
  logger?.log("tasks", `task ${task.id} ${status}: ${errorMessage(error)}`);
45
49
  await reportTerminalFailure(task, updated, error);
@@ -170,6 +170,17 @@ function retryDelayMs(task) {
170
170
  return Math.max(1, Math.round(seconds * 1000));
171
171
  }
172
172
 
173
+ function normalizeAuthResolution(resolution = {}) {
174
+ const value = resolution && typeof resolution === "object" && !Array.isArray(resolution) ? resolution : {};
175
+ const retryAfterSeconds = Math.max(300, boundedPositiveNumber(value.retryAfterSeconds, 3_600, 86_400));
176
+ const probeArgs = value.probeArgs && typeof value.probeArgs === "object" && !Array.isArray(value.probeArgs)
177
+ ? structuredClone(value.probeArgs)
178
+ : {};
179
+ if (JSON.stringify(probeArgs).length > 4_096) throw new Error("Authentication probe arguments are too large");
180
+ const toolName = typeof value.toolName === "string" ? value.toolName.trim().slice(0, 128) : "";
181
+ return { retryAfterSeconds, probeArgs, ...(toolName ? { toolName } : {}) };
182
+ }
183
+
173
184
  function failTask(task, error) {
174
185
  const failedAt = new Date().toISOString();
175
186
  task.status = "failed";
@@ -232,7 +243,7 @@ export class TaskStore {
232
243
  return this.mutate(async (tasks) => {
233
244
  const now = Date.now();
234
245
  const due = tasks
235
- .filter((task) => task.status === "pending"
246
+ .filter((task) => ["pending", "blocked_auth"].includes(task.status)
236
247
  && task.runAt
237
248
  && !Number.isNaN(Date.parse(task.runAt))
238
249
  && Date.parse(task.runAt) <= now)
@@ -275,7 +286,7 @@ export class TaskStore {
275
286
  if (task.status === "running") {
276
287
  const interruptedAt = new Date(now).toISOString();
277
288
  if (task.claimedAt && !task.executionStartedAt) {
278
- task.status = "pending";
289
+ task.status = task.authBlock ? "blocked_auth" : "pending";
279
290
  task.attempts = Math.max(0, Number(task.attempts || 0) - 1);
280
291
  task.lastError = "execution interrupted before start";
281
292
  task.lastFailedAt = interruptedAt;
@@ -294,8 +305,11 @@ export class TaskStore {
294
305
  delete task.claimedAt;
295
306
  delete task.executionStartedAt;
296
307
  if (task.kind === "poll_tool") {
297
- task.status = "pending";
298
- task.runAt = new Date(now + retryDelayMs(task)).toISOString();
308
+ task.status = task.authBlock ? "blocked_auth" : "pending";
309
+ const delayMs = task.authBlock
310
+ ? Number(task.authBlock.retryAfterSeconds || 3_600) * 1_000
311
+ : retryDelayMs(task);
312
+ task.runAt = new Date(now + delayMs).toISOString();
299
313
  delete task.startedAt;
300
314
  } else {
301
315
  const nextRunAt = computeNextRunAt(task, now);
@@ -337,6 +351,7 @@ export class TaskStore {
337
351
  delete task.error;
338
352
  delete task.lastOutcome;
339
353
  delete task.consecutiveFailures;
354
+ delete task.authBlock;
340
355
  delete task.claimedAt;
341
356
  delete task.executionStartedAt;
342
357
  if (nextRunAt) {
@@ -354,6 +369,35 @@ export class TaskStore {
354
369
  });
355
370
  }
356
371
 
372
+ async blockAuth(taskId, error, resolution = {}) {
373
+ return this.mutate(async (tasks) => {
374
+ const task = tasks.find((item) => item.id === taskId);
375
+ if (!task) return { result: null, changed: false };
376
+ const now = Date.now();
377
+ const checkedAt = new Date(now).toISOString();
378
+ const normalized = normalizeAuthResolution(resolution);
379
+ const wasBlocked = Boolean(task.authBlock);
380
+ task.status = "blocked_auth";
381
+ task.runAt = new Date(now + (normalized.retryAfterSeconds * 1_000)).toISOString();
382
+ task.attempts = 0;
383
+ task.authBlock = {
384
+ firstBlockedAt: task.authBlock?.firstBlockedAt || checkedAt,
385
+ lastCheckedAt: checkedAt,
386
+ ...normalized
387
+ };
388
+ task.lastError = error instanceof Error ? error.message : String(error);
389
+ task.lastFailedAt = checkedAt;
390
+ task.updatedAt = checkedAt;
391
+ if (task.claimedAt) task.lastClaimedAt = task.claimedAt;
392
+ if (task.executionStartedAt) task.lastExecutionStartedAt = task.executionStartedAt;
393
+ delete task.startedAt;
394
+ delete task.claimedAt;
395
+ delete task.executionStartedAt;
396
+ delete task.error;
397
+ return { result: { ...structuredClone(task), authBlockedNew: !wasBlocked } };
398
+ });
399
+ }
400
+
357
401
  async retryOrFail(taskId, error, { retryable = true, outcomeUncertain = false } = {}) {
358
402
  return this.mutate(async (tasks) => {
359
403
  const task = tasks.find((item) => item.id === taskId);
@@ -280,6 +280,13 @@ export async function unregisterManagedDaemon(toolNameOrOptions, { scope } = {})
280
280
  return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
281
281
  }
282
282
 
283
+ export async function purgeManagedDaemon(toolNameOrOptions, { scope } = {}) {
284
+ const paths = daemonPaths(toolNameOrOptions, scope);
285
+ await unregisterManagedDaemon(toolNameOrOptions, { scope });
286
+ await rm(paths.root, { recursive: true, force: true });
287
+ return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
288
+ }
289
+
283
290
  export async function stopManagedDaemon(toolNameOrOptions, {
284
291
  scope,
285
292
  signal = "SIGTERM",
@@ -43,10 +43,10 @@ export async function readMemoryPressure({
43
43
  };
44
44
  }
45
45
 
46
- export function memoryPressureReason(snapshot, policy) {
46
+ export function memoryPressureReason(snapshot, policy, { ignoreWorkerRss = false } = {}) {
47
47
  const mebibyte = 1024 * 1024;
48
48
  const workerRssMb = snapshot.workerRssBytes / mebibyte;
49
- if (workerRssMb > policy.maxWorkerRssMb) {
49
+ if (!ignoreWorkerRss && workerRssMb > policy.maxWorkerRssMb) {
50
50
  return `worker RSS ${Math.ceil(workerRssMb)} MiB exceeds the ${policy.maxWorkerRssMb} MiB limit`;
51
51
  }
52
52
  if (snapshot.swapTotalBytes > 0 && snapshot.swapUsedPercent > policy.maxSwapUsedPercent) {
@@ -42,6 +42,25 @@ function concurrentExecutionKey(name, chatId, request) {
42
42
  return createHash("sha256").update(serialized).digest("hex");
43
43
  }
44
44
 
45
+ export function readyDaemonAdmission(tool, diagnostic) {
46
+ const reusable = Boolean(tool?.daemon)
47
+ && diagnostic?.alive === true
48
+ && diagnostic?.state === "ready"
49
+ && diagnostic?.restart?.requested !== true;
50
+ return reusable ? { ignoreWorkerRss: true } : {};
51
+ }
52
+
53
+ function declaredDaemonScope(tool, chatId) {
54
+ if (!tool?.daemon) return null;
55
+ return tool.daemon.scope === "chat"
56
+ ? { type: "chat", chatId }
57
+ : { type: "global" };
58
+ }
59
+
60
+ function nativeDaemonScope(tool, chatId) {
61
+ return tool?.daemon?.protocol === "arisa-daemon-v1" ? declaredDaemonScope(tool, chatId) : null;
62
+ }
63
+
45
64
  function executionForLease(execution, lease) {
46
65
  if (!execution) return null;
47
66
  return {
@@ -399,20 +418,29 @@ export class ToolRegistry {
399
418
  let leaseOutcome = {};
400
419
  let result;
401
420
  try {
402
- lease = await this.executionGovernor.acquire(tool.execution, name);
421
+ const admissionScope = declaredDaemonScope(tool, chatId);
422
+ let admission = {};
423
+ if (admissionScope) {
424
+ const diagnostic = await readDaemonDiagnostic({
425
+ toolName: name,
426
+ scope: admissionScope,
427
+ autoStart: Boolean(tool.daemon.autoStart)
428
+ }).catch(() => null);
429
+ admission = readyDaemonAdmission(tool, diagnostic);
430
+ if (admission.ignoreWorkerRss) this.logger?.log("tools", `reusing ready ${name} daemon without worker RSS spawn admission`);
431
+ }
432
+ lease = await this.executionGovernor.acquire(tool.execution, name, admission);
403
433
  this.logger?.log("tools", `running ${name}`);
404
434
  await mkdir(tmpDir, { recursive: true });
405
435
  const skills = await this.resolveSkills(name);
406
436
  const enrichedRequest = { ...request, chatId, skills };
407
- if (tool.daemon?.protocol === "arisa-daemon-v1") {
408
- const scope = tool.daemon.scope === "chat"
409
- ? { type: "chat", chatId }
410
- : { type: "global" };
437
+ const runtimeScope = nativeDaemonScope(tool, chatId);
438
+ if (runtimeScope) {
411
439
  const runtime = createDaemonRuntime({
412
440
  toolName: name,
413
441
  entryPath: tool.entry,
414
- scope,
415
- startupContext: scope.type === "chat" ? { chatId: String(chatId) } : {},
442
+ scope: runtimeScope,
443
+ startupContext: runtimeScope.type === "chat" ? { chatId: String(chatId) } : {},
416
444
  autoStart: Boolean(tool.daemon.autoStart)
417
445
  });
418
446
  result = await runtime.submit(enrichedRequest, { onEvent });
@@ -205,11 +205,11 @@ export class WeightedResourceGovernor {
205
205
  request.resolve(this.createLease(request, waitedMs));
206
206
  }
207
207
 
208
- async pressureSnapshot(label, resourceClass) {
208
+ async pressureSnapshot(label, resourceClass, { ignoreWorkerRss = false } = {}) {
209
209
  const pressure = await this.memoryPressure();
210
210
  this.lastPressure = pressure;
211
211
  this.lastMemoryBudgetMb = this.memoryBudgetMb(pressure);
212
- const reason = memoryPressureReason(pressure, this.policy);
212
+ const reason = memoryPressureReason(pressure, this.policy, { ignoreWorkerRss });
213
213
  if (!reason) return pressure;
214
214
  const error = new Error(`Tool ${label} was not started because ${reason}`);
215
215
  error.code = "TOOL_RESOURCE_PRESSURE";
@@ -234,7 +234,7 @@ export class WeightedResourceGovernor {
234
234
  const next = state.queue[0];
235
235
  if (!next) continue;
236
236
  try {
237
- const pressure = await this.pressureSnapshot(next.label, resourceClass);
237
+ const pressure = await this.pressureSnapshot(next.label, resourceClass, next.admission);
238
238
  const budgetMb = this.memoryBudgetMb(pressure);
239
239
  next.memoryMb = this.requestedMemoryMb(next.execution, next.label, budgetMb);
240
240
  if (next.memoryMb < this.policy.minimumToolMemoryMb || !this.canGrant(next)) continue;
@@ -253,14 +253,14 @@ export class WeightedResourceGovernor {
253
253
  }
254
254
  }
255
255
 
256
- async acquire(rawExecution, label = "tool") {
256
+ async acquire(rawExecution, label = "tool", admission = {}) {
257
257
  const execution = normalizeToolExecution(rawExecution);
258
258
  if (!execution) return noopLease();
259
259
  const resourceClass = execution.resourceClass;
260
260
  const capacity = this.capacityFor(resourceClass);
261
261
  const weight = Math.min(execution.weight, capacity);
262
262
  const state = this.stateFor(resourceClass);
263
- const pressure = await this.pressureSnapshot(label, resourceClass);
263
+ const pressure = await this.pressureSnapshot(label, resourceClass, admission);
264
264
  const budgetMb = this.memoryBudgetMb(pressure);
265
265
  const memoryMb = this.requestedMemoryMb(execution, label, budgetMb);
266
266
  if (budgetMb < this.policy.minimumToolMemoryMb || memoryMb < this.policy.minimumToolMemoryMb) {
@@ -274,7 +274,8 @@ export class WeightedResourceGovernor {
274
274
  weight,
275
275
  memoryMb,
276
276
  label,
277
- queuedAt: this.now()
277
+ queuedAt: this.now(),
278
+ admission: { ignoreWorkerRss: admission?.ignoreWorkerRss === true }
278
279
  };
279
280
  if (!state.queue.length && this.canGrant(request)) {
280
281
  return new Promise((resolve) => this.grant({ ...request, resolve }));