arisa 5.1.66 → 5.2.7

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 (79) hide show
  1. package/README.md +7 -4
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +46 -6
  4. package/src/core/agent/agent-session-lifecycle.js +80 -3
  5. package/src/core/agent/core-tools.js +1 -1
  6. package/src/core/agent/pi-auth-login.js +1 -1
  7. package/src/core/agent/pi-runtime.js +1 -1
  8. package/src/core/agent/runtime-context.js +1 -1
  9. package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
  10. package/src/core/artifacts/artifact-store.js +1 -1
  11. package/src/core/capabilities/capability-service.js +1 -1
  12. package/src/core/config/config-defaults.js +30 -1
  13. package/src/core/config/config-store.js +1 -1
  14. package/src/core/conversation/session-seed-store.js +1 -1
  15. package/src/core/tasks/task-store.js +1 -1
  16. package/src/core/tools/daemon-client.js +180 -0
  17. package/src/core/tools/daemon-health.js +5 -2
  18. package/src/core/tools/daemon-processes.js +19 -3
  19. package/src/core/tools/daemon-protocol.js +72 -0
  20. package/src/core/tools/daemon-runtime.js +13 -490
  21. package/src/core/tools/daemon-worker.js +310 -0
  22. package/src/core/tools/ipc-client.js +2 -2
  23. package/src/core/tools/memory-pressure.js +56 -0
  24. package/src/core/tools/official-tool-installer.js +1 -1
  25. package/src/core/tools/tool-config.js +1 -1
  26. package/src/core/tools/tool-process-output.js +100 -0
  27. package/src/core/tools/tool-process-runner.js +175 -0
  28. package/src/core/tools/tool-registry.js +130 -186
  29. package/src/core/tools/tool-resource-note-store.js +1 -1
  30. package/src/core/tools/tool-usage-store.js +1 -1
  31. package/src/core/tools/weighted-resource-governor.js +192 -38
  32. package/src/index.js +14 -2
  33. package/src/official-tools.lock.json +430 -60
  34. package/src/platform/paths.js +152 -0
  35. package/src/runtime/bootstrap-cli.js +121 -0
  36. package/src/runtime/bootstrap-config.js +97 -0
  37. package/src/runtime/bootstrap-telegram.js +325 -0
  38. package/src/runtime/bootstrap.js +6 -543
  39. package/src/runtime/doctor.js +6 -3
  40. package/src/runtime/flush.js +1 -1
  41. package/src/runtime/ipc/ipc-server.js +1 -1
  42. package/src/runtime/log-viewer.js +1 -1
  43. package/src/runtime/oom-protection.js +20 -0
  44. package/src/runtime/paths.js +3 -151
  45. package/src/runtime/restart-receipt.js +1 -1
  46. package/src/runtime/service-manager.js +1 -1
  47. package/src/runtime/service-supervisor.js +14 -0
  48. package/src/runtime/slave-cli.js +2 -1
  49. package/src/runtime/tool-process-supervisor.js +1 -1
  50. package/src/runtime/tui.js +200 -0
  51. package/src/runtime/update-manager.js +1 -1
  52. package/src/runtime/worker-recovery-report.js +142 -0
  53. package/src/transport/telegram/bot.js +42 -320
  54. package/src/transport/telegram/prompt-builders.js +8 -3
  55. package/src/transport/telegram/telegram-prompt-controller.js +346 -0
  56. package/src/transport/telegram/workspace-topic-store.js +1 -1
  57. package/test/agent-session-lifecycle.test.js +92 -0
  58. package/test/architecture-boundaries.test.js +29 -0
  59. package/test/bootstrap.test.js +65 -0
  60. package/test/daemon-process-invocation.test.js +27 -0
  61. package/test/daemon-runtime.test.js +56 -1
  62. package/test/doctor.test.js +22 -0
  63. package/test/memory-pressure.test.js +36 -0
  64. package/test/model-selection.test.js +11 -1
  65. package/test/official-tool-dependencies.test.js +1 -1
  66. package/test/official-tool-installer.test.js +18 -1
  67. package/test/oom-protection.test.js +32 -0
  68. package/test/paths.test.js +7 -0
  69. package/test/pi-compaction.test.js +9 -0
  70. package/test/service-manager.test.js +6 -1
  71. package/test/slave-cli.test.js +20 -1
  72. package/test/telegram-prompt-controller.test.js +81 -0
  73. package/test/telegram-text-artifact.test.js +30 -0
  74. package/test/tool-registry-run.test.js +147 -4
  75. package/test/tui.test.js +41 -0
  76. package/test/weighted-resource-governor.test.js +103 -5
  77. package/test/worker-heap-circuit-breaker.test.js +79 -0
  78. package/test/worker-recovery-report.test.js +69 -0
  79. package/test-fixtures/fake-daemon.js +5 -0
