arisa 5.1.68 → 5.2.17

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 (97) hide show
  1. package/README.md +28 -6
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +123 -8
  4. package/src/core/agent/agent-session-lifecycle.js +108 -8
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/core-tools.js +1 -1
  7. package/src/core/agent/pi-auth-login.js +1 -1
  8. package/src/core/agent/pi-capability-tools.js +7 -4
  9. package/src/core/agent/pi-runtime.js +1 -1
  10. package/src/core/agent/runtime-context.js +1 -1
  11. package/src/core/agent/session-history-reader.js +168 -0
  12. package/src/core/agent/session-preload-migration.js +162 -0
  13. package/src/core/agent/session-rotation.js +29 -0
  14. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  15. package/src/core/agent/worker-tool-fanout.js +117 -0
  16. package/src/core/artifacts/artifact-store.js +1 -1
  17. package/src/core/capabilities/capability-service.js +4 -3
  18. package/src/core/config/config-defaults.js +59 -1
  19. package/src/core/config/config-store.js +1 -1
  20. package/src/core/conversation/session-seed-store.js +1 -1
  21. package/src/core/tasks/task-runner.js +8 -4
  22. package/src/core/tasks/task-store.js +48 -5
  23. package/src/core/tools/daemon-client.js +180 -0
  24. package/src/core/tools/daemon-processes.js +26 -3
  25. package/src/core/tools/daemon-protocol.js +72 -0
  26. package/src/core/tools/daemon-runtime.js +13 -490
  27. package/src/core/tools/daemon-worker.js +310 -0
  28. package/src/core/tools/ipc-client.js +2 -2
  29. package/src/core/tools/memory-pressure.js +56 -0
  30. package/src/core/tools/official-tool-installer.js +1 -1
  31. package/src/core/tools/tool-config.js +1 -1
  32. package/src/core/tools/tool-process-output.js +100 -0
  33. package/src/core/tools/tool-process-runner.js +175 -0
  34. package/src/core/tools/tool-registry.js +131 -191
  35. package/src/core/tools/tool-resource-note-store.js +1 -1
  36. package/src/core/tools/tool-usage-store.js +1 -1
  37. package/src/core/tools/weighted-resource-governor.js +189 -38
  38. package/src/index.js +14 -2
  39. package/src/official-tools.lock.json +526 -78
  40. package/src/platform/paths.js +152 -0
  41. package/src/runtime/bootstrap-cli.js +121 -0
  42. package/src/runtime/bootstrap-config.js +97 -0
  43. package/src/runtime/bootstrap-telegram.js +325 -0
  44. package/src/runtime/bootstrap.js +6 -543
  45. package/src/runtime/doctor.js +26 -76
  46. package/src/runtime/flush.js +1 -1
  47. package/src/runtime/ipc/ipc-server.js +1 -1
  48. package/src/runtime/log-viewer.js +1 -1
  49. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  50. package/src/runtime/oom-protection.js +20 -0
  51. package/src/runtime/paths.js +3 -151
  52. package/src/runtime/process-inspection.js +78 -0
  53. package/src/runtime/restart-receipt.js +1 -1
  54. package/src/runtime/service-manager.js +1 -1
  55. package/src/runtime/service-supervisor.js +14 -0
  56. package/src/runtime/slave-cli.js +1 -1
  57. package/src/runtime/tool-process-supervisor.js +36 -9
  58. package/src/runtime/tui.js +200 -0
  59. package/src/runtime/update-manager.js +1 -1
  60. package/src/runtime/worker-recovery-report.js +142 -0
  61. package/src/transport/telegram/bot.js +45 -321
  62. package/src/transport/telegram/chat-queue.js +6 -2
  63. package/src/transport/telegram/prompt-builders.js +8 -3
  64. package/src/transport/telegram/task-dispatcher.js +36 -11
  65. package/src/transport/telegram/telegram-prompt-controller.js +359 -0
  66. package/src/transport/telegram/workspace-topic-store.js +1 -1
  67. package/test/agent-session-lifecycle.test.js +92 -0
  68. package/test/agent-turn-coordinator.test.js +45 -0
  69. package/test/architecture-boundaries.test.js +29 -0
  70. package/test/bootstrap.test.js +65 -0
  71. package/test/context-and-task-bounds.test.js +2 -1
  72. package/test/daemon-process-invocation.test.js +27 -0
  73. package/test/daemon-runtime.test.js +38 -5
  74. package/test/doctor.test.js +41 -0
  75. package/test/memory-pressure.test.js +41 -0
  76. package/test/model-selection.test.js +11 -1
  77. package/test/obsolete-daemon-reaper.test.js +61 -0
  78. package/test/official-tool-dependencies.test.js +7 -2
  79. package/test/official-tool-installer.test.js +18 -1
  80. package/test/oom-protection.test.js +32 -0
  81. package/test/paths.test.js +7 -0
  82. package/test/pi-compaction.test.js +30 -0
  83. package/test/service-manager.test.js +6 -1
  84. package/test/session-history-reader.test.js +84 -0
  85. package/test/session-preload-migration.test.js +120 -0
  86. package/test/session-rotation.test.js +110 -0
  87. package/test/task-store.test.js +32 -0
  88. package/test/telegram-prompt-controller.test.js +82 -0
  89. package/test/telegram-task-dispatcher.test.js +66 -5
  90. package/test/telegram-text-artifact.test.js +30 -0
  91. package/test/tool-registry-run.test.js +117 -4
  92. package/test/tui.test.js +41 -0
  93. package/test/weighted-resource-governor.test.js +125 -5
  94. package/test/worker-heap-circuit-breaker.test.js +79 -0
  95. package/test/worker-recovery-report.test.js +69 -0
  96. package/test/worker-tool-fanout.test.js +79 -0
  97. package/test-fixtures/fake-daemon.js +5 -0
package/README.md CHANGED
@@ -108,13 +108,16 @@ Per chat (`~/.arisa/chats/<chatId>/`):
108
108
  - chat-scoped daemon infrastructure lives in `state/tools/<tool>/daemon/`; persistent tool data stays beside it
109
109
  - ephemeral scratch lives under `tmp/`
110
110
 
111
- Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context.
111
+ Managed daemons become ready only after their tool-defined health operation succeeds through the normal command queue. Arisa records heartbeats, successful jobs, errors, and standard lifecycle states, then retries recovery or recreates an unhealthy process with its persisted scope and startup context. The supervisor automatically removes registrations and daemon runtime directories that no longer match an installed daemon tool. A live process is terminated only when its command line matches the registered entry and daemon invocation; unverifiable PIDs are left untouched and reported for attention.
112
112
 
113
113
  Daemon tools may opt into the `arisa-daemon-v1` local protocol for immediate
114
114
  multiplexed jobs and incremental NDJSON events over a capability-protected local
115
115
  socket. The runtime persists request, accepted and terminal records so a restart
116
- can recover queued work and will not silently repeat an accepted effect. Legacy
117
- daemon tools continue to use the existing request-file contract.
116
+ can recover queued work and will not silently repeat an accepted effect. When a
117
+ client deadline expires, the runtime sends a scoped cancellation signal to that
118
+ job before closing the request; cooperative tools can stop it without restarting
119
+ the shared daemon or interrupting unrelated sessions. Legacy daemon tools
120
+ continue to use the existing request-file contract.
118
121
 
119
122
  ### Arisa Master and Slave
120
123
 
@@ -148,12 +151,31 @@ Automatic context compaction uses Pi's native implementation and can be tuned in
148
151
  "enabled": true,
149
152
  "reserveTokens": 120000,
150
153
  "keepRecentTokens": 20000
154
+ },
155
+ "sessionRotation": {
156
+ "enabled": true,
157
+ "compactAtPersistedBytes": 25165824,
158
+ "maxPersistedBytes": 33554432
159
+ },
160
+ "toolFanout": {
161
+ "enabled": true,
162
+ "maxConcurrent": 2,
163
+ "pressureConcurrent": 1,
164
+ "serializePercent": 60
165
+ },
166
+ "turnCoordinator": {
167
+ "enabled": true,
168
+ "backgroundQueueTtlMs": 600000,
169
+ "interactiveQueueTtlMs": 0,
170
+ "maxQueued": 100
151
171
  }
152
172
  }
153
173
  }