package/README.md CHANGED
@@ -113,8 +113,11 @@ Managed daemons become ready only after their tool-defined health operation succ
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
 
@@ -181,9 +184,9 @@ arisa --silent # run without verbose logs
181
184
 
182
185
  Authorized Telegram chats can run the same safe service lifecycle with `/restart`.
183
186
 
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.
187
+ 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
188
 
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.
189
+ 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
190
 
188
191
  Runtime model override (current process only):
189
192
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "5.1.66",
3
+ "version": "5.2.7",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,11 +8,12 @@ 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";
16
17
 
17
18
  const piValidationTimeoutMs = 60_000;
18
19
  const arisaToolNames = [
@@ -154,7 +155,13 @@ export class AgentManager {
154
155
  this.resourceNotes = new ToolResourceNoteStore();
155
156
  this.sessionLifecycle = new AgentSessionLifecycle({
156
157
  logger,
157
- summarizeContext: summarizeRetainedContext
158
+ summarizeContext: summarizeRetainedContext,
159
+ cachePolicy: config.pi.sessionCache
160
+ });
161
+ this.heapCircuitBreaker = new WorkerHeapCircuitBreaker({
162
+ lifecycle: this.sessionLifecycle,
163
+ logger,
164
+ config: config.pi.heapCircuitBreaker
158
165
  });
159
166
  this.sessions = this.sessionLifecycle.sessions;
160
167
  this.pendingNewSessions = this.sessionLifecycle.pendingNewSessions;
@@ -188,6 +195,8 @@ export class AgentManager {
188
195
 
189
196
  setConfig(config) {
190
197
  this.sessionLifecycle.resetConfigState();
198
+ this.sessionLifecycle.setCachePolicy(config.pi.sessionCache);
199
+ this.heapCircuitBreaker.setConfig(config.pi.heapCircuitBreaker);
191
200
  this.config = config;
192
201
  }
193
202
 
@@ -209,14 +218,44 @@ export class AgentManager {
209
218
  }
210
219
  }
211
220
 
212
- getRuntimeDiagnostic() {
213
- return this.sessionLifecycle.getDiagnostic();
221
+ async getRuntimeDiagnostic() {
222
+ const diagnostic = await this.sessionLifecycle.getDiagnostic();
223
+ return {
224
+ ...diagnostic,
225
+ heapCircuitBreaker: this.heapCircuitBreaker.getDiagnostic()
226
+ };
214
227
  }
215
228
 
216
229
  createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
217
230
  return this.sessionLifecycle.createSessionManager(chatId, workspaceDir, sessionRevision);
218
231
  }
219
232
 
233
+ async estimatePersistedSessionBytes(session) {
234
+ const sessionStats = session?.getSessionStats?.();
235
+ const sessionFile = session?.sessionManager?.getSessionFile?.() || sessionStats?.sessionFile;
236
+ if (!sessionFile) return 0;
237
+ try {
238
+ return (await stat(sessionFile)).size;
239
+ } catch {
240
+ return 0;
241
+ }
242
+ }
243
+
244
+ async acquireSessionContext(sessionKey, context) {
245
+ const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
246
+ this.sessionLifecycle.acquireCached(sessionKey, persistedBytes);
247
+ context.release = () => this.releaseSessionContext(sessionKey, context);
248
+ await this.sessionLifecycle.enforceCachePolicy({ protectedSessionKeys: [sessionKey] });
249
+ return context;
250
+ }
251
+
252
+ async releaseSessionContext(sessionKey, context) {
253
+ if (this.sessions.get(String(sessionKey)) !== context) return;
254
+ const persistedBytes = await this.estimatePersistedSessionBytes(context.session);
255
+ this.sessionLifecycle.releaseCached(sessionKey, persistedBytes);
256
+ await this.sessionLifecycle.enforceCachePolicy();
257
+ }
258
+
220
259
  async validatePiAgent(config = this.config) {
221
260
  this.logger?.log("agent", "validating Pi session");
222
261
  const { authStorage, modelRegistry } = createPiRuntime({
@@ -254,6 +293,7 @@ export class AgentManager {
254
293
  }
255
294
 
256
295
  async getSessionContext(chatId, telegram, { scopeChatId = chatId, accessGuard = async () => {} } = {}) {
296
+ await this.heapCircuitBreaker.admit();
257
297
  const sessionKey = String(chatId);
258
298
  const modelSelection = resolveChatModelSelection(this.config, sessionKey);
259
299
  const effectiveModelId = modelSelection.model;
@@ -274,7 +314,7 @@ export class AgentManager {
274
314
  existing.telegramTarget.current = telegram;
275
315
  existing.accessGuardTarget.current = accessGuard;
276
316
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
277
- return existing;
317
+ return this.acquireSessionContext(sessionKey, existing);
278
318
  }
279
319
  this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
280
320
  this.closeCachedSession(sessionKey);
@@ -366,7 +406,7 @@ export class AgentManager {
366
406
  };
367
407
  this.sessions.set(sessionKey, ctx);
368
408
  if (isNewSession) this.sessionLifecycle.completeNewSession(sessionKey);
369
- return ctx;
409
+ return this.acquireSessionContext(sessionKey, ctx);
370
410
  }
371
411
 
372
412
  async getAvailableModels(chatId) {
@@ -1,6 +1,6 @@
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
5
 
6
6
  const operationalNoteMaxChars = 220;
@@ -41,13 +41,84 @@ function closeAgentSession(session) {
41
41
  }
42
42
 
43
43
  export class AgentSessionLifecycle {
44
- constructor({ logger, summarizeContext }) {
44
+ constructor({ logger, summarizeContext, cachePolicy = {} }) {
45
45
  this.logger = logger;
46
46
  this.summarizeContext = summarizeContext;
47
47
  this.sessions = new Map();
48
48
  this.pendingNewSessions = new Set();
49
49
  this.pendingSessionHandoffs = new Map();
50
50
  this.sessionClosePromises = new Map();
51
+ this.setCachePolicy(cachePolicy);
52
+ }
53
+
54
+ setCachePolicy(cachePolicy = {}) {
55
+ this.cachePolicy = {
56
+ maxSessions: Math.max(1, Number(cachePolicy.maxSessions) || 3),
57
+ maxPersistedBytes: Math.max(1, Number(cachePolicy.maxPersistedBytes) || 48 * 1024 * 1024)
58
+ };
59
+ }
60
+
61
+ acquireCached(sessionKey, persistedBytes = 0) {
62
+ const context = this.sessions.get(String(sessionKey));
63
+ if (!context) return;
64
+ context.activeUsers = (context.activeUsers || 0) + 1;
65
+ context.lastAccessedAt = Date.now();
66
+ context.persistedBytes = Math.max(0, Number(persistedBytes) || 0);
67
+ }
68
+
69
+ releaseCached(sessionKey, persistedBytes = 0) {
70
+ const context = this.sessions.get(String(sessionKey));
71
+ if (!context) return;
72
+ context.activeUsers = Math.max(0, (context.activeUsers || 0) - 1);
73
+ context.lastAccessedAt = Date.now();
74
+ context.persistedBytes = Math.max(0, Number(persistedBytes) || 0);
75
+ }
76
+
77
+ cacheUsage() {
78
+ return {
79
+ sessions: this.sessions.size,
80
+ persistedBytes: [...this.sessions.values()].reduce((total, context) => total + (context.persistedBytes || 0), 0)
81
+ };
82
+ }
83
+
84
+ cacheOverLimit() {
85
+ const usage = this.cacheUsage();
86
+ return usage.sessions > this.cachePolicy.maxSessions
87
+ || usage.persistedBytes > this.cachePolicy.maxPersistedBytes;
88
+ }
89
+
90
+ evictionCandidate(protectedKeys = new Set()) {
91
+ return [...this.sessions.entries()]
92
+ .filter(([key, context]) => !protectedKeys.has(key) && !(context.activeUsers > 0) && !context.session?.isStreaming)
93
+ .sort((left, right) => (left[1].lastAccessedAt || 0) - (right[1].lastAccessedAt || 0))[0];
94
+ }
95
+
96
+ async evictInactive({ protectedSessionKeys = [] } = {}) {
97
+ const protectedKeys = new Set(protectedSessionKeys.map(String));
98
+ const evicted = [];
99
+ let candidate = this.evictionCandidate(protectedKeys);
100
+ while (candidate) {
101
+ const [sessionKey, context] = candidate;
102
+ evicted.push({ sessionKey, persistedBytes: context.persistedBytes || 0 });
103
+ this.logger?.log("agent", `evicting inactive Pi session for chat ${sessionKey} from resident cache`);
104
+ await this.closeCached(sessionKey);
105
+ candidate = this.evictionCandidate(protectedKeys);
106
+ }
107
+ return evicted;
108
+ }
109
+
110
+ async enforceCachePolicy({ protectedSessionKeys = [] } = {}) {
111
+ const protectedKeys = new Set(protectedSessionKeys.map(String));
112
+ const evicted = [];
113
+ while (this.cacheOverLimit()) {
114
+ const candidate = this.evictionCandidate(protectedKeys);
115
+ if (!candidate) break;
116
+ const [sessionKey, context] = candidate;
117
+ evicted.push({ sessionKey, persistedBytes: context.persistedBytes || 0 });
118
+ this.logger?.log("agent", `evicting inactive Pi session for chat ${sessionKey} from resident cache`);
119
+ await this.closeCached(sessionKey);
120
+ }
121
+ return evicted;
51
122
  }
52
123
 
53
124
  closeCached(sessionKey) {
@@ -147,7 +218,12 @@ export class AgentSessionLifecycle {
147
218
 
148
219
  async getDiagnostic() {
149
220
  const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
150
- const base = { chatId };
221
+ const base = {
222
+ chatId,
223
+ activeUsers: context.activeUsers || 0,
224
+ persistedBytes: context.persistedBytes || 0,
225
+ lastAccessedAt: context.lastAccessedAt || null
226
+ };
151
227
  try {
152
228
  const stats = context.session.getSessionStats();
153
229
  const retained = this.summarizeContext(context.session.messages);
@@ -166,6 +242,7 @@ export class AgentSessionLifecycle {
166
242
  harness: "pi",
167
243
  sessions: this.sessions.size,
168
244
  closingSessions: this.sessionClosePromises.size,
245
+ cache: { ...this.cachePolicy, ...this.cacheUsage() },
169
246
  contexts
170
247
  };
171
248
  }
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
- import { piAuthFile } from "../../runtime/paths.js";
2
+ import { piAuthFile } from "../../platform/paths.js";
3
3
 
4
4
  function compareText(a, b) {
5
5
  return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { arisaHomeDir, arisaPackageDir, chatsDir, stateDir, toolStateDir, toolsDir } from "../../runtime/paths.js";
3
+ import { arisaHomeDir, arisaPackageDir, chatsDir, stateDir, toolStateDir, toolsDir } from "../../platform/paths.js";
4
4
 
5
5
  export const arisaInstallDir = fileURLToPath(new URL("../../..", import.meta.url));
6
6
  export const arisaAgentsFile = path.join(arisaPackageDir, "AGENTS.md");
@@ -0,0 +1,122 @@
1
+ import v8 from "node:v8";
2
+
3
+ const defaults = Object.freeze({
4
+ enabled: true,
5
+ softPercent: 70,
6
+ criticalPercent: 82,
7
+ waitMs: 15_000,
8
+ pollMs: 500
9
+ });
10
+
11
+ function boundedPercent(value, fallback) {
12
+ const number = Number(value);
13
+ return Number.isFinite(number) ? Math.min(99, Math.max(1, number)) : fallback;
14
+ }
15
+
16
+ function boundedMilliseconds(value, fallback, minimum, maximum) {
17
+ const number = Number(value);
18
+ return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.floor(number))) : fallback;
19
+ }
20
+
21
+ function normalizeConfig(config = {}) {
22
+ const softPercent = boundedPercent(config.softPercent, defaults.softPercent);
23
+ return {
24
+ enabled: config.enabled !== false,
25
+ softPercent,
26
+ criticalPercent: Math.max(softPercent + 1, boundedPercent(config.criticalPercent, defaults.criticalPercent)),
27
+ waitMs: boundedMilliseconds(config.waitMs, defaults.waitMs, 0, 120_000),
28
+ pollMs: boundedMilliseconds(config.pollMs, defaults.pollMs, 50, 5_000)
29
+ };
30
+ }
31
+
32
+ export function readWorkerHeapPressure({ memoryUsage = process.memoryUsage, heapStatistics = v8.getHeapStatistics } = {}) {
33
+ const heapUsed = Math.max(0, Number(memoryUsage().heapUsed) || 0);
34
+ const heapLimit = Math.max(1, Number(heapStatistics().heap_size_limit) || 1);
35
+ return {
36
+ heapUsed,
37
+ heapLimit,
38
+ percent: heapUsed / heapLimit * 100
39
+ };
40
+ }
41
+
42
+ export class WorkerHeapPressureError extends Error {
43
+ constructor(pressure) {
44
+ super(`Worker heap pressure remained critical at ${pressure.percent.toFixed(1)}%; retry this request shortly`);
45
+ this.name = "WorkerHeapPressureError";
46
+ this.code = "WORKER_HEAP_PRESSURE";
47
+ this.retryable = true;
48
+ this.pressure = pressure;
49
+ }
50
+ }
51
+
52
+ export class WorkerHeapCircuitBreaker {
53
+ constructor({ lifecycle, logger, config, measure = readWorkerHeapPressure, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now }) {
54
+ this.lifecycle = lifecycle;
55
+ this.logger = logger;
56
+ this.measure = measure;
57
+ this.sleep = sleep;
58
+ this.now = now;
59
+ this.metrics = {
60
+ pressureEvents: 0,
61
+ evictedSessions: 0,
62
+ delayedAdmissions: 0,
63
+ rejectedAdmissions: 0,
64
+ peakHeapPercent: 0
65
+ };
66
+ this.setConfig(config);
67
+ }
68
+
69
+ setConfig(config) {
70
+ this.config = normalizeConfig(config);
71
+ }
72
+
73
+ sample() {
74
+ const pressure = this.measure();
75
+ this.metrics.peakHeapPercent = Math.max(this.metrics.peakHeapPercent, pressure.percent);
76
+ return pressure;
77
+ }
78
+
79
+ async relievePressure() {
80
+ const evicted = await this.lifecycle.evictInactive();
81
+ this.metrics.evictedSessions += evicted.length;
82
+ return evicted;
83
+ }
84
+
85
+ async admit() {
86
+ let pressure = this.sample();
87
+ if (!this.config.enabled || pressure.percent < this.config.softPercent) return pressure;
88
+
89
+ this.metrics.pressureEvents += 1;
90
+ const evicted = await this.relievePressure();
91
+ pressure = this.sample();
92
+ this.logger?.log("agent", `worker heap pressure ${pressure.percent.toFixed(1)}%; evicted ${evicted.length} inactive session(s)`);
93
+ if (pressure.percent < this.config.criticalPercent) return pressure;
94
+
95
+ this.metrics.delayedAdmissions += 1;
96
+ const deadline = this.now() + this.config.waitMs;
97
+ while (this.now() < deadline) {
98
+ await this.sleep(Math.min(this.config.pollMs, Math.max(0, deadline - this.now())));
99
+ await this.relievePressure();
100
+ pressure = this.sample();
101
+ if (pressure.percent < this.config.criticalPercent) {
102
+ this.logger?.log("agent", `worker heap pressure recovered to ${pressure.percent.toFixed(1)}%`);
103
+ return pressure;
104
+ }
105
+ }
106
+
107
+ this.metrics.rejectedAdmissions += 1;
108
+ this.logger?.log("agent", `worker heap admission rejected at ${pressure.percent.toFixed(1)}%`);
109
+ throw new WorkerHeapPressureError(pressure);
110
+ }
111
+
112
+ getDiagnostic() {
113
+ const pressure = this.sample();
114
+ return {
115
+ ...this.config,
116
+ heapUsed: pressure.heapUsed,
117
+ heapLimit: pressure.heapLimit,
118
+ currentHeapPercent: pressure.percent,
119
+ ...this.metrics
120
+ };
121
+ }
122
+ }
@@ -1,7 +1,7 @@
1
1
  import { copyFile, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../runtime/paths.js";
4
+ import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../platform/paths.js";
5
5
 
6
6
  const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
7
7
  const indexOperations = new Map();
@@ -5,7 +5,7 @@ import {
5
5
  getChatToolTmpDir,
6
6
  getToolStateDir,
7
7
  getToolTmpDir
8
- } from "../../runtime/paths.js";
8
+ } from "../../platform/paths.js";
9
9
  import { ToolResourceNoteStore } from "../tools/tool-resource-note-store.js";
10
10
  import { installBundledOfficialTool } from "../tools/official-tool-installer.js";
11
11
  import { searchOfficialToolCatalog } from "../tools/official-tool-catalog.js";
@@ -19,7 +19,17 @@ export const daemonConfigDefaults = Object.freeze({
19
19
  export const toolExecutionConfigDefaults = Object.freeze({
20
20
  defaultCapacity: 2,
21
21
  maxQueuedPerClass: 100,
22
- capacities: Object.freeze({})
22
+ maxWorkerRssMb: 384,
23
+ maxSwapUsedPercent: 95,
24
+ initialToolMemoryMb: 384,
25
+ minimumToolMemoryMb: 128,
26
+ maximumToolMemoryMb: 4096,
27
+ systemReserveMb: 128,
28
+ coreReserveMb: 384,
29
+ toolHeapPercent: 65,
30
+ toolMemoryHighPercent: 85,
31
+ toolSwapMaxMb: 128,
32
+ capacities: Object.freeze({ browser: 1, orchestrator: 1 })
23
33
  });
24
34
 
25
35
  export const telegramConfigDefaults = Object.freeze({
@@ -58,6 +68,17 @@ export const taskConfigDefaults = Object.freeze({
58
68
  export const piConfigDefaults = Object.freeze({
59
69
  thinkingLevel: "medium",
60
70
  speed: 1,
71
+ sessionCache: Object.freeze({
72
+ maxSessions: 3,
73
+ maxPersistedBytes: 48 * 1024 * 1024
74
+ }),
75
+ heapCircuitBreaker: Object.freeze({
76
+ enabled: true,
77
+ softPercent: 70,
78
+ criticalPercent: 82,
79
+ waitMs: 15_000,
80
+ pollMs: 500
81
+ }),
61
82
  compaction: Object.freeze({
62
83
  enabled: true,
63
84
  reserveTokens: 120_000,
@@ -108,6 +129,14 @@ export function applyConfigDefaults(config) {
108
129
  pi: {
109
130
  ...piConfigDefaults,
110
131
  ...configuredPi,
132
+ sessionCache: {
133
+ ...piConfigDefaults.sessionCache,
134
+ ...(configuredPi.sessionCache || {})
135
+ },
136
+ heapCircuitBreaker: {
137
+ ...piConfigDefaults.heapCircuitBreaker,
138
+ ...(configuredPi.heapCircuitBreaker || {})
139
+ },
111
140
  compaction: {
112
141
  ...piConfigDefaults.compaction,
113
142
  ...(configuredPi.compaction || {})
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { configFile } from "../../runtime/paths.js";
3
+ import { configFile } from "../../platform/paths.js";
4
4
  import { applyConfigDefaults } from "./config-defaults.js";
5
5
 
6
6
  export function prepareConfigForSave(config) {
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import {
4
4
  getChatLegacyConversationHistoryFile,
5
5
  getChatSessionSeedFile
6
- } from "../../runtime/paths.js";
6
+ } from "../../platform/paths.js";
7
7
 
8
8
  function parseRecords(contents) {
9
9
  return String(contents || "")
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { tasksFile } from "../../runtime/paths.js";
4
+ import { tasksFile } from "../../platform/paths.js";
5
5
 
6
6
  const DEFAULT_RETRY = Object.freeze({
7
7
  maxAttempts: 3,