154
174
  ```
155
175
 
156
- Pi compacts when the context exceeds the model's context window minus `reserveTokens`. The default keeps a large reserve so compaction occurs before Arisa Doctor's context warning on the default model. Set a smaller reserve when using models with substantially smaller context windows. Arisa does not add Telegram commands or compaction notifications.
176
+ Pi compacts when the context exceeds the model's context window minus `reserveTokens`. Arisa also requests compaction when persisted history exceeds `compactAtPersistedBytes`, then rotates to a fresh JSONL using the latest summary and retained active context. Before Pi loads a recent session above `maxPersistedBytes`, Arisa discovers the last valid active-branch compaction by streaming, atomically creates a compact child with `parentSession`, and leaves the historical JSONL intact. An unsafe oversized session is rejected rather than loaded into an OOM-prone worker. Same-turn `run_tool` fan-out is capped at two, becomes sequential when heap usage reaches `serializePercent`, and uses the heap circuit breaker before every admission. Across chats and topics, `turnCoordinator` admits only one Pi turn or headless poll at a time, prioritizes interactive messages and events over queued background work, and safely expires stale background admissions before execution. Set a smaller token reserve when using models with substantially smaller context windows. Arisa adds no Telegram commands or compaction notifications.
177
+
178
+ Browser and checkout tools report lifecycle states separately: profile connection is not a session share, a session share is not target validation, wallet enrollment is not checkout submission, and only explicit target or merchant evidence can confirm the final objective.
157
179
 
158
180
  ## Install globally
159
181
 
@@ -181,9 +203,9 @@ arisa --silent # run without verbose logs
181
203
 
182
204
  Authorized Telegram chats can run the same safe service lifecycle with `/restart`.
183
205
 
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.
206
+ Background mode runs the Telegram/Pi worker under a lightweight supervisor. Unexpected worker exits use bounded exponential restart backoff. After recovery, authorized Telegram chats automatically receive a bounded report with the classified exit cause, recent tool names and counts, uncertain scheduled executions, restart delay, and running version; prompts and private payloads are never included. 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
207
 
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, while the built-in `orchestrator` class has capacity one. An opt-in tool may set `deduplicateConcurrent: true` to join only exact concurrent duplicates from the same chat; later or different requests still execute normally. Override capacities with `toolExecution.defaultCapacity`, `toolExecution.capacities`, and `toolExecution.maxQueuedPerClass`. Arisa logs queue waits, joined duplicates, and new worker RSS peaks for operational measurement.
208
+ Tools may opt into weighted resource governance with a manifest declaration such as `"execution": { "resourceClass": "browser", "weight": 1 }`; undeclared lightweight and nested orchestration calls remain unconstrained. Declared runs queue when their class reaches its concurrency capacity. A global memory broker also reserves RAM across declared classes, leaving configurable system and core reserves before it admits work. The broker starts declared tools with a 384 MiB recommendation, derives their V8 heap from the granted memory, and raises the recommendation after an isolated memory-limit failure. Manifest values for `maxHeapMb` and `maxMemoryMb` act as ceilings; `maxOutputBytes` bounds protocol output. On systemd-based Linux hosts, each declared process tree runs in `arisa-tools.slice` with `MemoryHigh`, `MemoryMax`, bounded swap, and a higher OOM-kill priority than the core. Tool daemons receive the same OOM priority. Arisa lowers the core OOM score when the service account permits it. A limit failure stays inside the tool process tree and returns an uncertain result. Set host reserves and dynamic grants with `toolExecution.systemReserveMb`, `coreReserveMb`, `initialToolMemoryMb`, `minimumToolMemoryMb`, and `maximumToolMemoryMb`. Heap, soft-pressure, and swap controls use `toolHeapPercent`, `toolMemoryHighPercent`, and `toolSwapMaxMb`. Admission still checks `maxWorkerRssMb` and `maxSwapUsedPercent`. Class controls remain available through `defaultCapacity`, `capacities`, and `maxQueuedPerClass`. `deduplicateConcurrent: true` joins exact concurrent duplicates from the same chat.
187
209
 
188
210
  Runtime model override (current process only):
189
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.1.68",
3
+ "version": "5.2.17",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,11 +8,15 @@ import { buildPiToolPolicy } from "./core-tools.js";
8
8
  import { createSystemShellTool } from "./system-shell-tool.js";
9
9
  import { clampModelThinkingLevel } from "./pi-runtime.js";
10
10
  import { clampModelSpeed, createModelSpeedController } from "./model-speed.js";
11
- import { arisaHomeDir } from "../../runtime/paths.js";
11
+ import { arisaHomeDir } from "../../platform/paths.js";
12
12
  import { AgentSessionLifecycle } from "./agent-session-lifecycle.js";
13
13
  import { createPiCapabilityTools } from "./pi-capability-tools.js";
14
14
  import { ToolResourceNoteStore } from "../tools/tool-resource-note-store.js";
15
15
  import { materializeToolOutput } from "../tools/tool-output-materializer.js";
16
+ import { WorkerHeapCircuitBreaker } from "./worker-heap-circuit-breaker.js";
17
+ import { WorkerToolFanoutController } from "./worker-tool-fanout.js";
18
+ import { compactionRotationRequest, normalizeSessionRotationPolicy } from "./session-rotation.js";
19
+ import { AgentTurnCoordinator } from "./agent-turn-coordinator.js";
16
20
 
17
21
  const piValidationTimeoutMs = 60_000;
18
22
  const arisaToolNames = [
@@ -154,7 +158,23 @@ export class AgentManager {
154
158
  this.resourceNotes = new ToolResourceNoteStore();
155
159
  this.sessionLifecycle = new AgentSessionLifecycle({
156
160
  logger,
157
- summarizeContext: summarizeRetainedContext
161
+ summarizeContext: summarizeRetainedContext,
162
+ cachePolicy: config.pi.sessionCache,
163
+ sessionRotationPolicy: config.pi.sessionRotation
164
+ });
165
+ this.heapCircuitBreaker = new WorkerHeapCircuitBreaker({
166
+ lifecycle: this.sessionLifecycle,
167
+ logger,
168
+ config: config.pi.heapCircuitBreaker
169
+ });
170
+ this.toolFanout = new WorkerToolFanoutController({
171
+ heapCircuitBreaker: this.heapCircuitBreaker,
172
+ logger,
173
+ config: config.pi.toolFanout
174
+ });
175
+ this.turnCoordinator = new AgentTurnCoordinator({
176
+ logger,
177
+ config: config.pi.turnCoordinator
158
178
  });
159
179
  this.sessions = this.sessionLifecycle.sessions;
160
180
  this.pendingNewSessions = this.sessionLifecycle.pendingNewSessions;
@@ -188,6 +208,11 @@ export class AgentManager {
188
208
 
189
209
  setConfig(config) {
190
210
  this.sessionLifecycle.resetConfigState();
211
+ this.sessionLifecycle.setCachePolicy(config.pi.sessionCache);
212
+ this.sessionLifecycle.setSessionRotationPolicy(config.pi.sessionRotation);
213
+ this.heapCircuitBreaker.setConfig(config.pi.heapCircuitBreaker);
214
+ this.toolFanout.setConfig(config.pi.toolFanout);
215
+ this.turnCoordinator.setConfig(config.pi.turnCoordinator);
191
216
  this.config = config;
192
217
  }
193
218
 
@@ -209,14 +234,93 @@ export class AgentManager {
209
234
  }
210
235
  }
211
236
 
212
- getRuntimeDiagnostic() {
213
- return this.sessionLifecycle.getDiagnostic();
237
+ async getRuntimeDiagnostic() {
238
+ const diagnostic = await this.sessionLifecycle.getDiagnostic();
239
+ return {
240
+ ...diagnostic,
241
+ heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic(),
242
+ toolFanout: this.toolFanout.getDiagnostic(),
243
+ turnCoordinator: this.turnCoordinator.diagnostic()
244
+ };
214
245
  }
215
246
 
216
247
  createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
217
248
  return this.sessionLifecycle.createSessionManager(chatId, workspaceDir, sessionRevision);
218
249
  }
219
250
 
251
+ async estimatePersistedSessionBytes(session) {
252
+ const sessionStats = session?.getSessionStats?.();
253
+ const sessionFile = session?.sessionManager?.getSessionFile?.() || sessionStats?.sessionFile;
254
+ if (!sessionFile) return 0;
255
+ try {
256
+ return (await stat(sessionFile)).size;
257
+ } catch {
258
+ return 0;
259
+ }
260
+ }
261
+
262
+ async acquireSessionContext(sessionKey, context) {
263
+ const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
264
+ this.sessionLifecycle.acquireCached(sessionKey, persistedBytes);
265
+ context.release = () => this.releaseSessionContext(sessionKey, context);
266
+ await this.sessionLifecycle.enforceCachePolicy({ protectedSessionKeys: [sessionKey] });
267
+ return context;
268
+ }
269
+
270
+ async compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes) {
271
+ const policy = normalizeSessionRotationPolicy(this.config.pi.sessionRotation);
272
+ if (!policy.enabled
273
+ || persistedBytes <= policy.compactAtPersistedBytes
274
+ || context.rotationRequest
275
+ || typeof context.session?.compact !== "function") return;
276
+ try {
277
+ this.logger?.log("agent", `compacting ${Math.ceil(persistedBytes / 1024 / 1024)} MiB Pi session for chat ${sessionKey} before rotation`);
278
+ await context.session.compact();
279
+ await context.rotationCheckPromise;
280
+ } catch (error) {
281
+ this.logger?.error?.("agent", `persisted-size compaction failed for chat ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`);
282
+ }
283
+ }
284
+
285
+ async releaseSessionContext(sessionKey, context) {
286
+ if (this.sessions.get(String(sessionKey)) !== context) return;
287
+ await context.rotationCheckPromise;
288
+ const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
289
+ if ((context.activeUsers || 0) <= 1) {
290
+ await this.compactPersistedSessionIfNeeded(sessionKey, context, persistedBytes);
291
+ }
292
+ this.sessionLifecycle.releaseCached(sessionKey, persistedBytes);
293
+ if ((context.activeUsers || 0) === 0 && context.rotationRequest) {
294
+ const request = context.rotationRequest;
295
+ const parentSession = context.session.sessionFile || "";
296
+ this.logger?.log("agent", `rotating ${Math.ceil(request.persistedBytes / 1024 / 1024)} MiB Pi session for chat ${sessionKey} after compaction`);
297
+ this.sessionLifecycle.resetSession(sessionKey, {
298
+ handoff: request.handoff,
299
+ parentSession,
300
+ source: "compaction-rotation"
301
+ });
302
+ await this.sessionLifecycle.waitForClose(sessionKey);
303
+ return;
304
+ }
305
+ await this.sessionLifecycle.enforceCachePolicy();
306
+ }
307
+
308
+ scheduleCompactionRotationCheck(sessionKey, context, event) {
309
+ if (event?.type !== "compaction_end") return;
310
+ const previous = context.rotationCheckPromise || Promise.resolve();
311
+ context.rotationCheckPromise = previous
312
+ .catch(() => {})
313
+ .then(async () => {
314
+ if (this.sessions.get(String(sessionKey)) !== context) return;
315
+ const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
316
+ const request = compactionRotationRequest(event, persistedBytes, this.config.pi.sessionRotation);
317
+ if (request) context.rotationRequest = request;
318
+ })
319
+ .catch((error) => {
320
+ this.logger?.error?.("agent", `session rotation check failed for chat ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`);
321
+ });
322
+ }
323
+
220
324
  async validatePiAgent(config = this.config) {
221
325
  this.logger?.log("agent", "validating Pi session");
222
326
  const { authStorage, modelRegistry } = createPiRuntime({
@@ -254,6 +358,7 @@ export class AgentManager {
254
358
  }
255
359
 
256
360
  async getSessionContext(chatId, telegram, { scopeChatId = chatId, accessGuard = async () => {} } = {}) {
361
+ await this.heapCircuitBreaker.admit();
257
362
  const sessionKey = String(chatId);
258
363
  const modelSelection = resolveChatModelSelection(this.config, sessionKey);
259
364
  const effectiveModelId = modelSelection.model;
@@ -274,7 +379,7 @@ export class AgentManager {
274
379
  existing.telegramTarget.current = telegram;
275
380
  existing.accessGuardTarget.current = accessGuard;
276
381
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
277
- return existing;
382
+ return this.acquireSessionContext(sessionKey, existing);
278
383
  }
279
384
  this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
280
385
  this.closeCachedSession(sessionKey);
@@ -362,11 +467,14 @@ export class AgentManager {
362
467
  modelKey: effectiveModelKey,
363
468
  speedController,
364
469
  telegramTarget,
365
- accessGuardTarget
470
+ accessGuardTarget,
471
+ rotationCheckPromise: Promise.resolve(),
472
+ rotationRequest: null
366
473
  };
474
+ session.subscribe((event) => this.scheduleCompactionRotationCheck(sessionKey, ctx, event));
367
475
  this.sessions.set(sessionKey, ctx);
368
476
  if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
369
- return ctx;
477
+ return this.acquireSessionContext(sessionKey, ctx);
370
478
  }
371
479
 
372
480
  async getAvailableModels(chatId) {
@@ -384,9 +492,15 @@ export class AgentManager {
384
492
  }
385
493
 
386
494
  async close() {
495
+ this.turnCoordinator.close();
387
496
  await this.sessionLifecycle.closeAll();
388
497
  }
389
498
 
499
+ async runTurn(options, work) {
500
+ if (typeof work !== "function") throw new Error("Agent turn work is required");
501
+ return this.turnCoordinator.run(options, work);
502
+ }
503
+
390
504
  async runTool({ name, request, chatId, taskContext = null }) {
391
505
  await this.toolRegistry.load();
392
506
  this.logger?.log("agent", `run_tool ${name}`);
@@ -413,7 +527,8 @@ export class AgentManager {
413
527
  telegram,
414
528
  chatId,
415
529
  policy,
416
- logger: this.logger
530
+ logger: this.logger,
531
+ toolFanout: this.toolFanout
417
532
  });
418
533
  }
419
534
 
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { SessionManager } from "@earendil-works/pi-coding-agent";
3
- import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../runtime/paths.js";
3
+ import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../platform/paths.js";
4
4
  import { arisaInstallDir } from "./runtime-context.js";
5
+ import { migrateRecentSessionBeforeLoad } from "./session-preload-migration.js";
5
6
 
6
7
  const operationalNoteMaxChars = 220;
7
8
 
@@ -26,7 +27,7 @@ export function loadSessionStartOperationalNotes() {
26
27
  }
27
28
  }
28
29
 
29
- function formatSessionStartOperationalNotes(notes) {
30
+ export function formatSessionStartOperationalNotes(notes) {
30
31
  if (!notes.length) return "";
31
32
  return [
32
33
  "Durable operating notes for this Arisa session:",
@@ -41,13 +42,89 @@ function closeAgentSession(session) {
41
42
  }
42
43
 
43
44
  export class AgentSessionLifecycle {
44
- constructor({ logger, summarizeContext }) {
45
+ constructor({ logger, summarizeContext, cachePolicy = {}, sessionRotationPolicy = {} }) {
45
46
  this.logger = logger;
46
47
  this.summarizeContext = summarizeContext;
47
48
  this.sessions = new Map();
48
49
  this.pendingNewSessions = new Set();
49
50
  this.pendingSessionHandoffs = new Map();
50
51
  this.sessionClosePromises = new Map();
52
+ this.setCachePolicy(cachePolicy);
53
+ this.setSessionRotationPolicy(sessionRotationPolicy);
54
+ }
55
+
56
+ setCachePolicy(cachePolicy = {}) {
57
+ this.cachePolicy = {
58
+ maxSessions: Math.max(1, Number(cachePolicy.maxSessions) || 3),
59
+ maxPersistedBytes: Math.max(1, Number(cachePolicy.maxPersistedBytes) || 48 * 1024 * 1024)
60
+ };
61
+ }
62
+
63
+ setSessionRotationPolicy(policy = {}) {
64
+ this.sessionRotationPolicy = { ...policy };
65
+ }
66
+
67
+ acquireCached(sessionKey, persistedBytes = 0) {
68
+ const context = this.sessions.get(String(sessionKey));
69
+ if (!context) return;
70
+ context.activeUsers = (context.activeUsers || 0) + 1;
71
+ context.lastAccessedAt = Date.now();
72
+ context.persistedBytes = Math.max(0, Number(persistedBytes) || 0);
73
+ }
74
+
75
+ releaseCached(sessionKey, persistedBytes = 0) {
76
+ const context = this.sessions.get(String(sessionKey));
77
+ if (!context) return;
78
+ context.activeUsers = Math.max(0, (context.activeUsers || 0) - 1);
79
+ context.lastAccessedAt = Date.now();
80
+ context.persistedBytes = Math.max(0, Number(persistedBytes) || 0);
81
+ }
82
+
83
+ cacheUsage() {
84
+ return {
85
+ sessions: this.sessions.size,
86
+ persistedBytes: [...this.sessions.values()].reduce((total, context) => total + (context.persistedBytes || 0), 0)
87
+ };
88
+ }
89
+
90
+ cacheOverLimit() {
91
+ const usage = this.cacheUsage();
92
+ return usage.sessions > this.cachePolicy.maxSessions
93
+ || usage.persistedBytes > this.cachePolicy.maxPersistedBytes;
94
+ }
95
+
96
+ evictionCandidate(protectedKeys = new Set()) {
97
+ return [...this.sessions.entries()]
98
+ .filter(([key, context]) => !protectedKeys.has(key) && !(context.activeUsers > 0) && !context.session?.isStreaming)
99
+ .sort((left, right) => (left[1].lastAccessedAt || 0) - (right[1].lastAccessedAt || 0))[0];
100
+ }
101
+
102
+ async evictInactive({ protectedSessionKeys = [] } = {}) {
103
+ const protectedKeys = new Set(protectedSessionKeys.map(String));
104
+ const evicted = [];
105
+ let candidate = this.evictionCandidate(protectedKeys);
106
+ while (candidate) {
107
+ const [sessionKey, context] = candidate;
108
+ evicted.push({ sessionKey, persistedBytes: context.persistedBytes || 0 });
109
+ this.logger?.log("agent", `evicting inactive Pi session for chat ${sessionKey} from resident cache`);
110
+ await this.closeCached(sessionKey);
111
+ candidate = this.evictionCandidate(protectedKeys);
112
+ }
113
+ return evicted;
114
+ }
115
+
116
+ async enforceCachePolicy({ protectedSessionKeys = [] } = {}) {
117
+ const protectedKeys = new Set(protectedSessionKeys.map(String));
118
+ const evicted = [];
119
+ while (this.cacheOverLimit()) {
120
+ const candidate = this.evictionCandidate(protectedKeys);
121
+ if (!candidate) break;
122
+ const [sessionKey, context] = candidate;
123
+ evicted.push({ sessionKey, persistedBytes: context.persistedBytes || 0 });
124
+ this.logger?.log("agent", `evicting inactive Pi session for chat ${sessionKey} from resident cache`);
125
+ await this.closeCached(sessionKey);
126
+ }
127
+ return evicted;
51
128
  }
52
129
 
53
130
  closeCached(sessionKey) {
@@ -90,14 +167,19 @@ export class AgentSessionLifecycle {
90
167
  this.pendingSessionHandoffs.clear();
91
168
  }
92
169
 
93
- resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
170
+ resetSession(chatId, { handoff = "", parentSession = "", source = "" } = {}) {
94
171
  const sessionKey = String(chatId);
95
172
  this.closeCached(sessionKey);
96
173
  this.pendingNewSessions.add(sessionKey);
97
174
  const text = String(handoff || "").trim();
98
175
  const parent = String(parentSession || "").trim();
176
+ const handoffSource = String(source || "").trim();
99
177
  if (text || parent) {
100
- this.pendingSessionHandoffs.set(sessionKey, { text, parentSession: parent });
178
+ this.pendingSessionHandoffs.set(sessionKey, {
179
+ text,
180
+ parentSession: parent,
181
+ ...(handoffSource ? { source: handoffSource } : {})
182
+ });
101
183
  } else {
102
184
  this.pendingSessionHandoffs.delete(sessionKey);
103
185
  }
@@ -106,6 +188,7 @@ export class AgentSessionLifecycle {
106
188
  createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
107
189
  const sessionKey = String(chatId);
108
190
  const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
191
+ const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
109
192
  if (this.pendingNewSessions.has(sessionKey)) {
110
193
  this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
111
194
  const handoff = this.pendingSessionHandoffs.get(sessionKey);
@@ -114,7 +197,6 @@ export class AgentSessionLifecycle {
114
197
  sessionDir,
115
198
  handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
116
199
  );
117
- const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
118
200
  if (operationalNotes) {
119
201
  sessionManager.appendCustomMessageEntry(
120
202
  "arisa-operational-notes",
@@ -128,11 +210,23 @@ export class AgentSessionLifecycle {
128
210
  "arisa-session-handoff",
129
211
  handoff.text,
130
212
  false,
131
- { source: "telegram-new" }
213
+ { source: handoff.source || "telegram-new" }
132
214
  );
133
215
  }
134
216
  return { sessionManager, isNewSession: true };
135
217
  }
218
+ const migration = migrateRecentSessionBeforeLoad({
219
+ sessionDir,
220
+ cwd: workspaceDir,
221
+ policy: this.sessionRotationPolicy,
222
+ operationalNotes
223
+ });
224
+ if (migration) {
225
+ this.logger?.log(
226
+ "agent",
227
+ `migrated oversized Pi session for chat ${sessionKey} before loading (${Math.ceil(migration.sourceBytes / 1024 / 1024)} MiB -> ${Math.ceil(migration.targetBytes / 1024 / 1024)} MiB)`
228
+ );
229
+ }
136
230
  this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
137
231
  return {
138
232
  sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir),
@@ -147,7 +241,12 @@ export class AgentSessionLifecycle {
147
241
 
148
242
  async getDiagnostic() {
149
243
  const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
150
- const base = { chatId };
244
+ const base = {
245
+ chatId,
246
+ activeUsers: context.activeUsers || 0,
247
+ persistedBytes: context.persistedBytes || 0,
248
+ lastAccessedAt: context.lastAccessedAt || null
249
+ };
151
250
  try {
152
251
  const stats = context.session.getSessionStats();
153
252
  const retained = this.summarizeContext(context.session.messages);
@@ -166,6 +265,7 @@ export class AgentSessionLifecycle {
166
265
  harness: "pi",
167
266
  sessions: this.sessions.size,
168
267
  closingSessions: this.sessionClosePromises.size,
268
+ cache: { ...this.cachePolicy, ...this.cacheUsage() },
169
269
  contexts
170
270
  };
171
271
  }
@@ -0,0 +1,143 @@
1
+ function normalizedPriority(value) {
2
+ if (value === "interactive") return 10;
3
+ if (value === "background") return 0;
4
+ const parsed = Number(value);
5
+ return Number.isFinite(parsed) ? Math.max(-100, Math.min(100, parsed)) : 0;
6
+ }
7
+
8
+ function positiveInteger(value, fallback, max) {
9
+ const parsed = Number.parseInt(String(value ?? ""), 10);
10
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.min(max, parsed) : fallback;
11
+ }
12
+
13
+ function queueExpiredError(label) {
14
+ const error = new Error(`${label || "Agent turn"} expired while waiting for exclusive execution.`);
15
+ error.code = "AGENT_TURN_QUEUE_EXPIRED";
16
+ error.retryable = true;
17
+ error.outcomeUncertain = false;
18
+ return error;
19
+ }
20
+
21
+ export class AgentTurnCoordinator {
22
+ constructor({ config = {}, logger = null, now = Date.now } = {}) {
23
+ this.logger = logger;
24
+ this.now = now;
25
+ this.queue = [];
26
+ this.active = null;
27
+ this.sequence = 0;
28
+ this.closed = false;
29
+ this.completed = 0;
30
+ this.expired = 0;
31
+ this.maxObservedQueue = 0;
32
+ this.totalWaitMs = 0;
33
+ this.setConfig(config);
34
+ }
35
+
36
+ setConfig(config = {}) {
37
+ this.config = {
38
+ enabled: config.enabled !== false,
39
+ backgroundQueueTtlMs: positiveInteger(config.backgroundQueueTtlMs, 10 * 60_000, 60 * 60_000),
40
+ interactiveQueueTtlMs: positiveInteger(config.interactiveQueueTtlMs, 0, 60 * 60_000),
41
+ maxQueued: Math.max(1, positiveInteger(config.maxQueued, 100, 1_000))
42
+ };
43
+ }
44
+
45
+ queueTtlMs(priority, override) {
46
+ if (override != null) return positiveInteger(override, 0, 60 * 60_000);
47
+ return priority === "interactive" ? this.config.interactiveQueueTtlMs : this.config.backgroundQueueTtlMs;
48
+ }
49
+
50
+ describeActive(entry) {
51
+ if (!entry) return null;
52
+ return { label: entry.label, priority: entry.priorityName, startedAt: entry.startedAt };
53
+ }
54
+
55
+ diagnostic() {
56
+ return {
57
+ enabled: this.config.enabled,
58
+ active: this.describeActive(this.active),
59
+ queued: this.queue.length,
60
+ maxObservedQueue: this.maxObservedQueue,
61
+ completed: this.completed,
62
+ expired: this.expired,
63
+ averageWaitMs: this.completed ? Math.round(this.totalWaitMs / this.completed) : 0
64
+ };
65
+ }
66
+
67
+ remove(entry) {
68
+ const index = this.queue.indexOf(entry);
69
+ if (index >= 0) this.queue.splice(index, 1);
70
+ }
71
+
72
+ next() {
73
+ if (this.active || this.closed || !this.queue.length) return;
74
+ this.queue.sort((left, right) => right.priority - left.priority || left.sequence - right.sequence);
75
+ const entry = this.queue.shift();
76
+ if (entry.timer) clearTimeout(entry.timer);
77
+ entry.startedAt = new Date(this.now()).toISOString();
78
+ this.active = entry;
79
+ const waitMs = Math.max(0, this.now() - entry.queuedAt);
80
+ this.totalWaitMs += waitMs;
81
+ if (waitMs > 0) this.logger?.log("agent", `${entry.label} waited ${waitMs}ms for exclusive agent execution`);
82
+ let released = false;
83
+ entry.resolve(() => {
84
+ if (released) return;
85
+ released = true;
86
+ if (this.active === entry) this.active = null;
87
+ this.completed += 1;
88
+ queueMicrotask(() => this.next());
89
+ });
90
+ }
91
+
92
+ acquire({ priority: priorityName = "background", label = "Agent turn", queueTtlMs } = {}) {
93
+ if (!this.config.enabled) return Promise.resolve(() => {});
94
+ if (this.closed) return Promise.reject(Object.assign(new Error("Agent turn coordinator is closed."), { retryable: true }));
95
+ if (this.queue.length >= this.config.maxQueued) {
96
+ return Promise.reject(Object.assign(new Error("Agent turn queue is full."), { code: "AGENT_TURN_QUEUE_FULL", retryable: true }));
97
+ }
98
+ const priority = normalizedPriority(priorityName);
99
+ const ttlMs = this.queueTtlMs(priorityName, queueTtlMs);
100
+ return new Promise((resolve, reject) => {
101
+ const entry = {
102
+ label: String(label || "Agent turn").slice(0, 160),
103
+ priorityName: priorityName === "interactive" ? "interactive" : "background",
104
+ priority,
105
+ sequence: this.sequence += 1,
106
+ queuedAt: this.now(),
107
+ startedAt: null,
108
+ resolve,
109
+ reject,
110
+ timer: null
111
+ };
112
+ if (ttlMs > 0) {
113
+ entry.timer = setTimeout(() => {
114
+ if (this.active === entry) return;
115
+ this.remove(entry);
116
+ this.expired += 1;
117
+ reject(queueExpiredError(entry.label));
118
+ }, ttlMs);
119
+ entry.timer.unref?.();
120
+ }
121
+ this.queue.push(entry);
122
+ this.maxObservedQueue = Math.max(this.maxObservedQueue, this.queue.length);
123
+ this.next();
124
+ });
125
+ }
126
+
127
+ async run(options, work) {
128
+ const release = await this.acquire(options);
129
+ try {
130
+ return await work();
131
+ } finally {
132
+ release();
133
+ }
134
+ }
135
+
136
+ close() {
137
+ this.closed = true;
138
+ for (const entry of this.queue.splice(0)) {
139
+ if (entry.timer) clearTimeout(entry.timer);
140
+ entry.reject(Object.assign(new Error("Agent turn coordinator closed before execution."), { retryable: true }));
141
+ }
142
+ }
143
+ }
@@ -1,6 +1,6 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { arisaHomeDir } from "../../runtime/paths.js";
3
+ import { arisaHomeDir } from "../../platform/paths.js";
4
4
 
5
5
  const defaultShellTimeoutMs = 60_000;
6
6
 
@@ -1,5 +1,5 @@
1
1
  import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
- import { piAuthFile } from "../../runtime/paths.js";
2
+ import { piAuthFile } from "../../platform/paths.js";
3
3
 
4
4
  export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
5
5
  const authStorage = AuthStorage.create(piAuthFile);